Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9716f20f81 | ||
|
|
70af56e050 |
4 changed files with 305 additions and 107 deletions
|
|
@ -153,24 +153,8 @@ async fn serve(
|
||||||
});
|
});
|
||||||
let prompt = format_wake_prompt(&label, &from, &body);
|
let prompt = format_wake_prompt(&label, &from, &body);
|
||||||
let outcome =
|
let outcome =
|
||||||
turn::run_turn(&prompt, &mcp_config, &bus, mcp::Flavor::Agent).await;
|
drive_turn(&prompt, &mcp_config, &bus, mcp::Flavor::Agent).await;
|
||||||
match outcome {
|
emit_turn_end(&bus, &outcome);
|
||||||
Ok(()) => {
|
|
||||||
bus.emit(LiveEvent::TurnEnd {
|
|
||||||
ok: true,
|
|
||||||
note: None,
|
|
||||||
});
|
|
||||||
tracing::info!("claude turn finished");
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let note = format!("{e:#}");
|
|
||||||
bus.emit(LiveEvent::TurnEnd {
|
|
||||||
ok: false,
|
|
||||||
note: Some(note.clone()),
|
|
||||||
});
|
|
||||||
tracing::warn!(error = %note, "claude turn failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(AgentResponse::Empty) => {}
|
Ok(AgentResponse::Empty) => {}
|
||||||
Ok(AgentResponse::Ok | AgentResponse::Status { .. }) => {
|
Ok(AgentResponse::Ok | AgentResponse::Status { .. }) => {
|
||||||
|
|
@ -187,6 +171,47 @@ async fn serve(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drive one turn end-to-end. If claude hits `Prompt is too long`, run
|
||||||
|
/// `/compact` against the persistent session and retry once. Returns the
|
||||||
|
/// final `TurnOutcome` to drive the `TurnEnd` live event.
|
||||||
|
async fn drive_turn(
|
||||||
|
prompt: &str,
|
||||||
|
mcp_config: &Path,
|
||||||
|
bus: &Bus,
|
||||||
|
flavor: mcp::Flavor,
|
||||||
|
) -> turn::TurnOutcome {
|
||||||
|
match turn::run_turn(prompt, mcp_config, bus, flavor).await {
|
||||||
|
turn::TurnOutcome::PromptTooLong => {
|
||||||
|
if let Err(e) = turn::compact_session(bus).await {
|
||||||
|
tracing::warn!(error = %format!("{e:#}"), "compact failed");
|
||||||
|
return turn::TurnOutcome::Failed(e);
|
||||||
|
}
|
||||||
|
turn::run_turn(prompt, mcp_config, bus, flavor).await
|
||||||
|
}
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_turn_end(bus: &Bus, outcome: &turn::TurnOutcome) {
|
||||||
|
match outcome {
|
||||||
|
turn::TurnOutcome::Ok | turn::TurnOutcome::PromptTooLong => {
|
||||||
|
bus.emit(LiveEvent::TurnEnd {
|
||||||
|
ok: true,
|
||||||
|
note: None,
|
||||||
|
});
|
||||||
|
tracing::info!("claude turn finished");
|
||||||
|
}
|
||||||
|
turn::TurnOutcome::Failed(e) => {
|
||||||
|
let note = format!("{e:#}");
|
||||||
|
bus.emit(LiveEvent::TurnEnd {
|
||||||
|
ok: false,
|
||||||
|
note: Some(note.clone()),
|
||||||
|
});
|
||||||
|
tracing::warn!(error = %note, "claude turn failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// System prompt handed to claude on each turn. The harness has already
|
/// System prompt handed to claude on each turn. The harness has already
|
||||||
/// popped one message off the inbox (the wake signal); claude is told
|
/// popped one message off the inbox (the wake signal); claude is told
|
||||||
/// about it and the MCP tools, and is expected to drive any further
|
/// about it and the MCP tools, and is expected to drive any further
|
||||||
|
|
|
||||||
|
|
@ -175,24 +175,8 @@ async fn serve(socket: &Path, interval: Duration, bus: Bus) -> Result<()> {
|
||||||
});
|
});
|
||||||
let prompt = format_wake_prompt(&label, &from, &body);
|
let prompt = format_wake_prompt(&label, &from, &body);
|
||||||
let outcome =
|
let outcome =
|
||||||
turn::run_turn(&prompt, &mcp_config, &bus, mcp::Flavor::Manager).await;
|
drive_turn(&prompt, &mcp_config, &bus, mcp::Flavor::Manager).await;
|
||||||
match outcome {
|
emit_turn_end(&bus, &outcome);
|
||||||
Ok(()) => {
|
|
||||||
bus.emit(LiveEvent::TurnEnd {
|
|
||||||
ok: true,
|
|
||||||
note: None,
|
|
||||||
});
|
|
||||||
tracing::info!("manager turn finished");
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let note = format!("{e:#}");
|
|
||||||
bus.emit(LiveEvent::TurnEnd {
|
|
||||||
ok: false,
|
|
||||||
note: Some(note.clone()),
|
|
||||||
});
|
|
||||||
tracing::warn!(error = %note, "manager turn failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(ManagerResponse::Empty) => {}
|
Ok(ManagerResponse::Empty) => {}
|
||||||
Ok(ManagerResponse::Ok | ManagerResponse::Status { .. }) => {
|
Ok(ManagerResponse::Ok | ManagerResponse::Status { .. }) => {
|
||||||
|
|
@ -209,6 +193,46 @@ async fn serve(socket: &Path, interval: Duration, bus: Bus) -> Result<()> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drive one manager turn end-to-end with the same overflow-then-compact
|
||||||
|
/// retry as sub-agents.
|
||||||
|
async fn drive_turn(
|
||||||
|
prompt: &str,
|
||||||
|
mcp_config: &Path,
|
||||||
|
bus: &Bus,
|
||||||
|
flavor: mcp::Flavor,
|
||||||
|
) -> turn::TurnOutcome {
|
||||||
|
match turn::run_turn(prompt, mcp_config, bus, flavor).await {
|
||||||
|
turn::TurnOutcome::PromptTooLong => {
|
||||||
|
if let Err(e) = turn::compact_session(bus).await {
|
||||||
|
tracing::warn!(error = %format!("{e:#}"), "compact failed");
|
||||||
|
return turn::TurnOutcome::Failed(e);
|
||||||
|
}
|
||||||
|
turn::run_turn(prompt, mcp_config, bus, flavor).await
|
||||||
|
}
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_turn_end(bus: &Bus, outcome: &turn::TurnOutcome) {
|
||||||
|
match outcome {
|
||||||
|
turn::TurnOutcome::Ok | turn::TurnOutcome::PromptTooLong => {
|
||||||
|
bus.emit(LiveEvent::TurnEnd {
|
||||||
|
ok: true,
|
||||||
|
note: None,
|
||||||
|
});
|
||||||
|
tracing::info!("manager turn finished");
|
||||||
|
}
|
||||||
|
turn::TurnOutcome::Failed(e) => {
|
||||||
|
let note = format!("{e:#}");
|
||||||
|
bus.emit(LiveEvent::TurnEnd {
|
||||||
|
ok: false,
|
||||||
|
note: Some(note.clone()),
|
||||||
|
});
|
||||||
|
tracing::warn!(error = %note, "manager turn failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Manager-flavored wake prompt. Mentions the privileged tools the sub-agent
|
/// Manager-flavored wake prompt. Mentions the privileged tools the sub-agent
|
||||||
/// prompt doesn't have access to, and points the manager at its own
|
/// prompt doesn't have access to, and points the manager at its own
|
||||||
/// editable config repo for self-modification.
|
/// editable config repo for self-modification.
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
use anyhow::{Result, bail};
|
use anyhow::{Result, bail};
|
||||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
|
|
@ -13,6 +15,21 @@ use tokio::process::Command;
|
||||||
use crate::events::{Bus, LiveEvent};
|
use crate::events::{Bus, LiveEvent};
|
||||||
use crate::mcp;
|
use crate::mcp;
|
||||||
|
|
||||||
|
/// Inline `--settings` JSON applied to every claude invocation. We turn off
|
||||||
|
/// claude's in-session auto-compaction and its cross-session auto-memory
|
||||||
|
/// because hyperhive owns those concerns: compaction is operator/harness-
|
||||||
|
/// driven (`/compact` on overflow), notes persistence is a hyperhive
|
||||||
|
/// concern (planned, not yet wired). Unknown keys are silently ignored by
|
||||||
|
/// claude-code; if the key names ever rename, we'll spot it because
|
||||||
|
/// auto-compact will start firing mid-turn again.
|
||||||
|
const CLAUDE_SETTINGS: &str = r#"{"autoCompactEnabled":false,"autoMemoryEnabled":false}"#;
|
||||||
|
|
||||||
|
/// Regex-ish marker claude-code emits when context overflows. Same string
|
||||||
|
/// bitburner-agent watches for. Empirically reliable across claude-code
|
||||||
|
/// versions; if it ever changes, compaction won't fire and we'll see a
|
||||||
|
/// claude exit with a useful error in the live view.
|
||||||
|
const PROMPT_TOO_LONG_MARKER: &str = "Prompt is too long";
|
||||||
|
|
||||||
/// Drop the MCP config blob claude reads from `--mcp-config <path>`.
|
/// Drop the MCP config blob claude reads from `--mcp-config <path>`.
|
||||||
/// `socket` is the hyperhive per-container socket (forwarded to the child
|
/// `socket` is the hyperhive per-container socket (forwarded to the child
|
||||||
/// as `--socket <path>`); `binary_subcommand` is e.g. `"mcp"` for sub-agents
|
/// as `--socket <path>`); `binary_subcommand` is e.g. `"mcp"` for sub-agents
|
||||||
|
|
@ -31,30 +48,88 @@ pub async fn write_mcp_config(socket: &Path) -> Result<PathBuf> {
|
||||||
Ok(path)
|
Ok(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One claude turn's outcome. The harness uses this to decide whether to
|
||||||
|
/// transparently kick off a compaction and retry.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum TurnOutcome {
|
||||||
|
Ok,
|
||||||
|
/// claude saw "Prompt is too long" — the session needs compacting.
|
||||||
|
/// Run `compact_session()` then retry the same wake-up prompt.
|
||||||
|
PromptTooLong,
|
||||||
|
Failed(anyhow::Error),
|
||||||
|
}
|
||||||
|
|
||||||
/// Spawn `claude` for one turn and pump `stream-json` stdout into the
|
/// Spawn `claude` for one turn and pump `stream-json` stdout into the
|
||||||
/// live event bus. Prompt goes over stdin (variadic
|
/// live event bus. Prompt goes over stdin (variadic
|
||||||
/// `--allowedTools`/`--tools` would otherwise eat a trailing positional
|
/// `--allowedTools`/`--tools` would otherwise eat a trailing positional
|
||||||
/// prompt). On non-zero exit returns an error; the caller emits the
|
/// prompt). The session is persistent across turns via `--continue` and
|
||||||
/// `TurnEnd` event.
|
/// claude's in-session auto-compact is disabled via `--settings` so it
|
||||||
|
/// doesn't stall mid-turn — hyperhive owns compaction.
|
||||||
pub async fn run_turn(
|
pub async fn run_turn(
|
||||||
prompt: &str,
|
prompt: &str,
|
||||||
mcp_config: &Path,
|
mcp_config: &Path,
|
||||||
bus: &Bus,
|
bus: &Bus,
|
||||||
flavor: mcp::Flavor,
|
flavor: mcp::Flavor,
|
||||||
) -> Result<()> {
|
) -> TurnOutcome {
|
||||||
let mut child = Command::new("claude")
|
match run_claude(prompt, mcp_config, bus, flavor, ClaudeMode::Turn).await {
|
||||||
.arg("--print")
|
Ok(too_long) if too_long => TurnOutcome::PromptTooLong,
|
||||||
|
Ok(_) => TurnOutcome::Ok,
|
||||||
|
Err(e) => TurnOutcome::Failed(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run claude's built-in `/compact` slash command on the persistent
|
||||||
|
/// session so the next turn can fit. No MCP tools needed; we just feed
|
||||||
|
/// `/compact` over stdin and let claude rewrite its own history.
|
||||||
|
pub async fn compact_session(bus: &Bus) -> Result<()> {
|
||||||
|
bus.emit(LiveEvent::Note(
|
||||||
|
"context overflow — running /compact on the persistent session".into(),
|
||||||
|
));
|
||||||
|
let _ = run_claude(
|
||||||
|
"/compact",
|
||||||
|
Path::new("/dev/null"),
|
||||||
|
bus,
|
||||||
|
mcp::Flavor::Agent, // tool surface unused for /compact
|
||||||
|
ClaudeMode::Compact,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
bus.emit(LiveEvent::Note("/compact done".into()));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum ClaudeMode {
|
||||||
|
Turn,
|
||||||
|
Compact,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_claude(
|
||||||
|
prompt: &str,
|
||||||
|
mcp_config: &Path,
|
||||||
|
bus: &Bus,
|
||||||
|
flavor: mcp::Flavor,
|
||||||
|
mode: ClaudeMode,
|
||||||
|
) -> Result<bool> {
|
||||||
|
let mut cmd = Command::new("claude");
|
||||||
|
cmd.arg("--print")
|
||||||
.arg("--verbose")
|
.arg("--verbose")
|
||||||
.arg("--output-format")
|
.arg("--output-format")
|
||||||
.arg("stream-json")
|
.arg("stream-json")
|
||||||
.arg("--model")
|
.arg("--model")
|
||||||
.arg("haiku")
|
.arg("haiku")
|
||||||
.arg("--mcp-config")
|
.arg("--continue")
|
||||||
.arg(mcp_config)
|
.arg("--settings")
|
||||||
.arg("--tools")
|
.arg(CLAUDE_SETTINGS);
|
||||||
.arg(mcp::builtin_tools_arg())
|
if let ClaudeMode::Turn = mode {
|
||||||
.arg("--allowedTools")
|
cmd.arg("--mcp-config")
|
||||||
.arg(mcp::allowed_tools_arg(flavor))
|
.arg(mcp_config)
|
||||||
|
.arg("--strict-mcp-config")
|
||||||
|
.arg("--tools")
|
||||||
|
.arg(mcp::builtin_tools_arg())
|
||||||
|
.arg("--allowedTools")
|
||||||
|
.arg(mcp::allowed_tools_arg(flavor));
|
||||||
|
}
|
||||||
|
let mut child = cmd
|
||||||
.stdin(Stdio::piped())
|
.stdin(Stdio::piped())
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::piped())
|
.stderr(Stdio::piped())
|
||||||
|
|
@ -68,11 +143,17 @@ pub async fn run_turn(
|
||||||
let stdout = child.stdout.take().expect("piped stdout");
|
let stdout = child.stdout.take().expect("piped stdout");
|
||||||
let stderr = child.stderr.take().expect("piped stderr");
|
let stderr = child.stderr.take().expect("piped stderr");
|
||||||
|
|
||||||
|
let prompt_too_long = Arc::new(AtomicBool::new(false));
|
||||||
|
let flag_out = prompt_too_long.clone();
|
||||||
|
let flag_err = prompt_too_long.clone();
|
||||||
let bus_out = bus.clone();
|
let bus_out = bus.clone();
|
||||||
let bus_err = bus.clone();
|
let bus_err = bus.clone();
|
||||||
let pump_stdout = tokio::spawn(async move {
|
let pump_stdout = tokio::spawn(async move {
|
||||||
let mut reader = BufReader::new(stdout).lines();
|
let mut reader = BufReader::new(stdout).lines();
|
||||||
while let Ok(Some(line)) = reader.next_line().await {
|
while let Ok(Some(line)) = reader.next_line().await {
|
||||||
|
if line.contains(PROMPT_TOO_LONG_MARKER) {
|
||||||
|
flag_out.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
match serde_json::from_str::<serde_json::Value>(&line) {
|
match serde_json::from_str::<serde_json::Value>(&line) {
|
||||||
Ok(v) => bus_out.emit(LiveEvent::Stream(v)),
|
Ok(v) => bus_out.emit(LiveEvent::Stream(v)),
|
||||||
Err(_) => bus_out.emit(LiveEvent::Note(format!("(non-json) {line}"))),
|
Err(_) => bus_out.emit(LiveEvent::Note(format!("(non-json) {line}"))),
|
||||||
|
|
@ -82,6 +163,9 @@ pub async fn run_turn(
|
||||||
let pump_stderr = tokio::spawn(async move {
|
let pump_stderr = tokio::spawn(async move {
|
||||||
let mut reader = BufReader::new(stderr).lines();
|
let mut reader = BufReader::new(stderr).lines();
|
||||||
while let Ok(Some(line)) = reader.next_line().await {
|
while let Ok(Some(line)) = reader.next_line().await {
|
||||||
|
if line.contains(PROMPT_TOO_LONG_MARKER) {
|
||||||
|
flag_err.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
bus_err.emit(LiveEvent::Note(format!("stderr: {line}")));
|
bus_err.emit(LiveEvent::Note(format!("stderr: {line}")));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -89,8 +173,9 @@ pub async fn run_turn(
|
||||||
let status = child.wait().await?;
|
let status = child.wait().await?;
|
||||||
let _ = pump_stdout.await;
|
let _ = pump_stdout.await;
|
||||||
let _ = pump_stderr.await;
|
let _ = pump_stderr.await;
|
||||||
if !status.success() {
|
let too_long = prompt_too_long.load(Ordering::Relaxed);
|
||||||
|
if !status.success() && !too_long {
|
||||||
bail!("claude exited {status}");
|
bail!("claude exited {status}");
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(too_long)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -114,81 +114,109 @@ fn render_online(label: &str) -> String {
|
||||||
/// reload, so the login flow and other forms aren't clobbered.
|
/// reload, so the login flow and other forms aren't clobbered.
|
||||||
const LIVE_PANEL: &str = r#"
|
const LIVE_PANEL: &str = r#"
|
||||||
<h3>live</h3>
|
<h3>live</h3>
|
||||||
<pre id="live" class="diff"><span class="meta">connecting…</span></pre>
|
<div id="live" class="live"><div class="meta">connecting…</div></div>
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
const log = document.getElementById('live');
|
const log = document.getElementById('live');
|
||||||
let placeholder = log.firstChild;
|
let placeholder = log.firstChild;
|
||||||
function setPlaceholder(text, cls) {
|
function setPlaceholder(text) {
|
||||||
log.innerHTML = '';
|
log.innerHTML = '';
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('div');
|
||||||
span.className = cls || 'meta';
|
span.className = 'meta';
|
||||||
span.textContent = text;
|
span.textContent = text;
|
||||||
log.appendChild(span);
|
log.appendChild(span);
|
||||||
placeholder = span;
|
placeholder = span;
|
||||||
}
|
}
|
||||||
function appendLine(text, cls) {
|
function clearPlaceholder() {
|
||||||
if (placeholder) { log.innerHTML = ''; placeholder = null; }
|
if (placeholder) { log.innerHTML = ''; placeholder = null; }
|
||||||
const row = document.createElement('span');
|
}
|
||||||
if (cls) row.className = cls;
|
function row(cls, text) {
|
||||||
row.textContent = text + '\n';
|
clearPlaceholder();
|
||||||
log.appendChild(row);
|
const el = document.createElement('div');
|
||||||
|
el.className = 'row ' + (cls || '');
|
||||||
|
el.textContent = text;
|
||||||
|
log.appendChild(el);
|
||||||
log.scrollTop = log.scrollHeight;
|
log.scrollTop = log.scrollHeight;
|
||||||
|
return el;
|
||||||
}
|
}
|
||||||
function fmt(ev) {
|
function trim(s, n) {
|
||||||
if (ev.kind === 'turn_start') return '◆ TURN ← ' + ev.from + ': ' + ev.body;
|
return s.length > n ? s.slice(0, n) + '…' : s;
|
||||||
if (ev.kind === 'turn_end') return '◇ TURN END ' + (ev.ok ? 'ok' : 'fail') + (ev.note ? ' — ' + ev.note : '');
|
}
|
||||||
if (ev.kind === 'note') return '· ' + ev.text;
|
function renderStream(v) {
|
||||||
if (ev.kind === 'stream') {
|
if (v.type === 'system' && v.subtype === 'init') {
|
||||||
// serde internal tagging flattens the inner json next to `kind`,
|
row('sys', '· session init · tools=' + (v.tools||[]).length + ' model=' + (v.model || '?'));
|
||||||
// so the original stream-json event sits under `ev` minus `kind`.
|
return;
|
||||||
const v = Object.assign({}, ev); delete v.kind;
|
|
||||||
if (v.type === 'system' && v.subtype === 'init') return '[init] tools=' + (v.tools||[]).length;
|
|
||||||
if (v.type === 'assistant' && v.message && v.message.content) {
|
|
||||||
const parts = v.message.content.map(c => {
|
|
||||||
if (c.type === 'text') return c.text;
|
|
||||||
if (c.type === 'tool_use') return '→ ' + c.name + '(' + JSON.stringify(c.input) + ')';
|
|
||||||
return c.type;
|
|
||||||
});
|
|
||||||
return parts.join('\n');
|
|
||||||
}
|
|
||||||
if (v.type === 'user' && v.message && v.message.content) {
|
|
||||||
const parts = v.message.content.map(c => {
|
|
||||||
if (c.type === 'tool_result') {
|
|
||||||
const txt = Array.isArray(c.content) ? c.content.map(p => p.text || '').join(' ') : (c.content || '');
|
|
||||||
return '← ' + (txt.length > 200 ? txt.slice(0,200) + '…' : txt);
|
|
||||||
}
|
|
||||||
return c.type;
|
|
||||||
});
|
|
||||||
return parts.join('\n');
|
|
||||||
}
|
|
||||||
if (v.type === 'result') return '[done] ' + (v.subtype || '') + (v.is_error ? ' error' : '');
|
|
||||||
return JSON.stringify(v);
|
|
||||||
}
|
}
|
||||||
return JSON.stringify(ev);
|
if (v.type === 'rate_limit_event') {
|
||||||
|
const u = Math.round((v.rate_limit_info?.utilization || 0) * 100);
|
||||||
|
const s = v.rate_limit_info?.status || '';
|
||||||
|
row('sys', '· rate-limit util=' + u + '% (' + s + ')');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (v.type === 'assistant' && v.message && v.message.content) {
|
||||||
|
for (const c of v.message.content) {
|
||||||
|
if (c.type === 'text' && c.text && c.text.trim())
|
||||||
|
row('text', c.text);
|
||||||
|
else if (c.type === 'thinking')
|
||||||
|
row('thinking', '· thinking …');
|
||||||
|
else if (c.type === 'tool_use')
|
||||||
|
row('tool-use', '→ ' + c.name + ' ' + trim(JSON.stringify(c.input || {}), 240));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (v.type === 'user' && v.message && v.message.content) {
|
||||||
|
for (const c of v.message.content) {
|
||||||
|
if (c.type === 'tool_result') {
|
||||||
|
const txt = Array.isArray(c.content)
|
||||||
|
? c.content.map(p => p.text || '').join(' ')
|
||||||
|
: (c.content || '');
|
||||||
|
row('tool-result', '← ' + trim(txt, 300));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (v.type === 'result') {
|
||||||
|
row('result', '✓ done · ' + (v.subtype || '') + (v.is_error ? ' [error]' : ''));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Fallback: small one-liner for unknown events; don't spam.
|
||||||
|
row('sys', '· ' + trim(JSON.stringify(v), 200));
|
||||||
}
|
}
|
||||||
function cls(ev) {
|
function handle(ev) {
|
||||||
if (ev.kind === 'turn_start') return 'turnstart';
|
if (ev.kind === 'turn_start') {
|
||||||
if (ev.kind === 'turn_end') return ev.ok ? 'turnok' : 'turnfail';
|
const block = row('turn-start', '◆ TURN ← ' + ev.from);
|
||||||
if (ev.kind === 'note') return 'meta';
|
const body = document.createElement('div');
|
||||||
return '';
|
body.className = 'turn-body';
|
||||||
|
body.textContent = ev.body;
|
||||||
|
block.appendChild(body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ev.kind === 'turn_end') {
|
||||||
|
const cls = ev.ok ? 'turn-end-ok' : 'turn-end-fail';
|
||||||
|
const sym = ev.ok ? '✓' : '✗';
|
||||||
|
row(cls, sym + ' turn ' + (ev.ok ? 'ok' : 'fail') + (ev.note ? ' — ' + ev.note : ''));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ev.kind === 'note') {
|
||||||
|
row('note', '· ' + ev.text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ev.kind === 'stream') {
|
||||||
|
const v = Object.assign({}, ev); delete v.kind;
|
||||||
|
renderStream(v);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
row('note', JSON.stringify(ev));
|
||||||
}
|
}
|
||||||
const es = new EventSource('/events/stream');
|
const es = new EventSource('/events/stream');
|
||||||
es.onopen = function() { setPlaceholder('(connected — waiting for events)'); };
|
es.onopen = function() { setPlaceholder('(connected — waiting for events)'); };
|
||||||
es.onmessage = function(e) {
|
es.onmessage = function(e) {
|
||||||
try {
|
try { handle(JSON.parse(e.data)); }
|
||||||
const ev = JSON.parse(e.data);
|
catch (err) { row('note', '[parse err] ' + e.data); }
|
||||||
appendLine(fmt(ev), cls(ev));
|
|
||||||
} catch (err) {
|
|
||||||
appendLine('[parse err] ' + e.data, 'meta');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
es.onerror = function() {
|
es.onerror = function() {
|
||||||
if (es.readyState === EventSource.CONNECTING) {
|
if (es.readyState === EventSource.CONNECTING) setPlaceholder('(reconnecting…)');
|
||||||
setPlaceholder('(reconnecting…)');
|
else row('note', '[disconnected]');
|
||||||
} else {
|
|
||||||
appendLine('[disconnected]', 'meta');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -437,10 +465,46 @@ const STYLE: &str = r#"
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
max-height: 30em;
|
max-height: 30em;
|
||||||
}
|
}
|
||||||
#live { max-height: 24em; overflow-y: auto; }
|
.live {
|
||||||
#live span { display: block; }
|
background: rgba(255, 255, 255, 0.02);
|
||||||
#live .turnstart { color: var(--amber); }
|
border: 1px solid var(--purple-dim);
|
||||||
#live .turnok { color: var(--green); }
|
padding: 0.4em 0.6em;
|
||||||
#live .turnfail { color: #ff6b6b; }
|
overflow-y: auto;
|
||||||
|
max-height: 32em;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.live .row {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
padding: 0.05em 0;
|
||||||
|
line-height: 1.45;
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
padding-left: 0.5em;
|
||||||
|
margin: 0.1em 0;
|
||||||
|
}
|
||||||
|
.live .row + .row { border-top: 0; }
|
||||||
|
.live .turn-start {
|
||||||
|
color: var(--amber);
|
||||||
|
font-weight: bold;
|
||||||
|
margin-top: 1em;
|
||||||
|
border-left-color: var(--amber);
|
||||||
|
padding-top: 0.3em;
|
||||||
|
}
|
||||||
|
.live .turn-start:first-child { margin-top: 0; }
|
||||||
|
.live .turn-body {
|
||||||
|
color: var(--fg);
|
||||||
|
font-weight: normal;
|
||||||
|
margin-top: 0.15em;
|
||||||
|
padding-left: 1.2em;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.live .turn-end-ok { color: #66ff99; border-left-color: #66ff99; margin-bottom: 0.4em; }
|
||||||
|
.live .turn-end-fail { color: #ff6b6b; border-left-color: #ff6b6b; margin-bottom: 0.4em; }
|
||||||
|
.live .text { color: var(--fg); padding-left: 1.2em; }
|
||||||
|
.live .thinking { color: var(--muted); font-style: italic; padding-left: 1.2em; }
|
||||||
|
.live .tool-use { color: #66e0ff; padding-left: 1.2em; }
|
||||||
|
.live .tool-result { color: var(--muted); padding-left: 1.2em; }
|
||||||
|
.live .result { color: var(--green); padding-left: 0.5em; }
|
||||||
|
.live .sys, .live .note { color: var(--muted); }
|
||||||
</style>
|
</style>
|
||||||
"#;
|
"#;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue