From 8fc9381d6c799a55ccb0c2928ea8aac95c4300c3 Mon Sep 17 00:00:00 2001 From: Martin Patino Date: Fri, 2 Oct 2026 17:25:42 -0700 Subject: [PATCH 1/3] feat(monitor): mark pstack trees and track turns, prompts, and pending calls Adapters now flag pstack work (Claude attribution, pstack skills, agents, and commands; Codex prompts and runner calls; every lane) and report each agent's prompt and the tool call awaiting its result. Claude sessions without a process record and Claude subagents follow turn boundaries, so a subagent finishes when its own turn ends. The lane journal records the head of the prompt. --- .../poteto-mode/scripts/monitor/adapter.ts | 9 ++ .../scripts/monitor/adapters/blocks.ts | 29 ++++- .../scripts/monitor/adapters/claude.test.ts | 64 ++++++++++ .../scripts/monitor/adapters/claude.ts | 40 ++++++- .../scripts/monitor/adapters/codex.test.ts | 28 +++++ .../scripts/monitor/adapters/codex.ts | 22 +++- .../scripts/monitor/adapters/lane.test.ts | 39 ++++++ .../scripts/monitor/adapters/lane.ts | 22 +++- .../poteto-mode/scripts/monitor/domain.ts | 16 +++ .../scripts/monitor/layout.test.ts | 3 + .../poteto-mode/scripts/monitor/store.test.ts | 113 ++++++++++++++++++ .../poteto-mode/scripts/monitor/store.ts | 69 ++++++++++- .../scripts/runner/flex-journal.test.ts | 17 ++- .../scripts/runner/flex-journal.ts | 15 ++- 14 files changed, 461 insertions(+), 25 deletions(-) diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/adapter.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/adapter.ts index f366ac45..67392bf9 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/adapter.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/adapter.ts @@ -75,6 +75,15 @@ export type Fact = readonly at: string | null; } | { readonly kind: "activity"; readonly id: AgentId; readonly activity: Activity } + | { readonly kind: "pstack"; readonly id: AgentId } + | { readonly kind: "prompt"; readonly id: AgentId; readonly text: string; readonly at: string | null } + | { + readonly kind: "call"; + readonly id: AgentId; + readonly callId: string; + readonly at: string | null; + readonly event: { readonly kind: "started"; readonly name: string; readonly snippet: string } | { readonly kind: "ended" }; + } // `key: null` is a cumulative total that replaces earlier usage. | { readonly kind: "usage"; diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/blocks.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/blocks.ts index e874e548..9deac3eb 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/blocks.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/blocks.ts @@ -7,6 +7,8 @@ import { array, clip, contentText, object, oneLine, pretty, text } from "../json export const BODY_LIMIT = 16_000; export const SNIPPET_LIMIT = 140; +export const PROMPT_LIMIT = 300; +export const PSTACK_PREFIX = "pstack:"; const SPAWN_TOOLS = new Set(["Agent", "Task"]); @@ -39,6 +41,22 @@ export function describeTool(name: string, input: unknown): string { return detail === null ? name : `${name} · ${oneLine(detail, SNIPPET_LIMIT)}`; } +export function promptFact(agent: AgentId, body: string, at: string | null): Fact { + return { kind: "prompt", id: agent, text: oneLine(body, PROMPT_LIMIT), at }; +} + +export function callEnded(agent: AgentId, callId: string, at: string | null): Fact { + return { kind: "call", id: agent, callId, at, event: { kind: "ended" } }; +} + +/** A pstack skill or a pstack agent named by a Claude-style tool call. */ +export function namesPstack(name: string, input: unknown): boolean { + const record = object(input); + if (record === null) return false; + const target = name === "Skill" ? text(record.skill) : SPAWN_TOOLS.has(name) ? text(record.subagent_type) : null; + return target?.startsWith(PSTACK_PREFIX) === true; +} + export function textActivity(agent: AgentId, body: string, at: string | null): Fact { return { kind: "activity", id: agent, activity: { what: "text", snippet: oneLine(body, SNIPPET_LIMIT), at } }; } @@ -74,13 +92,14 @@ export function assistantBlocks( case "mcp_tool_use": { const name = text(block.name) ?? "tool"; const callId = text(block.id) ?? id; + const snippet = describeTool(name, block.input); out.items.push({ id, at, kind: "tool-call", callId, name, input: clip(pretty(block.input), BODY_LIMIT) }); - out.facts.push({ - kind: "activity", - id: agent, - activity: { what: "tool", snippet: describeTool(name, block.input), at }, - }); + out.facts.push( + { kind: "activity", id: agent, activity: { what: "tool", snippet, at } }, + { kind: "call", id: agent, callId, at, event: { kind: "started", name, snippet } }, + ); if (SPAWN_TOOLS.has(name)) out.facts.push({ kind: "spawn-call", by: agent, callId }); + if (namesPstack(name, block.input)) out.facts.push({ kind: "pstack", id: agent }); return; } } diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/claude.test.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/claude.test.ts index a3357344..11d3113f 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/claude.test.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/claude.test.ts @@ -88,6 +88,68 @@ describe("session transcript", () => { expect(command.items[0]).toMatchObject({ kind: "notice", text: "ran /model" }); }); + it("opens a turn on a human prompt and closes it only at the end of the reply", () => { + const turns = (record: Record) => parser().line(line(record), 0).facts.filter((fact) => fact.kind === "turn"); + const reply = (stop_reason: string | null) => turns({ type: "assistant", message: { id: "m1", stop_reason, content: [{ type: "text", text: "ok" }] } }); + expect(turns({ type: "user", message: { role: "user", content: "Build the monitor" } })).toEqual([ + { kind: "turn", id: "claude:s1", turnId: "turn", at: "2026-10-01T10:00:00.000Z", event: { kind: "started" } }, + ] as never); + expect(reply("tool_use")).toEqual([]); + expect(reply(null)).toEqual([]); + expect(reply("end_turn")).toMatchObject([{ event: { kind: "ended", outcome: "done" } }]); + expect(reply("stop_sequence")).toMatchObject([{ event: { kind: "ended", outcome: "done" } }]); + expect(turns({ type: "user", message: { role: "user", content: [{ type: "text", text: "[Request interrupted by user]" }] } })) + .toMatchObject([{ event: { kind: "ended", outcome: "cancelled" } }]); + expect(turns({ type: "user", isMeta: true, message: { role: "user", content: "caveat" } })).toEqual([]); + expect(turns({ type: "user", message: { role: "user", content: "/model" } })).toEqual([]); + }); + + it("records the prompt an agent is working on", () => { + const prompt = parser().line(line({ type: "user", message: { role: "user", content: "Build the\nmonitor" } }), 0); + expect(prompt.facts).toContainEqual({ kind: "prompt", id: "claude:s1", text: "Build the monitor", at: "2026-10-01T10:00:00.000Z" } as never); + const reminder = parser().line(line({ type: "user", message: { role: "user", content: "x" } }), 0); + expect(kinds(reminder.facts)).not.toContain("prompt"); + }); + + it("reports the tool call awaiting its result", () => { + const call = parser().line(line({ + type: "assistant", + message: { id: "m1", content: [{ type: "tool_use", id: "call-1", name: "Bash", input: { command: "bun test" } }] }, + }), 0); + expect(call.facts).toContainEqual({ + kind: "call", + id: "claude:s1", + callId: "call-1", + at: "2026-10-01T10:00:00.000Z", + event: { kind: "started", name: "Bash", snippet: "Bash · bun test" }, + } as never); + const result = (extra: Record) => parser().line(line({ + type: "user", + ...extra, + message: { role: "user", content: [{ type: "tool_result", tool_use_id: "call-1", content: "ok" }] }, + }), 0); + const ended = { kind: "call", id: "claude:s1", callId: "call-1", at: "2026-10-01T10:00:00.000Z", event: { kind: "ended" } }; + expect(result({}).facts).toContainEqual(ended as never); + expect(result({ isMeta: true }).facts).toContainEqual(ended as never); + }); + + it("flags pstack work from attribution, skills, agents, and commands", () => { + const flagged = (record: Record) => kinds(parser().line(line(record), 0).facts).includes("pstack"); + const assistant = (extra: Record, content: unknown[] = [{ type: "text", text: "ok" }]) => + ({ type: "assistant", ...extra, message: { id: "m1", content } }); + const tool = (name: string, input: Record) => assistant({}, [{ type: "tool_use", id: "c1", name, input }]); + expect(flagged(assistant({ attributionPlugin: "pstack" }))).toBe(true); + expect(flagged(assistant({ attributionSkill: "pstack:poteto-mode" }))).toBe(true); + expect(flagged(assistant({ attributionAgent: "pstack:pstack-opus-high" }))).toBe(true); + expect(flagged(assistant({ attributionPlugin: "caveman" }))).toBe(false); + expect(flagged(tool("Skill", { skill: "pstack:arena" }))).toBe(true); + expect(flagged(tool("Skill", { skill: "impeccable" }))).toBe(false); + expect(flagged(tool("Agent", { subagent_type: "pstack:pstack-fable-high", prompt: "x" }))).toBe(true); + expect(flagged(tool("Agent", { subagent_type: "Explore", prompt: "x" }))).toBe(false); + expect(flagged({ type: "user", message: { role: "user", content: "/pstack:monitor" } })).toBe(true); + expect(flagged({ type: "user", message: { role: "user", content: "/model" } })).toBe(false); + }); + it("turns SendMessage calls into messages, leaving other sessions' sockets out", () => { const send = (to: string) => parser().line(line({ type: "assistant", @@ -148,6 +210,8 @@ describe("documents", () => { ] as never); const stopped = adapter.document(metaPath, JSON.stringify({ agentType: "Explore", toolUseId: "call-1", stoppedByUser: true })); expect(kinds(stopped.facts)).toContain("outcome"); + const lane = adapter.document(metaPath, JSON.stringify({ agentType: "pstack:pstack-opus-high", toolUseId: "call-1" })); + expect(lane.facts).toContainEqual({ kind: "pstack", id: "claude:s1:a1" } as never); }); it("turns a process record into a probe-able process", () => { diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/claude.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/claude.ts index 488eaaf5..d59db787 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/claude.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/claude.ts @@ -26,8 +26,11 @@ import { import { assistantBlocks, BODY_LIMIT, + callEnded, collected, itemId, + promptFact, + PSTACK_PREFIX, toolResult, } from "./blocks.ts"; @@ -73,6 +76,17 @@ const RESULT_STATUS: Record = { cancelled: "cancelled", }; +/** Claude Code turns never overlap within one transcript, so one id serves them all. */ +const TURN = "turn"; + +const TURN_ENDS = new Set(["end_turn", "stop_sequence"]); + +function pstackAttribution(record: Record): boolean { + return text(record.attributionPlugin) === "pstack" + || text(record.attributionSkill)?.startsWith(PSTACK_PREFIX) === true + || text(record.attributionAgent)?.startsWith(PSTACK_PREFIX) === true; +} + type Location = | { readonly kind: "session"; readonly session: string } | { readonly kind: "subagent"; readonly session: string; readonly agent: string } @@ -199,6 +213,13 @@ class TranscriptParser implements LineParser { out.facts.push({ kind: "usage", id: this.agent, key: text(message.id) ?? `${offset}`, usage }); } assistantBlocks(this.agent, offset, at, message.content, out); + if (pstackAttribution(record) && !out.facts.some((fact) => fact.kind === "pstack")) { + out.facts.push({ kind: "pstack", id: this.agent }); + } + // Partial records carry a null stop reason and `tool_use` waits on results; only these end the turn. + if (TURN_ENDS.has(text(message.stop_reason) ?? "")) { + out.facts.push({ kind: "turn", id: this.agent, turnId: TURN, at, event: { kind: "ended", outcome: "done", reason: null } }); + } for (const block of array(message.content)) { const call = object(block); if (call?.type !== "tool_use" || call.name !== "SendMessage") continue; @@ -251,7 +272,13 @@ class TranscriptParser implements LineParser { } } - if (record.isMeta === true) return parsed(facts, items, cliVersion); + if (record.isMeta === true) { + for (const block of array(content)) { + const callId = text(object(block)?.tool_use_id); + if (object(block)?.type === "tool_result" && callId !== null) facts.push(callEnded(this.agent, callId, at)); + } + return parsed(facts, items, cliVersion); + } if (typeof content === "string") { this.prompt(content, offset, 0, at, facts, items); @@ -263,6 +290,7 @@ class TranscriptParser implements LineParser { if (block.type === "tool_result") { const item = toolResult(offset, index, at, block); if (item !== null) items.push(item); + if (item?.kind === "tool-result") facts.push(callEnded(this.agent, item.callId, at)); } else if (block.type === "text") { const body = text(block.text); if (body !== null) this.prompt(body, offset, index, at, facts, items); @@ -284,15 +312,22 @@ class TranscriptParser implements LineParser { const command = tag(body, "command-name"); if (command !== null) { items.push({ id: itemId(offset, index), at, kind: "notice", level: "info", text: `ran ${oneLine(command, 80)}` }); + // No turn: a built-in command such as /model gets no reply that would close it. + if (command.replace(/^\//, "").startsWith(PSTACK_PREFIX)) facts.push({ kind: "pstack", id: this.agent }); return; } if (body.startsWith("")) return; if (body.startsWith("[Request interrupted")) { items.push({ id: itemId(offset, index), at, kind: "notice", level: "info", text: "interrupted by the user" }); + facts.push({ kind: "turn", id: this.agent, turnId: TURN, at, event: { kind: "ended", outcome: "cancelled", reason: "interrupted by the user" } }); return; } items.push({ id: itemId(offset, index), at, kind: "prompt", body: clip(body, BODY_LIMIT) }); - facts.push({ kind: "agent", id: this.agent, patch: { titleHint: oneLine(body, 80) } }); + facts.push( + { kind: "agent", id: this.agent, patch: { titleHint: oneLine(body, 80) } }, + { kind: "turn", id: this.agent, turnId: TURN, at, event: { kind: "started" } }, + promptFact(this.agent, body, at), + ); } } @@ -393,6 +428,7 @@ export function claudeAdapter(home: string): Adapter { } else if (text(record.teamName) !== null) { facts.push({ kind: "link", id, parent: root, via: "team" }); } + if (text(record.agentType)?.startsWith(PSTACK_PREFIX) === true) facts.push({ kind: "pstack", id }); if (record.stoppedByUser === true) { facts.push({ kind: "outcome", id, outcome: "cancelled", at: null, reason: "stopped by the user" }); } diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/codex.test.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/codex.test.ts index a08dd149..4ede0431 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/codex.test.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/codex.test.ts @@ -96,6 +96,34 @@ describe("rollout", () => { expect(prompt.facts[0]).toMatchObject({ patch: { titleHint: "Run the arena" } }); }); + it("reports the call awaiting output and the prompt being worked on", () => { + const parser = adapter.open(rollout(parentThread)); + parser.line(record(0, "session_meta", { id: parentThread, cwd: "/repo", source: "cli", cli_version: "0.160.0" }), 0); + const call = parser.line(record(1, "response_item", { type: "function_call", name: "exec_command", arguments: '{"cmd":"bun test"}', call_id: "c1" }), 1); + expect(call.facts).toContainEqual(expect.objectContaining({ kind: "call", callId: "c1", event: expect.objectContaining({ kind: "started", name: "exec_command" }) })); + const output = parser.line(record(2, "response_item", { type: "function_call_output", call_id: "c1", output: "ok" }), 2); + expect(output.facts).toContainEqual(expect.objectContaining({ kind: "call", callId: "c1", event: { kind: "ended" } })); + const prompt = parser.line(record(3, "response_item", { type: "message", role: "user", content: [{ type: "input_text", text: "Run the arena" }] }), 3); + expect(prompt.facts).toContainEqual({ kind: "prompt", id: `codex:${parentThread}`, text: "Run the arena", at: "2026-10-01T10:00:00.000Z" } as never); + }); + + it("flags pstack from a root prompt or a call into pstack's skills or runner", () => { + const flagged = (thread: string, meta: string, line: string) => { + const parser = adapter.open(rollout(thread)); + parser.line(meta, 0); + return parser.line(line, 1).facts.some((fact) => fact.kind === "pstack"); + }; + const rootMeta = record(0, "session_meta", { id: parentThread, cwd: "/repo", source: "cli", cli_version: "0.160.0" }); + const ask = (text: string) => record(1, "response_item", { type: "message", role: "user", content: [{ type: "input_text", text }] }); + const shell = (cmd: string) => record(1, "response_item", { type: "function_call", name: "exec_command", arguments: JSON.stringify({ cmd }), call_id: "c1" }); + expect(flagged(parentThread, rootMeta, ask("Use pstack:monitor."))).toBe(true); + expect(flagged(parentThread, rootMeta, ask("Fix the parser"))).toBe(false); + expect(flagged(childThread, childMeta(), ask("Use pstack:monitor."))).toBe(false); + expect(flagged(parentThread, rootMeta, shell("pstack-runner --parent codex --provider minimax"))).toBe(true); + expect(flagged(parentThread, rootMeta, shell("cat ~/.codex/plugins/cache/open-pstack/pstack/1.5.0/skills/arena/SKILL.md"))).toBe(true); + expect(flagged(parentThread, rootMeta, shell("ls"))).toBe(false); + }); + it("records each agent message once, addressed by path, and names the tree's root path", () => { const parser = adapter.open(rollout(childThread)); const meta = parser.line(childMeta(), 0); diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/codex.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/codex.ts index 8f397dec..7e8320b4 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/codex.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/codex.ts @@ -23,7 +23,7 @@ import { text, usageFrom, } from "../json.ts"; -import { BODY_LIMIT, describeTool, itemId, textActivity } from "./blocks.ts"; +import { BODY_LIMIT, callEnded, describeTool, itemId, promptFact, textActivity } from "./blocks.ts"; // pstack-flex addition. Reads Codex rollouts. Each thread, including every // spawned child, writes its own rollout; children name their parent thread. @@ -37,6 +37,10 @@ const IGNORED_TYPES = new Set([ "inter_agent_communication_metadata", ]); +/** Paths and commands only pstack's skills and runner use. */ +const PSTACK_MARKERS = ["pstack-runner", "open-pstack/pstack/", "/pstack/skills/"]; +const PSTACK_WORD = /\bpstack\b/i; + const IGNORED_ITEMS = new Set(["ghost_snapshot"]); export function threadId(thread: string): AgentId { @@ -214,6 +218,9 @@ class RolloutParser implements LineParser { } if (isInjectedContext(body)) return NOTHING; const facts: Fact[] = this.isChild ? [] : [{ kind: "agent", id: this.agent, patch: { titleHint: oneLine(body, 80) } }]; + facts.push(promptFact(this.agent, body, at)); + // A child's prompt can quote its parent's history, so only a root prompt counts as asking for pstack. + if (!this.isChild && PSTACK_WORD.test(body)) facts.push({ kind: "pstack", id: this.agent }); return parsed(facts, [{ id, at, kind: "prompt", body: clip(body, BODY_LIMIT) }]); } case "reasoning": { @@ -237,10 +244,13 @@ class RolloutParser implements LineParser { ? text(payload.input) ?? "" : pretty(payload.action); const callId = text(payload.call_id) ?? text(payload.id) ?? `${offset}`; - return parsed( - [{ kind: "activity", id: this.agent, activity: { what: "tool", snippet: describeTool(name, firstLine(input)), at } }], - [{ id, at, kind: "tool-call", callId, name, input: clip(input, BODY_LIMIT) }], - ); + const snippet = describeTool(name, firstLine(input)); + const facts: Fact[] = [ + { kind: "activity", id: this.agent, activity: { what: "tool", snippet, at } }, + { kind: "call", id: this.agent, callId, at, event: { kind: "started", name, snippet } }, + ]; + if (PSTACK_MARKERS.some((marker) => input.includes(marker))) facts.push({ kind: "pstack", id: this.agent }); + return parsed(facts, [{ id, at, kind: "tool-call", callId, name, input: clip(input, BODY_LIMIT) }]); } case "function_call_output": case "custom_tool_call_output": @@ -248,7 +258,7 @@ class RolloutParser implements LineParser { const callId = text(payload.call_id); if (callId === null) return problem({ kind: "shape", recordType: `response_item/${type}`, detail: "missing call_id" }); const output = typeof payload.output === "string" ? payload.output : contentText(payload.output); - return parsed([], [{ id, at, kind: "tool-result", callId, ok: null, output: clip(output, BODY_LIMIT) }]); + return parsed([callEnded(this.agent, callId, at)], [{ id, at, kind: "tool-result", callId, ok: null, output: clip(output, BODY_LIMIT) }]); } case "agent_message": { // Recorded once, in the recipient's rollout. diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/lane.test.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/lane.test.ts index fa6bb13b..73bc4ce8 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/lane.test.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/lane.test.ts @@ -76,6 +76,24 @@ describe("lane record", () => { expect(kinds(parsed.facts)).not.toContain("link"); }); + it("is always pstack work and carries what the lane was asked", () => { + for (const provider of ["codex", "claude", "grok", "deepseek", "minimax"]) { + const parsed = adapter.document(join(dir, "lane.json"), JSON.stringify({ ...record, provider, label: null, promptHead: "Judge the candidates." })); + expect(parsed.facts).toContainEqual({ kind: "pstack", id } as never); + expect(parsed.facts).toContainEqual({ kind: "prompt", id, text: "Judge the candidates.", at: record.startedAt } as never); + expect(parsed.facts[0]).toMatchObject({ patch: { provider, titleHint: "Judge the candidates." } }); + } + const older = adapter.document(join(dir, "lane.json"), JSON.stringify(record)); + expect(kinds(older.facts)).toContain("pstack"); + expect(kinds(older.facts)).not.toContain("prompt"); + }); + + it("streams Codex and Grok lanes and waits for exit on Claude, DeepSeek, and MiniMax", () => { + const stream = (provider: string) => + (adapter.document(join(dir, "lane.json"), JSON.stringify({ ...record, provider })).facts[0] as { patch: { flavor: { stream: string } } }).patch.flavor.stream; + expect(["codex", "grok", "claude", "deepseek", "minimax"].map(stream)).toEqual(["live", "live", "at-exit", "at-exit", "at-exit"]); + }); + it("rejects a record without the fields it needs", () => { expect(adapter.document(join(dir, "lane.json"), JSON.stringify({ provider: "codex" })).problem).toMatchObject({ kind: "shape" }); }); @@ -148,6 +166,27 @@ describe("stream", () => { expect(parsed.items[0]).toMatchObject({ kind: "text", body: { text: "progress" } }); }); + it("tracks the call a Grok lane is waiting on", () => { + const parser = adapter.open(join(dir, "stream.jsonl")); + const call = parser.line(JSON.stringify({ + type: "assistant", + message: { content: [{ type: "tool_use", id: "g1", name: "read_file", input: { path: "src/a.ts" } }] }, + }), 0); + expect(call.facts).toContainEqual({ kind: "call", id, callId: "g1", at: null, event: { kind: "started", name: "read_file", snippet: "read_file · src/a.ts" } } as never); + const result = parser.line(JSON.stringify({ type: "user", message: { content: [{ type: "tool_result", tool_use_id: "g1", content: "x" }] } }), 10); + expect(result.facts).toEqual([{ kind: "call", id, callId: "g1", at: null, event: { kind: "ended" } }] as never); + }); + + it("tracks the item a Codex lane is waiting on", () => { + const parser = adapter.open(join(dir, "stream.jsonl")); + const started = parser.line(JSON.stringify({ type: "item.started", item: { id: "i1", type: "command_execution", command: "bun test" } }), 0); + expect(started.facts).toContainEqual(expect.objectContaining({ kind: "call", callId: "i1", event: expect.objectContaining({ kind: "started" }) })); + const done = parser.line(JSON.stringify({ type: "item.completed", item: { id: "i1", type: "command_execution", command: "bun test", exit_code: 0 } }), 10); + expect(done.facts).toEqual([{ kind: "call", id, callId: "i1", at: null, event: { kind: "ended" } }] as never); + const once = adapter.open(join(dir, "stream.jsonl")).line(JSON.stringify({ type: "item.completed", item: { id: "i2", type: "command_execution", command: "ls", exit_code: 0 } }), 0); + expect(once.facts.filter((fact) => fact.kind === "call").map((fact) => (fact as { event: { kind: string } }).event.kind)).toEqual(["started", "ended"]); + }); + it("reports events it does not recognize", () => { const parser = adapter.open(join(dir, "stream.jsonl")); expect(parser.line(JSON.stringify({ type: "hologram" }), 0).problem).toEqual({ kind: "unknown-type", recordType: "hologram" }); diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/lane.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/lane.ts index 5e6e901f..c1eaa560 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/lane.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/adapters/lane.ts @@ -14,7 +14,7 @@ import { } from "../adapter.ts"; import type { AgentId, Flavor, TimelineItem } from "../domain.ts"; import { array, clip, contentText, finite, object, oneLine, parseJson, pretty, text, usageFrom } from "../json.ts"; -import { assistantBlocks, BODY_LIMIT, collected, describeTool, itemId, textActivity, toolResult } from "./blocks.ts"; +import { assistantBlocks, BODY_LIMIT, callEnded, collected, describeTool, itemId, promptFact, textActivity, toolResult } from "./blocks.ts"; // pstack-flex addition. Reads the runner's lane journal: one directory per // external lane holding its start record, its raw stdout, and its receipt. @@ -93,13 +93,16 @@ class StreamParser implements LineParser { } case "user": { const items: TimelineItem[] = []; + const facts: Fact[] = []; array(object(event.message)?.content).forEach((block, index) => { const record = object(block); if (record?.type !== "tool_result") return; const item = toolResult(offset, index, null, record); - if (item !== null) items.push(item); + if (item?.kind !== "tool-result") return; + items.push(item); + facts.push(callEnded(this.agent, item.callId, null)); }); - return parsed([], items); + return parsed(facts, items); } case "result": { const body = text(event.result); @@ -131,15 +134,19 @@ class StreamParser implements LineParser { const call = (name: string, input: string): Parsed => { if (this.started.has(callId)) return NOTHING; this.started.add(callId); + const snippet = describeTool(name, input); return parsed( - [{ kind: "activity", id: this.agent, activity: { what: "tool", snippet: describeTool(name, input), at: null } }], + [ + { kind: "activity", id: this.agent, activity: { what: "tool", snippet, at: null } }, + { kind: "call", id: this.agent, callId, at: null, event: { kind: "started", name, snippet } }, + ], [{ id: itemId(offset, 0), at: null, kind: "tool-call", callId, name, input: clip(input, BODY_LIMIT) }], ); }; const callAndResult = (name: string, input: string, ok: boolean | null, output: string): Parsed => { const opened = call(name, input); if (!completed) return opened; - return parsed(opened.facts, [ + return parsed([...opened.facts, callEnded(this.agent, callId, null)], [ ...opened.items, { id: itemId(offset, 1), at: null, kind: "tool-result", callId, ok, output: clip(output, BODY_LIMIT) }, ]); @@ -238,6 +245,7 @@ function laneRecord(path: string, id: AgentId, record: Partial): Par } const label = text(record.label); const started = text(record.startedAt); + const head = text(record.promptHead); const startedMs = started === null ? Number.NaN : Date.parse(started); const facts: Fact[] = [ { @@ -253,8 +261,11 @@ function laneRecord(path: string, id: AgentId, record: Partial): Par ...(text(record.cwd) !== null ? { cwd: text(record.cwd)! } : {}), ...(started !== null ? { seenAt: started } : {}), ...(label !== null ? { title: oneLine(label, 120) } : {}), + ...(head !== null ? { titleHint: oneLine(head, 80) } : {}), }, }, + // Only pstack's runner writes a lane record. + { kind: "pstack", id }, { kind: "process", key: path, @@ -262,6 +273,7 @@ function laneRecord(path: string, id: AgentId, record: Partial): Par process: { pid, startedAtMs: Number.isFinite(startedMs) ? startedMs : null, state: "running" }, }, ]; + if (head !== null) facts.push(promptFact(id, head, started)); const session = text(record.parentSessionId); if (session !== null) facts.push({ kind: "link", id, parent: `${parent}:${session}` as AgentId, via: "runner" }); return parsed(facts); diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/domain.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/domain.ts index 7c0b1a4e..8654817f 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/domain.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/domain.ts @@ -52,6 +52,18 @@ export interface Activity { readonly at: string | null; } +export interface Prompt { + readonly text: string; + readonly at: string | null; +} + +/** A tool call still waiting for its result. */ +export interface PendingCall { + readonly name: string; + readonly snippet: string; + readonly since: string | null; +} + export interface AgentNode { readonly id: AgentId; // May name an agent outside the indexed window; the client then draws this node as a root. @@ -69,6 +81,10 @@ export interface AgentNode { readonly startedAt: string | null; readonly lastActivityAt: string | null; readonly activity: Activity | null; + /** True when any agent in this node's spawn tree is pstack work. */ + readonly pstack: boolean; + readonly prompt: Prompt | null; + readonly pending: PendingCall | null; readonly usage: NormalizedUsage | null; readonly health: Health; } diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/layout.test.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/layout.test.ts index 3dea9e81..08049150 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/layout.test.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/layout.test.ts @@ -28,6 +28,9 @@ function node(id: string, parent: string | null, status: AgentStatus["kind"] = " startedAt, lastActivityAt: startedAt, activity: null, + pstack: false, + prompt: null, + pending: null, usage: { inputTokens: 10, outputTokens: 5 }, health: "ok", }; diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/store.test.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/store.test.ts index 9ae8c34b..fefb7238 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/store.test.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/store.test.ts @@ -30,6 +30,15 @@ const pidFile = (state: string, pid = 10): Fact => ({ process: { pid, startedAtMs: null, state }, }); +const started = (id: AgentId, at: string): Fact => ({ kind: "turn", id, turnId: "turn", at, event: { kind: "started" } }); +const finished = (id: AgentId, at: string, outcome: "done" | "cancelled" = "done"): Fact => ({ + kind: "turn", + id, + turnId: "turn", + at, + event: { kind: "ended", outcome, reason: null }, +}); + describe("Claude session status", () => { it("follows the live process record", () => { const busy = store(session, pidFile("busy")); @@ -55,6 +64,21 @@ describe("Claude session status", () => { expect(store(session).node(root)!.status.kind).toBe("unknown"); }); + it("follows its turns when it keeps no process record", () => { + const desktop = store(session, started(root, "t0")); + expect(desktop.node(root)!.status).toEqual({ kind: "running", evidence: "lifecycle" }); + desktop.apply(finished(root, "t1")); + expect(desktop.node(root)!.status).toEqual({ kind: "idle", evidence: "lifecycle", detail: null }); + desktop.apply(finished(root, "t2", "cancelled")); + expect(desktop.node(root)!.status).toEqual({ kind: "idle", evidence: "lifecycle", detail: "interrupted" }); + }); + + it("stays ended inside a turn once its terminal process record is gone", () => { + const gone = store(session, started(root, "t0")); + gone.noteProcessRecords("claude"); + expect(gone.node(root)!.status.kind).toBe("ended"); + }); + it("keeps the probe result when the record is rewritten for the same process", () => { const result = store(session, pidFile("busy")); result.setAlive("/sessions/10.json", true); @@ -103,6 +127,33 @@ describe("Claude subagent status", () => { expect(orphan.node(child)!.status.kind).toBe("ended"); }); + it("finishes on its own end of turn before the parent records a result", () => { + const spawned = live( + { kind: "link-by-call", id: child, callId: "call-1", fallback: root }, + started(child, "t0"), + ); + expect(spawned.node(child)!.status).toEqual({ kind: "running", evidence: "lifecycle" }); + spawned.apply(finished(child, "t1")); + expect(spawned.node(child)!.status).toEqual({ kind: "done", at: "t1" }); + spawned.apply({ kind: "child-outcome", callId: "call-1", outcome: "launched", at: "t2", reason: null }); + expect(spawned.node(child)!.status).toEqual({ kind: "done", at: "t1" }); + spawned.apply({ kind: "child-outcome", callId: "call-1", outcome: "cancelled", at: "t3", reason: null }); + expect(spawned.node(child)!.status).toEqual({ kind: "cancelled", at: "t3" }); + }); + + it("runs mid-turn while its session lives and ends with it", () => { + const spawned = live({ kind: "link-by-call", id: child, callId: "call-1", fallback: root }, started(child, "t0")); + expect(spawned.node(child)!.status.kind).toBe("running"); + spawned.setAlive("/sessions/10.json", false); + expect(spawned.node(child)!.status.kind).toBe("ended"); + }); + + it("revives when a message resumes it", () => { + const resumed = live({ kind: "link-by-call", id: child, callId: "call-1", fallback: root }, started(child, "t0"), finished(child, "t1")); + resumed.apply(started(child, "t2")); + expect(resumed.node(child)!.status).toEqual({ kind: "running", evidence: "lifecycle" }); + }); + it("attaches to whichever agent made the spawning call", () => { const nested = live( { kind: "link-by-call", id: child, callId: "call-1", fallback: root }, @@ -150,6 +201,68 @@ describe("Codex thread status", () => { }); }); +describe("activity", () => { + const call = (callId: string, at: string, name = "Bash"): Fact => ({ + kind: "call", + id: root, + callId, + at, + event: { kind: "started", name, snippet: `${name} · ${callId}` }, + }); + const done = (callId: string): Fact => ({ kind: "call", id: root, callId, at: "t9", event: { kind: "ended" } }); + + it("keeps the latest call still awaiting its result", () => { + const result = store(session, started(root, "t0"), call("c1", "t1")); + expect(result.node(root)!.pending).toEqual({ name: "Bash", snippet: "Bash · c1", since: "t1" }); + result.apply(call("c2", "t2", "Read")); + expect(result.node(root)!.pending?.snippet).toBe("Read · c2"); + result.apply(done("c1")); + expect(result.node(root)!.pending?.snippet).toBe("Read · c2"); + result.apply(done("c2")); + expect(result.node(root)!.pending).toBeNull(); + result.apply(call("c3", "t3")); + result.apply(finished(root, "t4")); + expect(result.node(root)!.pending).toBeNull(); + }); + + it("keeps the latest prompt", () => { + const result = store( + session, + { kind: "prompt", id: root, text: "first", at: "t0" }, + { kind: "prompt", id: root, text: "second", at: "t1" }, + ); + expect(result.node(root)!.prompt).toEqual({ text: "second", at: "t1" }); + expect(result.node(root)!.lastActivityAt).toBe("t1"); + }); +}); + +describe("pstack scope", () => { + const other = "claude:s2" as AgentId; + const otherSession: Fact = { ...session, id: other } as Fact; + + it("marks the whole spawn tree when any member is pstack work", () => { + const result = store( + session, + subagent, + { kind: "link-by-call", id: child, callId: "call-1", fallback: root }, + otherSession, + ); + expect(result.node(root)!.pstack).toBe(false); + result.apply({ kind: "pstack", id: child }); + expect(result.node(root)!.pstack).toBe(true); + expect(result.node(child)!.pstack).toBe(true); + expect(result.node(other)!.pstack).toBe(false); + }); + + it("sends the newly marked tree in the next flush", () => { + const result = store(session, subagent, otherSession); + result.flush(); + result.apply({ kind: "pstack", id: root }); + const upserts = result.flush()!.upserts.map((node) => node.id).sort(); + expect(upserts).toEqual([root, child].sort()); + }); +}); + describe("usage", () => { it("counts a message split across records once", () => { const usage = { inputTokens: 10, outputTokens: 5 }; diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/store.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/store.ts index 39c3662c..4296bf8f 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/store.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/store.ts @@ -7,6 +7,8 @@ import type { Harness, MessageLink, NormalizedUsage, + PendingCall, + Prompt, SourceKind, SpawnVia, } from "./domain.ts"; @@ -44,6 +46,9 @@ interface AgentState { startedAt: string | null; lastActivityAt: string | null; activity: Activity | null; + pstack: boolean; + prompt: Prompt | null; + readonly openCalls: Map; usageTotal: NormalizedUsage | null; readonly usageByKey: Map; outcome: Ended | null; @@ -85,6 +90,8 @@ export interface StatusView { status(id: AgentId): AgentStatus | null; } +const MAX_OPEN_CALLS = 16; + const USAGE_FIELDS = [ "inputTokens", "cachedInputTokens", @@ -169,9 +176,10 @@ export function deriveStatus(agent: AgentState, view: StatusView): AgentStatus { } } -function codexStatus(agent: AgentState, isRoot: boolean): AgentStatus { +/** Status from turn boundaries alone; null when no turn was ever recorded. */ +function lifecycleStatus(agent: AgentState, isRoot: boolean): AgentStatus | null { if (agent.openTurn !== null) return { kind: "running", evidence: "lifecycle" }; - if (agent.lastTurn === null) return { kind: "unknown", why: "no turn recorded yet" }; + if (agent.lastTurn === null) return null; if (isRoot) { return agent.lastTurn.outcome === "done" ? { kind: "idle", evidence: "lifecycle", detail: null } @@ -180,6 +188,10 @@ function codexStatus(agent: AgentState, isRoot: boolean): AgentStatus { return ended(agent.lastTurn); } +function codexStatus(agent: AgentState, isRoot: boolean): AgentStatus { + return lifecycleStatus(agent, isRoot) ?? { kind: "unknown", why: "no turn recorded yet" }; +} + function claudeSessionStatus(agent: AgentState, view: StatusView): AgentStatus { const entry = view.process(agent.id); if (entry !== null) { @@ -189,7 +201,9 @@ function claudeSessionStatus(agent: AgentState, view: StatusView): AgentStatus { if (view.keepsProcessRecords("claude") && agent.entrypoint === "cli") { return { kind: "ended", at: agent.lastActivityAt }; } - // Desktop and SDK sessions keep no process record, so liveness cannot be known. + // Desktop and SDK sessions keep no process record; their turns are the only evidence. + const turns = lifecycleStatus(agent, true); + if (turns !== null) return turns; return { kind: "unknown", why: agent.entrypoint === null ? "no process record" : `${agent.entrypoint} sessions keep no process record`, @@ -204,11 +218,14 @@ function claudeSubagentStatus(agent: AgentState, view: StatusView): AgentStatus if (status !== null) return status; } } + // Its own transcript ending is the earliest proof of done; the parent's result can lag by minutes. + if (agent.openTurn === null && agent.lastTurn !== null) return ended(agent.lastTurn); const root = view.status(agent.root); if (root === null) return { kind: "unknown", why: "parent session not indexed" }; switch (root.kind) { case "running": case "idle": + if (agent.openTurn !== null) return { kind: "running", evidence: "lifecycle" }; return agent.spawnCall === null ? { kind: "unknown", why: "teammate; no lifecycle record" } : { kind: "running", evidence: "parent" }; @@ -240,6 +257,7 @@ export class Store implements StatusView { private readonly sent = new Map(); private readonly statusCache = new Map(); private readonly messages = new Map(); + private pstackRoots: Set | null = null; private sentLinks = "[]"; private dirty = false; private revision = 0; @@ -279,6 +297,27 @@ export class Store implements StatusView { agent.lastActivityAt = later(agent.lastActivityAt, fact.activity.at); return; } + case "pstack": + this.ensure(fact.id, {}).pstack = true; + return; + case "prompt": { + const agent = this.ensure(fact.id, {}); + agent.prompt = { text: fact.text, at: fact.at }; + agent.lastActivityAt = later(agent.lastActivityAt, fact.at); + return; + } + case "call": { + const agent = this.ensure(fact.id, {}); + if (fact.event.kind === "started") { + agent.openCalls.delete(fact.callId); + agent.openCalls.set(fact.callId, { name: fact.event.name, snippet: fact.event.snippet, since: fact.at }); + if (agent.openCalls.size > MAX_OPEN_CALLS) agent.openCalls.delete(agent.openCalls.keys().next().value!); + } else { + agent.openCalls.delete(fact.callId); + } + agent.lastActivityAt = later(agent.lastActivityAt, fact.at); + return; + } case "usage": { const agent = this.ensure(fact.id, {}); if (fact.key === null) { @@ -292,11 +331,15 @@ export class Store implements StatusView { case "child-outcome": this.childOutcomes.set(fact.callId, { outcome: fact.outcome, at: fact.at, reason: fact.reason }); return; - case "outcome": - this.ensure(fact.id, {}).outcome = { outcome: fact.outcome, at: fact.at, reason: fact.reason }; + case "outcome": { + const agent = this.ensure(fact.id, {}); + agent.outcome = { outcome: fact.outcome, at: fact.at, reason: fact.reason }; + agent.openCalls.clear(); return; + } case "turn": { const agent = this.ensure(fact.id, {}); + agent.openCalls.clear(); if (fact.event.kind === "started") { agent.openTurn = fact.turnId; } else { @@ -472,11 +515,23 @@ export class Store implements StatusView { startedAt: agent.startedAt, lastActivityAt: agent.lastActivityAt, activity: agent.activity, + pstack: this.isPstack(id), + prompt: agent.prompt, + pending: [...agent.openCalls.values()].at(-1) ?? null, usage: agent.usageTotal ?? sumUsage(agent.usageByKey.values()), health: agent.shapeErrors > 0 ? "degraded" : "ok", }; } + /** True when any agent in the same spawn tree carries pstack evidence. */ + private isPstack(id: AgentId): boolean { + if (this.pstackRoots === null) { + this.pstackRoots = new Set(); + for (const agent of this.agents.values()) if (agent.pstack) this.pstackRoots.add(this.treeRoot(agent.id)); + } + return this.pstackRoots.has(this.treeRoot(id)); + } + nodes(): AgentNode[] { const result: AgentNode[] = []; for (const id of this.agents.keys()) { @@ -509,6 +564,7 @@ export class Store implements StatusView { private changed(): void { this.dirty = true; this.statusCache.clear(); + this.pstackRoots = null; } private retractProcess(key: string): void { @@ -544,6 +600,9 @@ export class Store implements StatusView { startedAt: null, lastActivityAt: null, activity: null, + pstack: false, + prompt: null, + openCalls: new Map(), usageTotal: null, usageByKey: new Map(), outcome: null, diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/flex-journal.test.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/flex-journal.test.ts index dddfdf3f..99e80295 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/flex-journal.test.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/flex-journal.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { LANES_DIR_VAR, lanesRoot, openLaneJournal } from "./flex-journal.ts"; @@ -31,6 +31,21 @@ afterEach(() => { }); describe("openLaneJournal", () => { + it("records the head of the prompt, and nothing when it cannot be read", () => { + const root = join(scratch, "lanes"); + mkdirSync(root); + const promptPath = join(scratch, "prompt.md"); + writeFileSync(promptPath, `Judge the\ncandidates.\n${"x".repeat(400)}`); + openLaneJournal({ ...options, promptPath }, Date.now(), { [LANES_DIR_VAR]: root }); + openLaneJournal(options, Date.now() + 1, { [LANES_DIR_VAR]: root }); + const heads = readdirSync(root) + .map((lane) => JSON.parse(readFileSync(join(root, lane, "lane.json"), "utf8")).promptHead as string | null) + .sort((a, b) => (a === null ? -1 : b === null ? 1 : 0)); + expect(heads[0]).toBeNull(); + expect(heads[1]).toStartWith("Judge the candidates. xxx"); + expect(heads[1]).toHaveLength(300); + }); + it("defaults under the user's pstack-flex directory and honors an override", () => { expect(lanesRoot({})).toEndWith(join(".pstack-flex", "lanes")); expect(lanesRoot({ [LANES_DIR_VAR]: "/elsewhere" })).toBe("/elsewhere"); diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/flex-journal.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/flex-journal.ts index e172ce3c..4afada0c 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/flex-journal.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/flex-journal.ts @@ -1,5 +1,5 @@ import { randomBytes } from "node:crypto"; -import { closeSync, mkdirSync, openSync, renameSync, writeFileSync, writeSync } from "node:fs"; +import { closeSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync, writeSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import type { AccessMode, Effort, Parent, Provider, RunnerOptions, RunnerReceipt } from "./types.ts"; @@ -10,6 +10,7 @@ import type { AccessMode, Effort, Parent, Provider, RunnerOptions, RunnerReceipt // never changes the lane's receipt, exit code, or output. export const LANES_DIR_VAR = "PSTACK_FLEX_LANES_DIR"; +const PROMPT_HEAD_CHARS = 300; // The parent harness's own session id, inherited by the runner from the tool that launched it. const PARENT_SESSION_VAR: Record = { @@ -31,6 +32,8 @@ export interface LaneRecord { readonly label: string | null; readonly cwd: string; readonly promptPath: string; + /** The start of the prompt, so the monitor can say what the lane was asked. */ + readonly promptHead: string | null; readonly outputPath: string; readonly receiptPath: string; } @@ -50,6 +53,15 @@ export function lanesRoot(env: NodeJS.ProcessEnv = process.env): string { : join(homedir(), ".pstack-flex", "lanes"); } +function promptHead(path: string): string | null { + try { + const head = readFileSync(path, "utf8").slice(0, PROMPT_HEAD_CHARS * 4).replace(/\s+/g, " ").trim(); + return head.length === 0 ? null : head.slice(0, PROMPT_HEAD_CHARS); + } catch { + return null; + } +} + function writeAtomic(path: string, value: unknown): void { const temporary = `${path}.${process.pid}.tmp`; writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); @@ -82,6 +94,7 @@ export function openLaneJournal( label: options.label ?? null, cwd: options.cwd, promptPath: options.promptPath, + promptHead: promptHead(options.promptPath), outputPath: options.outputPath, receiptPath: options.receiptPath, }; From 1fea1132b208751bc9222d9dc0dd16a7766386ad Mon Sep 17 00:00:00 2001 From: Martin Patino Date: Fri, 2 Oct 2026 17:25:42 -0700 Subject: [PATCH 2/3] feat(monitor): show pstack sessions only, with what each agent is doing The page shows pstack trees by default; Show all or start --all shows every session. Sessions group into working, open, and earlier; a turn-only run silent for 15 minutes reads as stalled. Cards, rows, and the panel show the task, the pending call, and the latest step. The panel resizes by drag or a widen button and remembers its width. --- .../skills/poteto-mode/scripts/monitor/cli.ts | 34 ++--- .../poteto-mode/scripts/monitor/daemon.ts | 15 +- .../scripts/monitor/format.test.ts | 49 +++++- .../poteto-mode/scripts/monitor/format.ts | 55 ++++++- .../poteto-mode/scripts/monitor/graph.ts | 13 +- .../poteto-mode/scripts/monitor/layout.ts | 4 +- .../scripts/monitor/server.test.ts | 4 + .../poteto-mode/scripts/monitor/web/app.css | 139 +++++++++++++++++- .../poteto-mode/scripts/monitor/web/canvas.ts | 24 ++- .../poteto-mode/scripts/monitor/web/dom.ts | 1 + .../poteto-mode/scripts/monitor/web/main.ts | 137 +++++++++++------ .../poteto-mode/scripts/monitor/web/panel.ts | 136 ++++++++++++++++- .../poteto-mode/scripts/monitor/web/rail.ts | 94 ++++++++---- 13 files changed, 579 insertions(+), 126 deletions(-) diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/cli.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/cli.ts index 0cfa6578..5fd80b6b 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/cli.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/cli.ts @@ -1,6 +1,6 @@ import { parseArgs as parseNodeArgs } from "node:util"; import { buildAssets } from "./assets.ts"; -import { DEFAULT_PORT, serve, start, status, stop, type Io } from "./daemon.ts"; +import { DEFAULT_PORT, serve, start, status, stop, type Io, type Scope } from "./daemon.ts"; import { diagnose, renderReport } from "./doctor.ts"; import type { Harness } from "./domain.ts"; import { journalEnabled, journalOff, journalOn } from "./journal.ts"; @@ -11,26 +11,23 @@ import { homes, type Homes } from "./sources.ts"; const HELP = `Usage: pstack-monitor [options] Commands: - start Start the monitor in the background, or reuse the running one, - and print the link to open. Safe to run repeatedly. + start Start the monitor, or reuse the running one, and print its link. status Print a one-line summary and the link. - stop Stop the running monitor. - doctor Index recent transcripts headlessly and report how well each - source parsed. Prints counts, never content. + stop Stop the monitor. Agents keep running. + doctor Report how well recent transcripts parsed (counts only). journal - Record external pstack lanes so the monitor can show them live. - \`start\` turns this on; \`off\` stops it and deletes the records. - serve Run the server in the foreground (what \`start\` launches). + Record external lanes for the monitor. \`off\` deletes the records. + serve Run the server in the foreground. Options: - --parent The harness asking; sets the page's default theme - and focuses that harness's current session. - --focus Session to select first (defaults from --parent). + --parent The harness asking: sets the theme and focuses its session. + --focus Session to select first. + --all Show every session, not only pstack's. --port Port on 127.0.0.1 (default ${DEFAULT_PORT}). --hours How far back to index (default 24). -h, --help Show this help. -The monitor has no idle timeout; it runs until \`pstack-monitor stop\`. +The monitor runs until \`pstack-monitor stop\`. `; const defaultIo: Io = { @@ -52,6 +49,7 @@ export interface Options { readonly hours: number; readonly harness: Harness | null; readonly focus: string | null; + readonly scope: Scope; } function positiveNumber(name: string, value: unknown, fallback: number): number { @@ -79,6 +77,7 @@ export function parseArgs(argv: readonly string[], env: NodeJS.ProcessEnv = proc focus: { type: "string" }, port: { type: "string" }, hours: { type: "string" }, + all: { type: "boolean", default: false }, help: { type: "boolean", short: "h", default: false }, }, }); @@ -97,15 +96,16 @@ export function parseArgs(argv: readonly string[], env: NodeJS.ProcessEnv = proc const focusValue = typeof parsed.values.focus === "string" && parsed.values.focus.length > 0 ? parsed.values.focus : null; const session = focusValue ?? currentSession(harness, env); const focus = session === null || harness === null || session.includes(":") ? session : `${harness}:${session}`; + const scope: Scope = parsed.values.all === true ? "all" : "pstack"; if (parsed.values.help === true || command === undefined || command === "help") { - return { command: "help", journal: "status", port, hours, harness, focus }; + return { command: "help", journal: "status", port, hours, harness, focus, scope }; } if (!(COMMANDS as readonly string[]).includes(command)) throw new UsageError(`unknown command: ${command}`); const action = parsed.positionals[1] ?? "status"; if (command === "journal" && !(JOURNAL_ACTIONS as readonly string[]).includes(action)) { throw new UsageError("journal takes on, off, or status"); } - return { command: command as Command, journal: action as JournalAction, port, hours, harness, focus }; + return { command: command as Command, journal: action as JournalAction, port, hours, harness, focus, scope }; } function journal(where: Homes, action: JournalAction, io: Io): number { @@ -143,9 +143,9 @@ export async function main( io.stdout(HELP); return 0; case "start": - return start(where, { port: options.port, windowHours: options.hours, harness: options.harness, focus: options.focus }, io); + return start(where, { port: options.port, windowHours: options.hours, harness: options.harness, focus: options.focus, scope: options.scope }, io); case "status": - return status(where, io); + return status(where, io, options.scope); case "stop": return stop(where, io); case "journal": diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/daemon.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/daemon.ts index 415e1483..d7533e1c 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/daemon.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/daemon.ts @@ -32,11 +32,14 @@ export interface ServeOptions { readonly assets: () => Promise; } +export type Scope = "pstack" | "all"; + export interface StartOptions { readonly port: number; readonly windowHours: number; readonly harness: Harness | null; readonly focus: string | null; + readonly scope: Scope; } interface Health { @@ -70,10 +73,11 @@ async function waitForExit(pid: number): Promise { while (processExists(pid)) await Bun.sleep(POLL_INTERVAL_MS); } -export function launchUrl(record: ServerRecord, harness: Harness | null, focus: string | null): string { +export function launchUrl(record: ServerRecord, harness: Harness | null, focus: string | null, scope: Scope = "pstack"): string { const query = new URLSearchParams({ token: record.token }); if (harness !== null) query.set("harness", harness); if (focus !== null) query.set("focus", focus); + if (scope === "all") query.set("all", "1"); return `${serverUrl(record)}/?${query.toString()}`; } @@ -145,7 +149,7 @@ export async function start(where: Homes, options: StartOptions, io: Io): Promis const running = await health(existing.port); if (running?.instance === existing.instance) { if (running.version === version) { - io.stdout(`${launchUrl(existing, options.harness, options.focus)}\n`); + io.stdout(`${launchUrl(existing, options.harness, options.focus, options.scope)}\n`); return 0; } io.stderr(`replacing pstack-monitor ${running.version} with ${version}\n`); @@ -178,7 +182,7 @@ export async function start(where: Homes, options: StartOptions, io: Io): Promis } const record = readRecord(where.state); if (record !== null && record.pid === child.pid && (await health(record.port))?.instance === record.instance) { - io.stdout(`${launchUrl(record, options.harness, options.focus)}\n`); + io.stdout(`${launchUrl(record, options.harness, options.focus, options.scope)}\n`); return 0; } await Bun.sleep(POLL_INTERVAL_MS); @@ -214,7 +218,7 @@ export function summarize(agents: readonly AgentNode[]): string { return parts.join(" · "); } -export async function status(where: Homes, io: Io): Promise { +export async function status(where: Homes, io: Io, scope: Scope = "pstack"): Promise { const record = readRecord(where.state); const running = record === null ? null : await health(record.port); if (record === null || running?.instance !== record.instance) { @@ -227,7 +231,8 @@ export async function status(where: Homes, io: Io): Promise { }); const snapshot = (await response.json()) as Snapshot; const indexing = snapshot.server.indexing ? " · indexing" : ""; - io.stdout(`pstack-monitor ${running.version} · ${summarize(snapshot.agents)}${indexing}\n${launchUrl(record, null, null)}\n`); + const agents = snapshot.agents.filter((agent) => scope === "all" || agent.pstack); + io.stdout(`pstack-monitor ${running.version} · ${summarize(agents)}${indexing}\n${launchUrl(record, null, null, scope)}\n`); return 0; } catch (error) { io.stderr(`pstack-monitor did not answer: ${error instanceof Error ? error.message : String(error)}\n`); diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/format.test.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/format.test.ts index 03d57e47..2a7611a5 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/format.test.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/format.test.ts @@ -1,5 +1,17 @@ import { describe, expect, it } from "bun:test"; -import { ago, compactNumber, duration, prettyModel, shortPath, summarizeInput } from "./format.ts"; +import type { AgentNode, AgentStatus, Flavor } from "./domain.ts"; +import { activityLine, ago, compactNumber, duration, prettyModel, shortPath, statusLine, summarizeInput } from "./format.ts"; + +const now = Date.parse("2026-10-01T12:00:00Z"); +const node = (status: AgentStatus, flavor: Flavor = { kind: "session" }, extra: Partial = {}): AgentNode => ({ + status, + flavor, + startedAt: "2026-10-01T11:57:46Z", + lastActivityAt: "2026-10-01T11:57:00Z", + activity: null, + pending: null, + ...extra, +}) as AgentNode; describe("format", () => { it("compacts token counts", () => { @@ -32,4 +44,39 @@ describe("format", () => { expect(shortPath("/Users/me/workspace/pstack-flex")).toBe("…/workspace/pstack-flex"); expect(shortPath("/repo")).toBe("/repo"); }); + + it("says whether an agent works, waits for input, or is gone", () => { + const subagent: Flavor = { kind: "subagent", agentType: null }; + const fresh = { activity: { what: "tool", snippet: "Bash · ls", at: "2026-10-01T11:59:50Z" }, lastActivityAt: "2026-10-01T11:59:50Z" } as const; + expect(statusLine(node({ kind: "running", evidence: "pid" }, { kind: "session" }, fresh), now)).toBe("working · 2m 14s"); + expect(statusLine(node({ kind: "running", evidence: "lifecycle" }, subagent, fresh), now)).toBe("in turn · 2m 14s"); + expect(statusLine(node({ kind: "running", evidence: "parent" }, subagent, fresh), now)).toBe("running · 2m 14s"); + expect(statusLine(node({ kind: "idle", evidence: "pid", detail: null }), now)).toBe("waiting for input · 3m ago"); + expect(statusLine(node({ kind: "idle", evidence: "lifecycle", detail: null }), now)).toBe("between turns · 3m ago"); + expect(statusLine(node({ kind: "ended", at: null }), now)).toBe("ended · process gone"); + expect(statusLine(node({ kind: "ended", at: null }, subagent), now)).toBe("ended · 3m ago"); + }); + + it("flags a running agent as quiet only when its source stamps activity", () => { + const stale = { activity: { what: "text", snippet: "x", at: "2026-10-01T11:57:00Z" } } as const; + expect(statusLine(node({ kind: "running", evidence: "lifecycle" }, { kind: "session" }, stale), now)).toBe("in turn · 2m 14s · quiet 3m 00s"); + const lane: Flavor = { kind: "lane", mode: "read-only", stream: "at-exit", label: null, receipt: null }; + expect(statusLine(node({ kind: "running", evidence: "pid" }, lane), now)).toBe("working · 2m 14s"); + }); + + it("calls a turn stalled once it has been silent for 15 minutes, but never a live process", () => { + const old = { lastActivityAt: "2026-10-01T11:40:00Z" } as const; + expect(statusLine(node({ kind: "running", evidence: "lifecycle" }, { kind: "session" }, old), now)).toBe("stalled · 20m ago"); + expect(statusLine(node({ kind: "running", evidence: "pid" }, { kind: "session" }, old), now)).toBe("working · 2m 14s"); + }); + + it("names the call an agent waits on before its latest step", () => { + const lane: Flavor = { kind: "lane", mode: "read-only", stream: "at-exit", label: null, receipt: null }; + const pending = { name: "Bash", snippet: "Bash · bun test", since: null }; + const activity = { what: "text", snippet: "Looking", at: null } as const; + expect(activityLine(node({ kind: "running", evidence: "pid" }, { kind: "session" }, { pending, activity }))).toBe("Bash · bun test"); + expect(activityLine(node({ kind: "running", evidence: "pid" }, { kind: "session" }, { activity }))).toBe("Looking"); + expect(activityLine(node({ kind: "running", evidence: "pid" }, lane))).toBe("reply arrives at exit"); + expect(activityLine(node({ kind: "done", at: null }, lane))).toBeNull(); + }); }); diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/format.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/format.ts index eff9a9d0..8fec16ab 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/format.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/format.ts @@ -61,17 +61,60 @@ export const STATUS_WORD: Record = { unknown: "unknown", }; -/** One line of status for a card, e.g. `running · 2m 14s` or `done · 4m ago`. */ +export const QUIET_MS = 90_000; +export const STALE_MS = 15 * 60_000; + +const RUNNING_VERB = { pid: "working", lifecycle: "in turn", parent: "running" } as const; + +/** How long a running agent has gone without stamped activity, once past `QUIET_MS`. Lanes stamp none. */ +export function quietFor(node: AgentNode, now: number): number | null { + if (node.status.kind !== "running" || node.activity?.at == null || node.lastActivityAt === null) return null; + const silent = now - Date.parse(node.lastActivityAt); + return Number.isFinite(silent) && silent > QUIET_MS ? silent : null; +} + +/** + * A turn that opened and then went silent. Only a missing end-of-turn record says it still runs, + * which is what a process killed mid-turn leaves behind; a live process record is never stalled. + */ +export function stalled(node: AgentNode, now: number): boolean { + if (node.status.kind !== "running" || node.status.evidence !== "lifecycle" || node.lastActivityAt === null) return false; + const silent = now - Date.parse(node.lastActivityAt); + return Number.isFinite(silent) && silent >= STALE_MS; +} + +/** Doing work now, as far as the evidence shows. */ +export function working(node: AgentNode, now: number): boolean { + return node.status.kind === "running" && !stalled(node, now); +} + +/** What the agent is doing now: the call it waits on, else its latest step. */ +export function activityLine(node: AgentNode): string | null { + if (node.pending !== null) return node.pending.snippet; + if (node.activity !== null) return node.activity.snippet; + if (node.status.kind === "running" && node.flavor.kind === "lane" && node.flavor.stream === "at-exit") return "reply arrives at exit"; + return null; +} + +/** One line of status for a card, e.g. `working · 2m 14s` or `done · 4m ago`. */ export function statusLine(node: AgentNode, now: number): string { const status = node.status; + const session = node.flavor.kind === "session"; switch (status.kind) { case "running": { + if (stalled(node, now)) return `stalled · ${ago(node.lastActivityAt, now)}`; const since = node.startedAt === null ? null : now - Date.parse(node.startedAt); - const verb = status.evidence === "lifecycle" ? "in turn" : "running"; - return since === null || !Number.isFinite(since) ? verb : `${verb} · ${duration(since)}`; + const verb = RUNNING_VERB[status.evidence]; + const line = since === null || !Number.isFinite(since) ? verb : `${verb} · ${duration(since)}`; + const quiet = quietFor(node, now); + return quiet === null ? line : `${line} · quiet ${duration(quiet)}`; + } + case "idle": { + if (status.detail !== null) return status.detail.replace(/_/g, " "); + const since = ago(node.lastActivityAt, now); + if (session && status.evidence === "pid") return `waiting for input · ${since}`; + return `${session ? "between turns" : "idle"} · ${since}`; } - case "idle": - return status.detail === null ? `idle · ${ago(node.lastActivityAt, now)}` : `${status.detail.replace(/_/g, " ")}`; case "done": return `done · ${ago(status.at ?? node.lastActivityAt, now)}`; case "failed": @@ -79,7 +122,7 @@ export function statusLine(node: AgentNode, now: number): string { case "cancelled": return `cancelled · ${ago(status.at ?? node.lastActivityAt, now)}`; case "ended": - return `ended · ${ago(status.at ?? node.lastActivityAt, now)}`; + return session ? "ended · process gone" : `ended · ${ago(status.at ?? node.lastActivityAt, now)}`; case "unknown": return "status unknown"; } diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/graph.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/graph.ts index 88393b95..59060ef0 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/graph.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/graph.ts @@ -1,4 +1,5 @@ import type { AgentId, AgentNode, NormalizedUsage } from "./domain.ts"; +import { working } from "./format.ts"; // pstack-flex addition. Pure tree helpers shared by the browser and tests. @@ -20,8 +21,8 @@ export interface Counts { } /** Working now, or idle in a process known to be alive. A turn boundary alone proves nothing. */ -export function isLive(node: AgentNode): boolean { - return node.status.kind === "running" || (node.status.kind === "idle" && node.status.evidence === "pid"); +export function isLive(node: AgentNode, now = Date.now()): boolean { + return working(node, now) || (node.status.kind === "idle" && node.status.evidence === "pid"); } function byStart(a: AgentNode, b: AgentNode): number { @@ -40,11 +41,11 @@ export function parentOf(node: AgentNode, nodes: ReadonlyMap } /** Sessions to list: live ones first, then most recently active. */ -export function rootsOf(nodes: ReadonlyMap): AgentNode[] { +export function rootsOf(nodes: ReadonlyMap, now = Date.now()): AgentNode[] { return [...nodes.values()] .filter((node) => parentOf(node, nodes) === null) .sort((a, b) => { - const live = Number(isLive(b)) - Number(isLive(a)); + const live = Number(isLive(b, now)) - Number(isLive(a, now)); if (live !== 0) return live; const left = recency(a); const right = recency(b); @@ -94,7 +95,7 @@ export function tokenTotal(usage: NormalizedUsage | null): number { return (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0); } -export function countsOf(tree: Tree): Counts { +export function countsOf(tree: Tree, now = Date.now()): Counts { let running = 0; let waiting = 0; let done = 0; @@ -105,7 +106,7 @@ export function countsOf(tree: Tree): Counts { if (node === tree.root) continue; switch (node.status.kind) { case "running": - running += 1; + if (working(node, now)) running += 1; break; case "idle": waiting += 1; diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/layout.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/layout.ts index c440a5af..5a382477 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/layout.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/layout.ts @@ -6,9 +6,9 @@ import type { Tree } from "./graph.ts"; // new child appends below its siblings instead of reshuffling the canvas. export const CARD_WIDTH = 264; -export const CARD_HEIGHT = 64; +export const CARD_HEIGHT = 84; export const ROOT_WIDTH = 304; -export const ROOT_HEIGHT = 80; +export const ROOT_HEIGHT = 100; export const COLUMN_GAP = 112; export const ROW_GAP = 24; /** Room under a card for its model node and label. */ diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/server.test.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/server.test.ts index 9c64bf21..249d682c 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/server.test.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/server.test.ts @@ -134,11 +134,15 @@ describe("launcher", () => { expect(parseArgs(["start"], { CLAUDE_CODE_SESSION_ID: "abc" })).toMatchObject({ harness: null, focus: null }); expect(() => parseArgs(["start", "--parent", "cursor"], {})).toThrow("--parent"); expect(() => parseArgs(["start", "--port", "99999"], {})).toThrow("--port"); + expect(parseArgs(["start"], {})).toMatchObject({ scope: "pstack" }); + expect(parseArgs(["start", "--all"], {})).toMatchObject({ scope: "all" }); }); it("builds a link that carries the token, theme, and focus", () => { const url = launchUrl({ pid: 1, port: 47317, token: "t", version: "v", instance: "i", startedAt: "s" }, "codex", "codex:t1"); expect(url).toBe("http://127.0.0.1:47317/?token=t&harness=codex&focus=codex%3At1"); + const all = launchUrl({ pid: 1, port: 47317, token: "t", version: "v", instance: "i", startedAt: "s" }, null, null, "all"); + expect(all).toBe("http://127.0.0.1:47317/?token=t&all=1"); }); it("summarizes agent counts in a stable order", () => { diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/web/app.css b/plugins/pstack/skills/poteto-mode/scripts/monitor/web/app.css index 326b5e45..b115a466 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/web/app.css +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/web/app.css @@ -275,6 +275,7 @@ code { } .stat[data-kind="running"] b { color: var(--accent-strong); } .stat[data-kind="failed"][data-zero="false"] b { color: var(--failed); } +.stat[data-kind="waiting"][data-zero="false"] b { color: var(--attention); } .stat[data-zero="true"] { color: var(--text-3); } .stat[data-zero="true"] b { color: var(--text-3); font-weight: 500; } @@ -323,8 +324,20 @@ code { padding: 14px 16px 4px; } +.rail-names { display: flex; align-items: center; gap: 8px; } .rail-title { font-size: 12.5px; font-weight: 650; } +.rail-scope { + padding: 3px 9px; + border: 1px solid var(--line); + border-radius: 999px; + color: var(--text-2); + font-size: 11.5px; + transition: color 0.15s, border-color 0.15s; +} +.rail-scope:hover { color: var(--text); border-color: var(--line-strong); } +.rail-group[hidden] { display: none; } + .rail-count { padding: 1px 7px; border-radius: 999px; @@ -426,8 +439,11 @@ code { border-radius: 50%; border: 1.5px solid var(--text-3); } -.session[data-status="running"] .session-dot { --beacon: var(--p); background: var(--p); border-color: var(--p); animation: beacon 2.2s ease-out infinite; } -.session[data-status="idle"][data-evidence="pid"] .session-dot { background: var(--done); border-color: var(--done); } +.session[data-group="working"] .session-dot { --beacon: var(--p); background: var(--p); border-color: var(--p); animation: beacon 2.2s ease-out infinite; } +.session[data-group="open"] .session-dot { border-color: var(--p); } +.session[data-group="earlier"] .session-dot { border-color: var(--text-3); opacity: 0.6; } +.session[data-status="failed"] .session-dot { background: var(--failed); border-color: var(--failed); opacity: 1; } +.session[data-group="working"] .session-meta { color: var(--text-2); } .rail-empty { margin: 0; padding: 12px 8px; font-size: 12.5px; color: var(--text-3); } @@ -594,7 +610,7 @@ code { top: 0; left: 0; width: 264px; - height: 64px; + height: 84px; display: grid; grid-template-columns: 40px minmax(0, 1fr) 22px; align-items: center; @@ -621,12 +637,12 @@ code { } .card[data-kind="session"] { width: 304px; - height: 80px; + height: 100px; grid-template-columns: 48px minmax(0, 1fr) 22px; padding-left: 16px; - border-radius: 40px 16px 16px 40px; + border-radius: 50px 16px 16px 50px; } -.card[data-kind="session"]:focus-visible { border-radius: 40px 16px 16px 40px; } +.card[data-kind="session"]:focus-visible { border-radius: 50px 16px 16px 50px; } .card[data-status="running"] { box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.04), @@ -663,6 +679,12 @@ code { opacity: 1; animation: sweep 2.6s linear infinite; } +/* Running but silent: still alive, nothing recent to show. */ +.card[data-quiet="quiet"] .card-ring { opacity: 0.35; } +/* A turn that never ended: nothing proves it still runs. */ +.card[data-quiet="stalled"] .card-ring { opacity: 0; animation: none; } +.card[data-quiet="stalled"] .card-badge .icon { animation: none; } +.card[data-quiet="stalled"] .card-status { color: var(--attention); } .card-glyph { width: 40px; @@ -722,6 +744,18 @@ code { .card[data-status="failed"] .card-status { color: var(--failed); } .card[data-attention="true"] .card-status { color: var(--attention); } +.card-activity { + min-width: 0; + font: 11px/1.4 var(--font-mono); + color: var(--text-3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.card-activity[hidden] { display: none; } +.card[data-status="running"] .card-activity { color: var(--text-2); } +.card[data-pending="true"][data-status="running"] .card-activity { color: var(--text); } + .card-badge { width: 22px; height: 22px; @@ -736,6 +770,9 @@ code { .card[data-status="done"] .card-badge { color: var(--done); background: color-mix(in oklab, var(--done) 16%, transparent); } .card[data-status="failed"] .card-badge { color: var(--failed); background: color-mix(in oklab, var(--failed) 18%, transparent); } .card[data-status="idle"] .card-badge { color: var(--text-2); } +.card[data-status="idle"][data-evidence="pid"] .card-badge { color: var(--accent-strong); background: var(--accent-soft); } +.card[data-status="ended"] .card-badge, +.card[data-status="cancelled"] .card-badge { color: var(--text-3); background: var(--layer-2); } .card[data-attention="true"] .card-badge { color: var(--attention); background: color-mix(in oklab, var(--attention) 16%, transparent); } .port { @@ -864,6 +901,15 @@ code { .empty .logo { width: 52px; height: 52px; margin-bottom: 6px; } .empty h2 { margin: 0; font-size: 17px; font-weight: 650; letter-spacing: -0.01em; text-wrap: balance; } .empty p { margin: 0; font-size: 13.5px; line-height: 1.55; color: var(--text-2); text-wrap: pretty; } +.empty-action { + margin-top: 4px; + padding: 6px 14px; + border: 1px solid var(--line-strong); + border-radius: 999px; + font-size: 12.5px; + font-weight: 550; +} +.empty-action:hover { border-color: var(--accent); color: var(--accent-strong); } .canvas-hint { max-width: 420px; @@ -908,7 +954,7 @@ code { top: 0; right: 0; bottom: 0; - width: 460px; + width: var(--panel-width, 460px); max-width: 100%; display: flex; flex-direction: column; @@ -926,8 +972,76 @@ code { transition: translate 0.34s var(--ease-out), visibility 0s, background-color 0.4s ease; } +.panel[data-resizing="true"] { transition: none; user-select: none; } + +.panel-resizer { + position: absolute; + top: 0; + bottom: 0; + left: -5px; + width: 10px; + cursor: col-resize; + touch-action: none; + z-index: 1; +} +.panel-resizer::after { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 4.5px; + width: 1px; + background: transparent; + transition: background-color 0.15s; +} +.panel-resizer:hover::after, +.panel-resizer:focus-visible::after, +.panel[data-resizing="true"] .panel-resizer::after { width: 2px; background: var(--accent); } +.panel-resizer:focus-visible { outline: none; } + +.panel-actions { display: flex; gap: 2px; } +.panel-widen[aria-pressed="true"] { color: var(--accent-strong); } + +.panel-label { + display: block; + margin-bottom: 3px; + font-size: 11px; + font-weight: 600; + color: var(--text-3); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.panel-task-text { + margin: 0; + padding: 8px 11px; + border: 1px solid var(--line); + border-radius: 9px; + background: var(--layer-2); + font-size: 12.5px; + line-height: 1.5; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + overflow-wrap: anywhere; +} + +.panel-now-text { + display: block; + font: 12px/1.45 var(--font-mono); + color: var(--text-2); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.panel-now[data-pending="true"] .panel-now-text { color: var(--text); } +.panel-task[hidden], +.panel-now[hidden] { display: none; } + .panel-head { display: grid; + grid-template-columns: minmax(0, 1fr); gap: 12px; padding: 18px 20px 16px; border-bottom: 1px solid var(--line); @@ -993,11 +1107,20 @@ code { border-color: color-mix(in oklab, var(--accent) 35%, transparent); } .status-chip[data-status="running"] .icon { animation: spin 0.9s linear infinite; } +.status-chip[data-stalled="true"] { color: var(--attention); background: var(--layer-2); border-color: var(--line); } +.status-chip[data-stalled="true"] .icon { animation: none; } .status-chip[data-status="done"] { color: var(--done); background: color-mix(in oklab, var(--done) 12%, transparent); border-color: color-mix(in oklab, var(--done) 30%, transparent); } +.status-chip[data-status="idle"] { + color: var(--text); + border-color: var(--line-strong); +} +.status-chip[data-status="ended"], +.status-chip[data-status="cancelled"] { color: var(--text-3); } +.status-chip[data-status="unknown"] { border-style: dashed; } .status-chip[data-status="failed"] { color: var(--failed); background: color-mix(in oklab, var(--failed) 12%, transparent); @@ -1383,6 +1506,8 @@ code { } body[data-rail="open"] .scrim { opacity: 1; pointer-events: auto; } .panel { width: 100%; border-left: 0; } + .panel-resizer, + .panel-widen { display: none; } .controls { left: 10px; bottom: 10px; } } diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/web/canvas.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/web/canvas.ts index 4478bc5b..b6818190 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/web/canvas.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/web/canvas.ts @@ -1,5 +1,5 @@ import type { AgentId, AgentNode, AgentStatus, MessageLink } from "../domain.ts"; -import { kindLabel, modelOf, statusLine } from "../format.ts"; +import { activityLine, kindLabel, modelOf, quietFor, stalled, statusLine } from "../format.ts"; import type { Tree } from "../graph.ts"; import { connector, layout, messageArc, type Bounds, type Layout } from "../layout.ts"; import { h, icon, providerIcon, svgElement, type IconName } from "./dom.ts"; @@ -17,6 +17,10 @@ const STATUS_ICON: Record = { unknown: "question", }; +function quietness(node: AgentNode, now: number): "stalled" | "quiet" | "no" { + return stalled(node, now) ? "stalled" : quietFor(node, now) !== null ? "quiet" : "no"; +} + const MIN_ZOOM = 0.25; const MAX_ZOOM = 1.75; const FIT_PADDING = 72; @@ -33,6 +37,7 @@ interface View { readonly title: HTMLElement; readonly kind: HTMLElement; readonly status: HTMLElement; + readonly activity: HTMLElement; readonly badge: HTMLElement; satellite: HTMLElement | null; tether: SVGGElement | null; @@ -163,9 +168,10 @@ export class Canvas { this.empty.toggleAttribute("hidden", content === null); } - setRightInset(pixels: number): void { + /** `reveal: false` while a drag is still changing the inset, so the camera does not chase it. */ + setRightInset(pixels: number, reveal = true): void { this.rightInset = pixels; - if (this.selected !== null) this.reveal(this.selected); + if (reveal && this.selected !== null) this.reveal(this.selected); } render(tree: Tree | null, selected: AgentId | null, now: number, links: readonly MessageLink[] = []): void { @@ -274,6 +280,7 @@ export class Canvas { for (const view of this.views.values()) { if (view.node.status.kind === "running" || view.node.status.kind === "idle") { view.status.textContent = statusLine(view.node, now); + view.card.dataset.quiet = quietness(view.node, now); } } } @@ -496,6 +503,7 @@ export class Canvas { const title = h("span", { class: "card-title" }); const kind = h("span", { class: "card-kind" }); const status = h("span", { class: "card-status" }); + const activity = h("span", { class: "card-activity" }); const badge = h("span", { class: "card-badge", attrs: { "aria-hidden": "true" } }); const card = h( "button", @@ -503,7 +511,7 @@ export class Canvas { h("span", { class: "card-ring", attrs: { "aria-hidden": "true" } }), h("span", { class: "port port-in", attrs: { "aria-hidden": "true" } }), glyph, - h("span", { class: "card-text" }, title, h("span", { class: "card-meta" }, kind, h("span", { class: "card-sep", text: "·" }), status)), + h("span", { class: "card-text" }, title, h("span", { class: "card-meta" }, kind, h("span", { class: "card-sep", text: "·" }), status), activity), badge, h("span", { class: "port port-out", attrs: { "aria-hidden": "true" } }), ); @@ -516,6 +524,7 @@ export class Canvas { title, kind, status, + activity, badge, satellite: null, tether: null, @@ -542,12 +551,17 @@ export class Canvas { card.dataset.harness = node.harness; card.dataset.health = node.health; card.dataset.attention = String(status.kind === "idle" && status.detail !== null); - card.dataset.evidence = status.kind === "running" ? status.evidence : ""; + card.dataset.evidence = status.kind === "running" || status.kind === "idle" ? status.evidence : ""; + card.dataset.quiet = quietness(node, now); + card.dataset.pending = String(node.pending !== null); card.setAttribute("aria-pressed", String(selected)); view.title.textContent = node.title; // The root's glyph already names the harness; its card needs only a short label. view.kind.textContent = node.flavor.kind === "session" ? (node.harness === "claude" ? "Claude Code" : "Codex") : kindLabel(node); view.status.textContent = statusLine(node, now); + const doing = activityLine(node); + view.activity.textContent = doing ?? ""; + view.activity.hidden = doing === null; card.setAttribute("aria-label", `${node.title}, ${kindLabel(node)}, ${statusLine(node, now)}`); card.title = node.title; const glyphName = node.flavor.kind === "session" ? providerIcon(node.harness) : providerIcon(node.model.provider); diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/web/dom.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/web/dom.ts index 12218bcf..4bf0ad5d 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/web/dom.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/web/dom.ts @@ -79,6 +79,7 @@ const ICONS = { sessions: ["M4 6h16", "M4 12h16", "M4 18h10"], arrowDown: ["M12 5v14", "m6 13 6 6 6-6"], arrowRight: ["M5 12h14", "m13 6 6 6-6 6"], + widen: ["M9 5H4v14h5", "M15 5h5v14h-5", "m7 12 3-3", "m7 12 3 3", "m17 12-3-3", "m17 12-3 3"], folder: ["M3.5 6.5a1.5 1.5 0 0 1 1.5-1.5h4l2 2.5h8a1.5 1.5 0 0 1 1.5 1.5v8.5a1.5 1.5 0 0 1-1.5 1.5H5a1.5 1.5 0 0 1-1.5-1.5Z"], } as const; diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/web/main.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/web/main.ts index bf259092..029656dd 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/web/main.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/web/main.ts @@ -1,11 +1,11 @@ import type { AgentId, AgentNode, Harness, MessageLink, SourceKind } from "../domain.ts"; -import { compactNumber, shortPath } from "../format.ts"; +import { compactNumber, shortPath, working } from "../format.ts"; import { countsOf, isLive, rootOf, rootsOf, treeOf } from "../graph.ts"; import type { Delta, ServerInfo, Snapshot, SourceHealth, TimelineAppend, TimelinePage } from "../wire.ts"; import { Canvas } from "./canvas.ts"; import { h, icon, logo } from "./dom.ts"; -import { Panel } from "./panel.ts"; -import { Rail, type Descendants } from "./rail.ts"; +import { Panel, PANEL_MIN } from "./panel.ts"; +import { Rail, type Descendants, type Scope } from "./rail.ts"; // pstack-flex addition. The page controller: one event stream, one state, // and a render that every view reads from. @@ -16,8 +16,11 @@ const SOURCE_NAME: Record = { "runner-lane": "pstack lanes", }; -const PANEL_WIDTH = 460; const RETRY_MS = 3_000; +/** Canvas kept visible beside the panel; narrower than this and the panel overlays it. */ +const CANVAS_MIN = 360; +const PANEL_GUTTER = 240; +const SCOPE_KEY = "pstack-monitor.scope"; type Connection = "connecting" | "live" | "retrying" | "expired"; @@ -30,12 +33,22 @@ interface State { agent: AgentId | null; connection: Connection; dismissed: string; + scope: Scope; } const params = new URLSearchParams(location.search); const defaultHarness: Harness = params.get("harness") === "codex" ? "codex" : "claude"; let pendingFocus = params.get("focus") as AgentId | null; +function initialScope(): Scope { + if (params.get("all") === "1") return "all"; + try { + return localStorage.getItem(SCOPE_KEY) === "all" ? "all" : "pstack"; + } catch { + return "pstack"; + } +} + const state: State = { nodes: new Map(), links: [], @@ -45,15 +58,23 @@ const state: State = { agent: null, connection: "connecting", dismissed: "", + scope: initialScope(), }; +/** The agents the current scope shows: pstack's trees, or everything. */ +function visible(): Map { + if (state.scope === "all") return state.nodes; + return new Map([...state.nodes].filter(([, node]) => node.pstack)); +} + const canvas = new Canvas({ select: (id) => selectAgent(id) }); const panel = new Panel({ close: () => selectAgent(null), select: (id) => selectAgent(id), loadOlder: (agent, before) => fetchTimeline(agent, before), + resized: (width, settled) => canvas.setRightInset(insetFor(width), settled), }); -const rail = new Rail({ select: (root) => selectSession(root) }); +const rail = new Rail({ select: (root) => selectSession(root), toggleScope: () => setScope(state.scope === "pstack" ? "all" : "pstack") }); const sessionTitle = h("h1", { class: "bar-title" }); const sessionPath = h("span", { class: "bar-path mono" }); @@ -136,8 +157,9 @@ function onSnapshot(snapshot: Snapshot): void { state.links = snapshot.links; state.health = snapshot.health; state.server = snapshot.server; - if (state.session === null || !state.nodes.has(state.session)) state.session = chooseSession(); - if (state.agent !== null && !state.nodes.has(state.agent)) state.agent = null; + const shown = visible(); + if (state.session === null || !shown.has(state.session)) state.session = chooseSession(); + if (state.agent !== null && !shown.has(state.agent)) state.agent = null; setConnection("live"); render(); } @@ -161,22 +183,37 @@ function onDelta(delta: Delta): void { } if (delta.health !== null) state.health = delta.health; if (state.server !== null) state.server = { ...state.server, indexing: delta.indexing }; - if (state.session === null) state.session = chooseSession(); + if (state.session === null || !visible().has(state.session)) state.session = chooseSession(); render(); for (const id of pulses) canvas.pulse(id); for (const link of talks) canvas.pulseMessage(link.from, link.to); } function chooseSession(): AgentId | null { + const shown = visible(); if (pendingFocus !== null) { const focus = pendingFocus; - if (state.nodes.has(focus)) { + if (shown.has(focus)) { pendingFocus = null; - return rootOf(focus, state.nodes); + return rootOf(focus, shown); } } - const roots = rootsOf(state.nodes); - return (roots.find(isLive) ?? roots[0])?.id ?? null; + const now = Date.now(); + const roots = rootsOf(shown, now); + return (roots.find((root) => isLive(root, now)) ?? roots[0])?.id ?? null; +} + +function setScope(scope: Scope): void { + state.scope = scope; + try { + localStorage.setItem(SCOPE_KEY, scope); + } catch { + // Storage is unavailable; the choice lasts for this page only. + } + const shown = visible(); + if (state.agent !== null && !shown.has(state.agent)) selectAgent(null); + if (state.session === null || !shown.has(state.session)) state.session = chooseSession(); + render(); } // --- selection ------------------------------------------------------------- @@ -202,20 +239,27 @@ function selectAgent(id: AgentId | null): void { render(); return; } - const node = state.nodes.get(id); + const shown = visible(); + const node = shown.get(id); if (node === undefined) return; - const root = rootOf(id, state.nodes); + const root = rootOf(id, shown); if (root !== state.session) state.session = root; - panel.open(node, state.nodes, Date.now()); + panel.open(node, shown, Date.now()); render(); - canvas.setRightInset(wide() ? PANEL_WIDTH : 0); + canvas.setRightInset(insetFor(panel.width)); canvas.reveal(id); // The stream carries the watched agent's timeline, so a new selection reconnects. connect(); } -function wide(): boolean { - return matchMedia("(min-width: 1100px)").matches; +/** The panel overlays the canvas when keeping both side by side would leave the canvas too narrow. */ +function insetFor(width: number): number { + return stage.clientWidth - width >= CANVAS_MIN ? width : 0; +} + +function fitPanel(): void { + panel.setMaxWidth(Math.max(PANEL_MIN, stage.clientWidth - PANEL_GUTTER)); + canvas.setRightInset(state.agent === null ? 0 : insetFor(panel.width), false); } function toggleRail(open?: boolean): void { @@ -228,18 +272,19 @@ function toggleRail(open?: boolean): void { function render(): void { const now = Date.now(); - const roots = rootsOf(state.nodes); + const shown = visible(); + const roots = rootsOf(shown, now); const descendants = new Map(); - for (const node of state.nodes.values()) { - const root = rootOf(node.id, state.nodes); + for (const node of shown.values()) { + const root = rootOf(node.id, shown); if (root === node.id) continue; const entry = descendants.get(root) ?? { total: 0, running: 0 }; - descendants.set(root, { total: entry.total + 1, running: entry.running + (node.status.kind === "running" ? 1 : 0) }); + descendants.set(root, { total: entry.total + 1, running: entry.running + (working(node, now) ? 1 : 0) }); } const hours = state.server?.windowHours ?? 24; - rail.render(roots, descendants, state.session, now, `Showing the last ${hours === 24 ? "24 hours" : `${hours} hours`}`); + rail.render(roots, descendants, state.session, now, state.scope, `Last ${hours} hours`); - const tree = state.session === null ? null : treeOf(state.session, state.nodes); + const tree = state.session === null ? null : treeOf(state.session, shown); canvas.render(tree, state.agent, now, state.links); document.documentElement.dataset.harness = tree?.root.harness ?? defaultHarness; @@ -249,14 +294,14 @@ function render(): void { "div", { class: "empty" }, logo(), - h("h2", { text: "This link has expired" }), - h("p", {}, "The monitor restarted with a new access token. Run ", h("code", { text: "pstack-monitor start" }), " and open the link it prints."), + h("h2", { text: "Link expired" }), + h("p", {}, "Run ", h("code", { text: "pstack-monitor start" }), " and open the new link."), ), ); - } else if (state.nodes.size === 0) { - canvas.setEmpty(emptyState(state.server?.indexing === true)); + } else if (shown.size === 0) { + canvas.setEmpty(emptyState(state.server?.indexing === true, state.nodes.size > 0)); } else if (tree !== null && tree.nodes.length === 1) { - canvas.setEmpty(h("p", { class: "canvas-hint", text: "No subagents in this session yet. When it spawns agents or runs pstack lanes, they appear here." })); + canvas.setEmpty(h("p", { class: "canvas-hint", text: "No agents spawned yet." })); } else { canvas.setEmpty(null); } @@ -269,13 +314,14 @@ function render(): void { sessionTitle.textContent = tree.root.title; sessionPath.textContent = shortPath(tree.root.cwd); sessionPath.title = tree.root.cwd ?? ""; - const counts = countsOf(tree); + const counts = countsOf(tree, now); const members = new Set(tree.nodes.map((node) => node.id)); const messages = state.links .filter((link) => members.has(link.from) && members.has(link.to)) .reduce((sum, link) => sum + link.count, 0); stats.replaceChildren( stat("running", counts.running, "running"), + stat("waiting", counts.waiting, "waiting"), stat("spawned", counts.spawned, counts.spawned === 1 ? "agent" : "agents"), stat("done", counts.done, "done"), stat("failed", counts.failed, "failed"), @@ -285,7 +331,7 @@ function render(): void { document.title = counts.running > 0 ? `(${counts.running}) pstack monitor` : "pstack monitor"; } - if (state.agent !== null) panel.update(state.nodes, now, state.links); + if (state.agent !== null) panel.update(shown, now, state.links); renderBanner(); renderLive(); } @@ -294,17 +340,24 @@ function stat(kind: string, value: number, label: string, shown = String(value)) return h("span", { class: "stat", attrs: { "data-kind": kind, "data-zero": String(value === 0) } }, h("b", { text: shown }), h("span", { text: ` ${label}` })); } -function emptyState(indexing: boolean): HTMLElement { +function emptyState(indexing: boolean, hidden: boolean): HTMLElement { + const hours = state.server?.windowHours ?? 24; + if (indexing) return h("div", { class: "empty" }, logo(), h("h2", { text: "Reading recent transcripts…" })); + const scoped = state.scope === "pstack"; + const showAll = scoped && hidden + ? (() => { + const button = h("button", { class: "empty-action", text: "Show every session", attrs: { type: "button" } }); + button.addEventListener("click", () => setScope("all")); + return button; + })() + : null; return h( "div", { class: "empty" }, logo(), - h("h2", { text: indexing ? "Reading recent transcripts…" : `No agent activity in the last ${state.server?.windowHours ?? 24} hours` }), - h("p", { - text: indexing - ? "This takes a moment the first time." - : "Start a Claude Code or Codex session. It appears here as soon as it writes its first message, and every agent it spawns joins the graph.", - }), + h("h2", { text: scoped ? `No pstack sessions in the last ${hours} hours` : `No sessions in the last ${hours} hours` }), + h("p", { text: scoped ? "Run a pstack skill in Claude Code or Codex and it appears here." : "Start a Claude Code or Codex session and it appears here." }), + showAll, ); } @@ -314,7 +367,7 @@ function renderBanner(): void { if (!source.present) continue; const name = SOURCE_NAME[source.source]; if (source.state === "degraded") { - messages.push(`${name}: ${source.shape} ${source.shape === 1 ? "record is" : "records are"} in a shape the monitor does not recognize, so some agents may be missing activity.`); + messages.push(`${name}: ${source.shape} unrecognized ${source.shape === 1 ? "record" : "records"}; some activity may be missing.`); } else if (source.unchecked.length > 0) { messages.push(`${name} ${source.unchecked.join(", ")} is newer than this monitor was checked against.`); } @@ -352,7 +405,7 @@ function renderLive(): void { retrying: "Reconnecting…", expired: "Link expired", }[shown]; - live.title = shown === "expired" ? "The monitor restarted. Run `pstack-monitor start` and open the new link." : ""; + live.title = shown === "expired" ? "Run `pstack-monitor start` and open the new link." : ""; } // --- clocks and keys ------------------------------------------------------- @@ -371,8 +424,6 @@ document.addEventListener("keydown", (event) => { else if (state.agent !== null) selectAgent(null); }); -matchMedia("(min-width: 1100px)").addEventListener("change", () => { - canvas.setRightInset(state.agent !== null && wide() ? PANEL_WIDTH : 0); -}); +new ResizeObserver(() => fitPanel()).observe(stage); connect(); diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/web/panel.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/web/panel.ts index 71970199..1ccd4ed5 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/web/panel.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/web/panel.ts @@ -1,5 +1,5 @@ import type { AgentId, AgentNode, Clipped, MessageLink, TimelineItem } from "../domain.ts"; -import { clockTime, compactNumber, duration, kindLabel, modelOf, prettyModel, shortPath, statusLine, summarizeInput } from "../format.ts"; +import { activityLine, ago, stalled, clockTime, compactNumber, duration, kindLabel, modelOf, prettyModel, shortPath, statusLine, summarizeInput } from "../format.ts"; import type { TimelinePage } from "../wire.ts"; import { h, icon, providerIcon, toolIcon, type IconName } from "./dom.ts"; @@ -9,11 +9,36 @@ import { h, icon, providerIcon, toolIcon, type IconName } from "./dom.ts"; const STICK_THRESHOLD = 40; const LOAD_OLDER_THRESHOLD = 160; const CLAMP_CHARS = 700; +export const PANEL_MIN = 360; +export const PANEL_DEFAULT = 460; +const PANEL_WIDE = 760; +const KEY_STEP = 24; +const WIDTH_KEY = "pstack-monitor.panel-width"; export interface PanelEvents { close(): void; select(id: AgentId): void; loadOlder(agent: AgentId, before: number): Promise; + /** `settled` is false while a drag is still moving. */ + resized(width: number, settled: boolean): void; +} + +function storedWidth(): number { + try { + const value = Number(localStorage.getItem(WIDTH_KEY)); + return Number.isFinite(value) && value >= PANEL_MIN ? value : PANEL_DEFAULT; + } catch { + return PANEL_DEFAULT; + } +} + +function storeWidth(width: number | null): void { + try { + if (width === null) localStorage.removeItem(WIDTH_KEY); + else localStorage.setItem(WIDTH_KEY, String(Math.round(width))); + } catch { + // Storage is unavailable; the width lasts for this page only. + } } type ToolCall = Extract; @@ -43,7 +68,15 @@ export class Panel { private readonly kind: HTMLElement; private readonly chip: HTMLElement; private readonly facts: HTMLElement; + private readonly task: HTMLElement; + private readonly taskText: HTMLElement; + private readonly now: HTMLElement; + private readonly nowLabel: HTMLElement; + private readonly nowText: HTMLElement; private readonly note: HTMLElement; + private readonly widen: HTMLButtonElement; + private preferred = storedWidth(); + private maxWidth = Number.POSITIVE_INFINITY; private readonly scroller: HTMLElement; private readonly list: HTMLOListElement; private readonly olderButton: HTMLButtonElement; @@ -65,9 +98,22 @@ export class Panel { this.kind = h("p", { class: "panel-kind" }); this.chip = h("p", { class: "status-chip" }); this.facts = h("dl", { class: "facts" }); + this.taskText = h("p", { class: "panel-task-text" }); + this.task = h("div", { class: "panel-task", attrs: { hidden: "" } }, h("span", { class: "panel-label", text: "Task" }), this.taskText); + this.nowLabel = h("span", { class: "panel-label" }); + this.nowText = h("span", { class: "panel-now-text" }); + this.now = h("div", { class: "panel-now", attrs: { hidden: "" } }, this.nowLabel, this.nowText); this.note = h("p", { class: "panel-note", attrs: { hidden: "" } }); const close = h("button", { class: "icon-button", title: "Close", attrs: { type: "button", "aria-label": "Close agent details" } }, icon("close")); close.addEventListener("click", () => this.events.close()); + this.widen = h("button", { class: "icon-button panel-widen", title: "Widen", attrs: { type: "button", "aria-label": "Widen panel", "aria-pressed": "false" } }, icon("widen")); + this.widen.addEventListener("click", () => this.setWidth(this.width >= PANEL_WIDE ? PANEL_DEFAULT : PANEL_WIDE, true)); + const resizer = h("div", { + class: "panel-resizer", + title: "Drag to resize; double-click to reset", + attrs: { role: "separator", "aria-orientation": "vertical", "aria-label": "Resize panel", tabindex: "0" }, + }); + this.bindResizer(resizer); this.olderButton = h("button", { class: "older", text: "Load earlier activity", attrs: { type: "button", hidden: "" } }); this.olderButton.addEventListener("click", () => void this.loadOlder()); this.list = h("ol", { class: "timeline-items" }); @@ -79,11 +125,14 @@ export class Panel { this.element = h( "aside", { class: "panel", attrs: { "aria-labelledby": "panel-title", "data-open": "false" } }, + resizer, h( "header", { class: "panel-head" }, - h("div", { class: "panel-identity" }, this.glyph, h("div", { class: "panel-names" }, this.title, this.kind), close), + h("div", { class: "panel-identity" }, this.glyph, h("div", { class: "panel-names" }, this.title, this.kind), h("div", { class: "panel-actions" }, this.widen, close)), this.chip, + this.task, + this.now, this.facts, this.note, ), @@ -96,6 +145,65 @@ export class Panel { return this.agent?.id ?? null; } + get width(): number { + return Math.max(PANEL_MIN, Math.min(this.preferred, this.maxWidth)); + } + + /** Keeps the panel from covering the whole stage; called when the window resizes. */ + setMaxWidth(pixels: number): void { + this.maxWidth = Math.max(PANEL_MIN, pixels); + this.applyWidth(); + } + + private setWidth(pixels: number | null, settled: boolean): void { + this.preferred = pixels ?? PANEL_DEFAULT; + if (settled) storeWidth(pixels === null ? null : this.width); + this.applyWidth(); + this.events.resized(this.width, settled); + } + + private applyWidth(): void { + this.element.style.setProperty("--panel-width", `${this.width}px`); + this.widen.setAttribute("aria-pressed", String(this.width >= PANEL_WIDE)); + } + + private bindResizer(resizer: HTMLElement): void { + let start: { x: number; width: number } | null = null; + let frame: number | null = null; + let next = 0; + resizer.addEventListener("pointerdown", (event) => { + if (event.button !== 0) return; + event.preventDefault(); + resizer.setPointerCapture(event.pointerId); + start = { x: event.clientX, width: this.width }; + this.element.dataset.resizing = "true"; + }); + resizer.addEventListener("pointermove", (event) => { + if (start === null) return; + next = start.width + start.x - event.clientX; + frame ??= requestAnimationFrame(() => { + frame = null; + this.setWidth(next, false); + }); + }); + const finish = (): void => { + if (start === null) return; + start = null; + if (frame !== null) cancelAnimationFrame(frame); + frame = null; + delete this.element.dataset.resizing; + this.setWidth(this.width, true); + }; + resizer.addEventListener("pointerup", finish); + resizer.addEventListener("pointercancel", finish); + resizer.addEventListener("dblclick", () => this.setWidth(null, true)); + resizer.addEventListener("keydown", (event) => { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; + event.preventDefault(); + this.setWidth(this.width + (event.key === "ArrowLeft" ? KEY_STEP : -KEY_STEP), true); + }); + } + open(node: AgentNode, nodes: ReadonlyMap, now: number): void { const switched = this.agent?.id !== node.id; this.agent = node; @@ -112,6 +220,7 @@ export class Panel { this.jump.hidden = true; } this.element.dataset.open = "true"; + this.applyWidth(); this.renderHeader(now); } @@ -166,8 +275,22 @@ export class Panel { this.title.textContent = node.title; this.kind.textContent = kindLabel(node); this.chip.dataset.status = node.status.kind; + this.chip.dataset.stalled = String(stalled(node, now)); this.chip.replaceChildren(icon(statusIcon(node)), h("span", { text: statusLine(node, now) })); + this.task.hidden = node.prompt === null; + this.taskText.textContent = node.prompt?.text ?? ""; + this.taskText.title = node.prompt?.text ?? ""; + const running = node.status.kind === "running" && !stalled(node, now); + const doing = activityLine(node); + const since = node.pending?.since ?? node.activity?.at ?? null; + const age = since === null ? "" : ago(since, now); + this.now.hidden = doing === null; + this.now.dataset.pending = String(node.pending !== null && running); + this.nowLabel.textContent = running ? "Now" : "Last"; + this.nowText.textContent = doing === null ? "" : age.length > 0 ? `${doing} · ${age}` : doing; + this.nowText.title = doing ?? ""; + const rows: [string, Node | string][] = []; const model = modelOf(node); if (model !== null) { @@ -199,10 +322,7 @@ export class Panel { const notes: string[] = []; if (node.status.kind === "unknown") notes.push(capitalize(node.status.why)); - if (node.flavor.kind === "lane" && node.flavor.stream === "at-exit") { - notes.push("This CLI reports its whole response when it exits, so activity appears at the end."); - } - if (node.health === "degraded") notes.push("Some of this agent's records are in a shape the monitor does not recognize."); + if (node.health === "degraded") notes.push("Some records were not recognized."); this.note.textContent = notes.join(" "); this.note.hidden = notes.length === 0; } @@ -249,8 +369,8 @@ export class Panel { this.olderButton.hidden = this.older === null; this.emptyNote.hidden = rows.length > 0; this.emptyNote.textContent = node.flavor.kind === "lane" && node.flavor.stream === "at-exit" - ? "Waiting for this lane to finish; its CLI prints everything at exit." - : "No activity recorded for this agent yet."; + ? "The reply arrives when the lane exits." + : "No activity yet."; if (prepended) this.scroller.scrollTop += this.scroller.scrollHeight - previousHeight; } diff --git a/plugins/pstack/skills/poteto-mode/scripts/monitor/web/rail.ts b/plugins/pstack/skills/poteto-mode/scripts/monitor/web/rail.ts index a0b63803..fd671e84 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/monitor/web/rail.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/monitor/web/rail.ts @@ -1,12 +1,14 @@ import type { AgentId, AgentNode } from "../domain.ts"; -import { ago, kindLabel } from "../format.ts"; -import { isLive } from "../graph.ts"; +import { activityLine, ago, kindLabel, working } from "../format.ts"; import { h, icon, providerIcon } from "./dom.ts"; -// pstack-flex addition. The session list: live sessions first, then recent ones. +// pstack-flex addition. The session list, grouped by whether anyone is working. + +export type Scope = "pstack" | "all"; export interface RailEvents { select(root: AgentId): void; + toggleScope(): void; } export interface Descendants { @@ -14,35 +16,63 @@ export interface Descendants { readonly running: number; } +type Group = "working" | "open" | "earlier"; + +const GROUPS: readonly { readonly key: Group; readonly label: string }[] = [ + { key: "working", label: "Working" }, + { key: "open", label: "Open" }, + { key: "earlier", label: "Earlier" }, +]; + +function groupOf(root: AgentNode, counts: Descendants | undefined, now: number): Group { + if (working(root, now) || (counts?.running ?? 0) > 0) return "working"; + if (root.status.kind === "idle" && root.status.evidence === "pid") return "open"; + return "earlier"; +} + function folderName(cwd: string | null): string { if (cwd === null) return ""; const parts = cwd.split("/").filter((part) => part.length > 0); return parts.at(-1) ?? cwd; } +/** The row's second line: what the session is doing, or when it last did anything. */ +function rowActivity(root: AgentNode, group: Group, now: number): string { + const when = ago(root.lastActivityAt, now); + if (group === "working") return activityLine(root) ?? "working"; + if (group === "open") return `waiting for input${when.length > 0 ? ` · ${when}` : ""}`; + return [folderName(root.cwd), when].filter((part) => part.length > 0).join(" · "); +} + export class Rail { readonly element: HTMLElement; - private readonly live: HTMLUListElement; - private readonly earlier: HTMLUListElement; - private readonly liveGroup: HTMLElement; - private readonly earlierGroup: HTMLElement; + private readonly lists = new Map(); + private readonly groups = new Map(); + private readonly title: HTMLElement; private readonly count: HTMLElement; + private readonly scopeButton: HTMLButtonElement; private readonly foot: HTMLElement; private readonly empty: HTMLElement; constructor(private readonly events: RailEvents) { + this.title = h("span", { class: "rail-title" }); this.count = h("span", { class: "rail-count" }); - this.live = h("ul", { class: "sessions" }); - this.earlier = h("ul", { class: "sessions" }); - this.liveGroup = h("section", { class: "rail-group" }, h("h2", { class: "rail-heading", text: "Live now" }), this.live); - this.earlierGroup = h("section", { class: "rail-group" }, h("h2", { class: "rail-heading", text: "Earlier" }), this.earlier); - this.empty = h("p", { class: "rail-empty", text: "Sessions appear here once Claude Code or Codex writes a transcript." }); + this.scopeButton = h("button", { class: "rail-scope", attrs: { type: "button" } }); + this.scopeButton.addEventListener("click", () => this.events.toggleScope()); + const sections = GROUPS.map(({ key, label }) => { + const list = h("ul", { class: "sessions" }); + const section = h("section", { class: "rail-group", attrs: { "data-group": key } }, h("h2", { class: "rail-heading", text: label }), list); + this.lists.set(key, list); + this.groups.set(key, section); + return section; + }); + this.empty = h("p", { class: "rail-empty" }); this.foot = h("p", { class: "rail-foot" }); this.element = h( "nav", { class: "rail", attrs: { "aria-label": "Sessions" } }, - h("header", { class: "rail-head" }, h("span", { class: "rail-title", text: "Sessions" }), this.count), - h("div", { class: "rail-scroll" }, this.liveGroup, this.earlierGroup, this.empty), + h("header", { class: "rail-head" }, h("span", { class: "rail-names" }, this.title, this.count), this.scopeButton), + h("div", { class: "rail-scroll" }, ...sections, this.empty), this.foot, ); } @@ -52,23 +82,30 @@ export class Rail { descendants: ReadonlyMap, selected: AgentId | null, now: number, + scope: Scope, footer: string, ): void { - const live = roots.filter(isLive); - const earlier = roots.filter((root) => !isLive(root)); - this.live.replaceChildren(...live.map((root) => this.row(root, descendants.get(root.id), root.id === selected, now))); - this.earlier.replaceChildren(...earlier.map((root) => this.row(root, descendants.get(root.id), root.id === selected, now))); - this.liveGroup.hidden = live.length === 0; - this.earlierGroup.hidden = earlier.length === 0; + const rows = new Map(GROUPS.map(({ key }) => [key, []])); + for (const root of roots) { + const counts = descendants.get(root.id); + const group = groupOf(root, counts, now); + rows.get(group)!.push(this.row(root, counts, group, root.id === selected, now)); + } + for (const { key } of GROUPS) { + const items = rows.get(key)!; + this.lists.get(key)!.replaceChildren(...items); + this.groups.get(key)!.hidden = items.length === 0; + } + this.title.textContent = scope === "pstack" ? "pstack sessions" : "All sessions"; + this.scopeButton.textContent = scope === "pstack" ? "Show all" : "pstack only"; + this.scopeButton.setAttribute("aria-pressed", String(scope === "all")); + this.empty.textContent = scope === "pstack" ? "No pstack sessions yet." : "No sessions yet."; this.empty.hidden = roots.length > 0; this.count.textContent = String(roots.length); this.foot.textContent = footer; } - private row(root: AgentNode, counts: Descendants | undefined, selected: boolean, now: number): HTMLElement { - const folder = folderName(root.cwd); - const when = ago(root.lastActivityAt, now); - const meta = [folder, when].filter((part) => part.length > 0).join(" · "); + private row(root: AgentNode, counts: Descendants | undefined, group: Group, selected: boolean, now: number): HTMLElement { const total = counts?.total ?? 0; const running = counts?.running ?? 0; const button = h( @@ -78,14 +115,19 @@ export class Rail { title: `${root.title}\n${kindLabel(root)}${root.cwd === null ? "" : `\n${root.cwd}`}`, attrs: { type: "button", + "data-group": group, "data-status": root.status.kind, - "data-evidence": root.status.kind === "running" || root.status.kind === "idle" ? root.status.evidence : "", "data-harness": root.harness, ...(selected ? { "aria-current": "true" } : {}), }, }, h("span", { class: "session-glyph" }, icon(providerIcon(root.harness))), - h("span", { class: "session-text" }, h("span", { class: "session-title", text: root.title }), h("span", { class: "session-meta", text: meta })), + h( + "span", + { class: "session-text" }, + h("span", { class: "session-title", text: root.title }), + h("span", { class: "session-meta", text: rowActivity(root, group, now) }), + ), h( "span", { From 716987cb3602bf370e53ba38fda8600971428041 Mon Sep 17 00:00:00 2001 From: Martin Patino Date: Fri, 2 Oct 2026 17:25:42 -0700 Subject: [PATCH 3/3] docs(monitor): describe pstack scope and status, and trim the prose --- CHANGES.md | 9 ++-- README.md | 4 +- docs/USAGE.md | 75 +++++++++++--------------- docs/reference.md | 19 +++---- plugins/pstack/skills/monitor/SKILL.md | 32 ++++------- 5 files changed, 56 insertions(+), 83 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 18072306..360df52b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,11 +2,10 @@ ## Unreleased: agent monitor -- Add `/pstack:monitor` and `pstack-monitor`, a read-only local server with a live node canvas of the agents on this machine: Claude Code and Codex sessions, the subagents they spawn, and each agent's model. Any agent opens into a panel that streams its prompts, replies, thinking, and tool calls. The theme follows the session's harness: warm for Claude Code, blue for Codex. -- The monitor reads the transcripts each harness already writes and adds no hook, so it costs nothing in sessions that never open it. Status comes from process records, lifecycle events, and parent tool results, never file times; what cannot be known shows as unknown. Unrecognized transcript records are counted and surfaced instead of dropped, and `pstack-monitor doctor` reports them without printing content. -- Messages between agents appear as dashed, arrowed arcs with a count, from Codex's agent messages and Claude Code's `SendMessage` calls. Each new message sends a spark from sender to recipient. -- External lanes become visible while they run. `pstack-runner` writes an opt-in journal (start record, stdout as it arrives, receipt copy) under `~/.pstack-flex/lanes/` whenever that directory exists; `pstack-monitor start` creates it and `pstack-monitor journal off` deletes it. The new `--label` flag names a lane by its role, such as `arena cross-judge`, and routes nothing. A journal failure never changes a lane's receipt, exit status, or output. -- The server binds `127.0.0.1`, accepts no writes, rejects foreign `Host` and `Origin` headers, and requires a per-start token. It runs until `pstack-monitor stop`. Tracked in [pstack-flex #23](https://github.com/thisguymartin/pstack-flex/issues/23). +- Add `/pstack:monitor` and `pstack-monitor`, a read-only local server that draws pstack work on this machine as a live node canvas: each session that runs pstack, every subagent it spawns, and external Codex, Grok, DeepSeek, and MiniMax lanes, with each agent's model and messages. `--all` or **Show all** shows every session. Tracked in [pstack-flex #23](https://github.com/thisguymartin/pstack-flex/issues/23). +- Status comes from process records, turn boundaries, parent results, and lane receipts, never file times. The page separates working, waiting for input, stalled, and finished, and shows each agent's task, the tool call it waits on, and its latest step. +- The agent panel resizes by dragging its edge or with a widen button, and remembers its width. +- `pstack-runner` writes an opt-in lane journal (start record with the head of the prompt, stdout as it arrives, receipt copy) under `~/.pstack-flex/lanes/` while that directory exists. `--label` names a lane. A journal failure never changes a lane's receipt, exit status, or output. ## Unreleased: merge open-pstack 1.5.0 (Cursor pstack 0.15.5) diff --git a/README.md b/README.md index 8dd44487..61a9ac08 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ The monitor does not start by itself. Start it when you want to watch a run: /pstack:monitor ``` -In Codex, ask for `pstack:monitor`. It prints a link to a local page that draws every session, the agents it spawned, the external lanes pstack launched, and the messages between them, live. It keeps running in the background until you say "pstack, kill the monitor" or restart the machine. See [Watching your agents](docs/USAGE.md#watching-your-agents-the-monitor) for the terminal commands and troubleshooting. +In Codex, ask for `pstack:monitor`. It prints a link to a local page that draws each session running pstack, the agents it spawned, the external lanes pstack launched, and what each one is doing now, live. It keeps running in the background until you say "pstack, kill the monitor" or restart the machine. See [Watching your agents](docs/USAGE.md#watching-your-agents-the-monitor) for the terminal commands and troubleshooting. ## Useful skills @@ -167,7 +167,7 @@ In Codex, ask for `pstack:monitor`. It prints a link to a local page that draws | `maintain-verification-skill` | The project's verification instructions no longer match the product. | | `babysit` | A pull request needs CI failures and review comments handled until it is ready. | | `reflect` | A hard task is finished and its lessons should improve the next run. | -| `monitor` | You want to watch your agents work: a live graph of every session, subagent, and model lane, with each agent's activity a click away. | +| `monitor` | You want to watch your agents work: a live graph of each pstack session, its subagents and model lanes, and what each is doing now. | Plugin skills include `pstack:` in their name. In Claude Code, invoke a native skill such as `/pstack:architect`. In Codex, ask for the skill, such as `Use pstack:architect for this design.` See the [technical reference](docs/reference.md) for the full list. diff --git a/docs/USAGE.md b/docs/USAGE.md index 654a0195..913acd12 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -220,75 +220,62 @@ Every external lane writes a JSON receipt next to its output. The fields that ma ## Watching your agents: the monitor -The monitor is a local web page that draws every Claude Code and Codex session on this machine as a live graph. It shows the agents each session spawned, the external lanes pstack launched, and the messages they sent each other. Click any agent to read what it is doing. +The monitor is a local web page that shows pstack work on this machine as a live graph. Each session that runs pstack appears with every agent it spawned: native Claude and Codex subagents, and external Codex, Grok, DeepSeek, and MiniMax lanes. Click an agent to see its task, what it is doing now, and its full activity. -### It does not start by itself +A session counts as pstack work once it runs a `/pstack:` command, a pstack skill, or a pstack agent (in Codex, a prompt that names pstack or a call into pstack's skills or runner). **Show all** in the session list, or `start --all`, shows every session instead. -Nothing launches the monitor when a session starts or when you log in. Start it once. It then runs in the background, outside any session, until you stop it or restart the machine. Starting it while it already runs prints the same link again. After a plugin update, the next start replaces the old server with the new build. +Status comes from evidence: -### Start it - -In Claude Code: +| Shown | Meaning | +| --- | --- | +| working | the process is busy, or the agent is mid-turn | +| waiting for input | the session's process is open and idle | +| stalled | a turn started and has been silent for 15 minutes with no live process behind it | +| quiet | still working, but nothing new for 90 seconds | +| done, failed, cancelled | the agent or lane recorded an outcome | +| ended · process gone | the process exited without an outcome | -```text -/pstack:monitor -``` +### Start and stop -In Codex: +Nothing starts the monitor for you. Start it once; it runs in the background until you stop it or restart the machine. ```text -Use pstack:monitor. +/pstack:monitor # Claude Code +Use pstack:monitor. # Codex ``` -Either one prints a link such as `http://127.0.0.1:47317/?token=…`. Open it in your browser. The link carries an access token that is new each time the monitor starts, so an old link stops working after a restart. - -From a terminal, run the launcher inside the installed plugin. It needs Bun, which pstack's runner already uses. +Either prints a link such as `http://127.0.0.1:47317/?token=…`. The token changes on every start. From a terminal, run the launcher inside the installed plugin (it needs Bun): ```shell -# Claude Code install ~/.claude/plugins/cache/open-pstack/pstack//skills/poteto-mode/scripts/monitor/pstack-monitor start --parent claude - -# Codex install ~/.codex/plugins/cache/open-pstack/pstack//skills/poteto-mode/scripts/monitor/pstack-monitor start --parent codex - -# a checkout of this repository -plugins/pstack/skills/poteto-mode/scripts/monitor/pstack-monitor start ``` -`--parent` picks the page's starting theme, orange for Claude Code and blue for Codex, and selects that harness's current session first. Other options: `--port ` (default 47317), `--hours ` for how far back the session list reaches (default 24), and `--focus `. - -### Stop it and other commands +Options: `--all`, `--port ` (default 47317), `--hours ` (default 24), `--focus `. | Ask pstack | Or run | What happens | | --- | --- | --- | -| "pstack, kill the monitor" | `pstack-monitor stop` | Stops the server. Your agents keep running; the monitor never stops an agent. | -| "is the monitor running?" | `pstack-monitor status` | Prints agent counts and the link. | -| | `pstack-monitor doctor` | Reports how well recent transcripts parsed, as counts only. | -| | `pstack-monitor journal off` | Stops recording external lanes and deletes what was recorded. `on` and `status` also work. | - -To stop an agent itself, interrupt it in its own session, or cancel a pstack lane through the background task that launched it. The runner then writes a `cancelled` receipt. +| "pstack, kill the monitor" | `pstack-monitor stop` | Stops the server. Agents keep running. | +| "is the monitor running?" | `pstack-monitor status` | Prints counts and the link. | +| | `pstack-monitor doctor` | Reports how well recent transcripts parsed. | +| | `pstack-monitor journal off` | Stops recording lanes and deletes the records. | ### External lanes -pstack runs some lanes outside the harness, such as a Codex cross-judge launched from Claude Code. Those lanes normally leave nothing on disk until they finish. The first `start` turns on the lane journal, which records each lane in `~/.pstack-flex/lanes/` as it runs, so the monitor can show it live. The journal keeps each lane for 7 days, readable only by you. Lanes that started before the journal was on do not appear. - -Codex and Grok lanes stream while they work. Claude, DeepSeek, and MiniMax lanes appear as soon as they start, but their reply arrives only when they finish, because those CLIs print one result at exit. - -### What it reads and writes +The first `start` turns on the lane journal in `~/.pstack-flex/lanes/`, kept for 7 days, so lanes show while they run. Codex and Grok lanes stream. Claude, DeepSeek, and MiniMax lanes show their reply when they exit. Lanes started while the journal was off do not appear. -It reads the transcripts Claude Code and Codex already keep in `~/.claude` and `~/.codex` (or `CLAUDE_CONFIG_DIR` and `CODEX_HOME`). It writes only its own record and log in `~/.pstack-flex/monitor/` and the lane journal. It adds no hook and changes no harness setting. It listens on `127.0.0.1` only and accepts no writes. `PSTACK_FLEX_MONITOR_DIR` and `PSTACK_FLEX_LANES_DIR` move its two directories. +The monitor reads the transcripts in `~/.claude` and `~/.codex` (or `CLAUDE_CONFIG_DIR` and `CODEX_HOME`), writes only `~/.pstack-flex/monitor/` and the journal, listens on `127.0.0.1` only, and accepts no writes. ### When something looks wrong -| Symptom | Meaning | Fix | -| --- | --- | --- | -| `port 47317 is unavailable` | another program uses the port | `pstack-monitor start --port 47400` | -| The page says "This link has expired" | the monitor restarted, after an update or a reboot | run start again and open the new link | -| Start fails inside a sandboxed session | the sandbox blocked the local port or `~/.pstack-flex` | run the start command in your own terminal; in Claude Code, type it after `!` | -| A session says "status unknown" | Claude Desktop and SDK sessions keep no process record | nothing to fix; its activity still streams | -| A banner says a source is degraded or newer than checked | a CLI update changed its transcript format | run `pstack-monitor doctor` and open an issue with its output | -| An external lane never appears | the journal was off when the lane started | `pstack-monitor journal status`, then `pstack-monitor journal on` | -| A Codex thread stays "in turn" | Codex was killed mid-turn and wrote no ending | nothing to fix | +| Symptom | Fix | +| --- | --- | +| `port 47317 is unavailable` | `pstack-monitor start --port 47400` | +| "Link expired" | the monitor restarted; run start again and open the new link | +| Start fails in a sandboxed session | run the start command in your own terminal (in Claude Code, after `!`) | +| A session is missing | it has not run pstack yet; click **Show all** | +| A banner says a source is degraded | run `pstack-monitor doctor` and open an issue with its output | +| An external lane never appears | `pstack-monitor journal status`, then `journal on` | ## Cost playbook diff --git a/docs/reference.md b/docs/reference.md index 94a38496..7cdf5a2f 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -155,15 +155,16 @@ The table uses the short upstream names. Claude Code exposes each native skill w ## Agent monitor -`/monitor` starts `skills/poteto-mode/scripts/monitor/pstack-monitor`, a read-only server on `127.0.0.1` (default port 47317), and prints a link that carries a per-start access token. The page draws the selected session's agents as a node canvas, themed by the session's harness, and streams any agent's timeline in a side panel. - -- **Sources.** It reads what each harness already writes: Claude Code session transcripts, subagent transcripts and their `.meta.json` sidecars, and `~/.claude/sessions/.json` process records; Codex rollouts under `~/.codex/sessions/`, where a child thread names its parent. It adds no hook and changes no harness setting. `CLAUDE_CONFIG_DIR` and `CODEX_HOME` move the sources; `PSTACK_FLEX_MONITOR_DIR` moves the server record and log. -- **Status comes from evidence, never file times.** A Claude Code terminal session is live while its process record names a running process with the recorded start time. A subagent finishes on its parent's tool result or background-task notification. A Codex thread is in a turn between `task_started` and `task_complete` or `turn_aborted`. Claude Desktop and SDK sessions keep no process record, so their status shows as unknown rather than guessed. -- **External lanes.** `pstack-runner` keeps a lane's output in memory until it exits, so the runner also writes an opt-in journal: one directory per lane under `~/.pstack-flex/lanes/` (or `PSTACK_FLEX_LANES_DIR`) with `lane.json` (provider, model, effort, label, runner pid, parent session), `stream.jsonl` (the CLI's stdout as it arrives), and `receipt.json` (a copy of the receipt). The journal is on only while that directory exists: `pstack-monitor start` creates it, `pstack-monitor journal off` deletes it with everything recorded, and the server prunes lanes older than 7 days. A lane hangs under the session that launched it, read from that harness's own session variable. Codex and Grok lanes stream; Claude, DeepSeek, and MiniMax lanes print one result when they exit. A journal failure never changes a lane's receipt, exit status, or output. -- **Messages between agents.** Dashed, arrowed arcs show who messaged whom, with a count; each new message sends a spark along its arc, and an agent's panel totals what it sent and received. Codex records each message once, in the recipient's rollout, addressed by agent path (`/root`, `/root/`), and the monitor resolves the path within that tree. Claude Code messages come from `SendMessage` calls, addressed by agent id, name, or `main`. A message to another session's socket is left off the canvas, since that session is drawn separately. -- **Format drift is visible.** These transcript formats are undocumented. Unrecognized record types and malformed records are counted per source; the page shows a banner and `pstack-monitor doctor` prints the counts, record types, and CLI versions, never content. -- **Lifecycle.** `start` reuses a running server of the same build and replaces one from another build. `status`, `stop`, `doctor`, and `journal on|off|status` complete the CLI. Nothing times the server out. -- **Security.** The server binds loopback only, rejects foreign `Host` and `Origin` headers, accepts no writes, and requires the token (exchanged for an `HttpOnly`, `SameSite=Strict` cookie) on every route except a health check that reveals no data. The page runs under a same-origin content security policy and renders transcript text only as text. +`/monitor` starts `skills/poteto-mode/scripts/monitor/pstack-monitor`, a read-only server on `127.0.0.1` (default port 47317), and prints a link with a per-start access token. The page draws one session's agents as a node canvas and streams any agent's timeline in a resizable side panel. + +- **Scope.** The server indexes every Claude Code and Codex session and marks a spawn tree as pstack work when any member shows pstack evidence: `attributionPlugin`/`attributionSkill`/`attributionAgent` on Claude records, a `Skill` or `Agent` call naming `pstack:`, a `/pstack:` command, a `pstack:` subagent sidecar, a Codex root prompt naming pstack or a call into `pstack-runner` or pstack's skills, or any runner lane. The page shows marked trees by default; **Show all** or `?all=1` (`start --all`) shows everything. +- **Sources.** Claude Code session and subagent transcripts, `.meta.json` sidecars, and `~/.claude/sessions/.json` process records; Codex rollouts under `~/.codex/sessions/`. No hook, no harness setting. `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, and `PSTACK_FLEX_MONITOR_DIR` move them. +- **Status comes from evidence, never file times.** A terminal session follows its live process record (`busy` is working, `idle` is waiting for input). Sessions without one, and Claude subagents, follow turn boundaries: a human prompt opens a turn and an `end_turn` reply closes it. A subagent is done when its own turn ends or its parent records the result, whichever comes first. A Codex thread follows `task_started` and `task_complete`. The page adds two qualifiers: quiet after 90 s without stamped activity, and stalled once a turn-only run has been silent for 15 minutes. +- **Activity.** Each agent carries its latest prompt, the tool call still awaiting a result, and its latest step. Cards, the session list, and the panel's Task and Now lines show them. +- **External lanes.** `pstack-runner` writes an opt-in journal under `~/.pstack-flex/lanes/` (or `PSTACK_FLEX_LANES_DIR`) while that directory exists: `lane.json` (provider, model, effort, label, the first 300 characters of the prompt, runner pid, parent session), `stream.jsonl` (stdout as it arrives), and `receipt.json`. `pstack-monitor start` creates it, `journal off` deletes it, and the server prunes lanes older than 7 days. Codex and Grok lanes stream; Claude, DeepSeek, and MiniMax lanes print one result at exit. A journal failure never changes a lane's receipt, exit status, or output. +- **Messages between agents.** Arrowed arcs with a count, from Codex agent messages (addressed by agent path) and Claude Code `SendMessage` calls. Messages to another session's socket are left off. +- **Format drift is visible.** Unrecognized and malformed records are counted per source; the page shows a banner and `pstack-monitor doctor` prints counts and CLI versions, never content. +- **Lifecycle and security.** `start` reuses a running server of the same build and replaces another build. The server binds loopback, rejects foreign `Host` and `Origin` headers, accepts no writes, requires the token (exchanged for an `HttpOnly`, `SameSite=Strict` cookie) everywhere except a data-free health check, and renders transcript text only as text. Nothing times it out. ## Subagents diff --git a/plugins/pstack/skills/monitor/SKILL.md b/plugins/pstack/skills/monitor/SKILL.md index 187b6deb..ce1f2c5b 100644 --- a/plugins/pstack/skills/monitor/SKILL.md +++ b/plugins/pstack/skills/monitor/SKILL.md @@ -1,41 +1,27 @@ --- name: monitor -description: "Open a live local view of the agents on this machine: Claude Code and Codex sessions, every subagent they spawn, and pstack's external model lanes, with each agent's activity streaming in a drill-down panel. Also stops it. Use for /monitor, 'show me the agents', 'what is running', watching an arena, swarm, or interrogate fan-out, or 'stop / kill / shut down the monitor'." +description: "Open a live local view of pstack work on this machine: every session that runs pstack and every agent it spawns, including native Claude and Codex subagents and external Codex, Grok, DeepSeek, and MiniMax lanes, with what each is doing now. Also stops it. Use for /monitor, 'show me the agents', 'what is running', watching an arena, swarm, or interrogate fan-out, or 'stop / kill / shut down the monitor'." --- # Monitor -Start the agent monitor and hand the user its link, or stop it when asked. - -Match the request to one command and run only that one: +Run one command and relay its output. | The user asks to | Run | | --- | --- | | open, show, or start the monitor (the default) | `pstack-monitor start --parent ` | +| see every session, not only pstack's | `pstack-monitor start --parent --all` | | stop, kill, close, or shut down the monitor | `pstack-monitor stop` | | check whether it is running, or get the link again | `pstack-monitor status` | -`stop` ends the monitor server only. It never touches the sessions or agents the page shows; the monitor is read-only. If the user wants a running agent stopped, tell them to stop it where it runs: interrupt it in its own Claude Code or Codex session, or cancel a pstack lane through the background task that launched it, which makes the runner write a `cancelled` receipt. - -The launcher lives at `skills/poteto-mode/scripts/monitor/pstack-monitor` under the installed plugin. Run it with the harness you are running in: - -```text -pstack-monitor start --parent -``` - -`start` returns at once. It reuses a running monitor of the same build, replaces one from an older build, or launches a new one in the background, then prints one link. Give the user that link exactly as printed and say nothing else is needed. The link carries the server's access token: never paste it anywhere but the reply to the user, and never open it with a fetch tool. - -The first `start` also turns on the lane journal, so external lanes launched through `pstack-runner` appear while they run, and says so on stderr. Relay that line: the journal keeps lane output on disk for 7 days, and `pstack-monitor journal off` stops it and deletes what it kept. - -`--parent` sets the page's theme (warm for Claude Code, blue for Codex) and selects your own session first. It names the harness you are; it does not route anything. +The launcher is `skills/poteto-mode/scripts/monitor/pstack-monitor` under the installed plugin. Pass the harness you are running in as `--parent`. -`stop` waits until the server has exited, then prints `pstack-monitor stopped`, or `pstack-monitor is not running` when there was nothing to stop. Relay that line. Stopping leaves the lane journal as it is; `journal off` is a separate request. +`start` returns at once and prints one link. Give the user that link exactly as printed. The link carries an access token: never paste it anywhere else, and never open it with a fetch tool. -Other commands: +The first `start` also turns on the lane journal and says so on stderr. Relay that line: lane output is kept on disk for 7 days, and `pstack-monitor journal off` stops it and deletes what it kept. -- `pstack-monitor doctor` reports how well recent transcripts parsed, as counts only. Run it when the page warns that a source is degraded or newer than the monitor was checked against, and relay the result. -- `pstack-monitor journal ` controls the lane journal. +`stop` ends the monitor only, never an agent. To stop an agent, the user interrupts it in its own session, or cancels a pstack lane through the background task that launched it. -Apart from the lane journal, the monitor only reads transcripts that Claude Code and Codex already write. It changes no harness setting, adds no hook, and has no idle timeout: it runs until `stop`. +`pstack-monitor doctor` reports how well recent transcripts parsed. Run it when the page warns that a source is degraded, and relay the result. -If `start` fails because the sandbox blocks a local port or `~/.pstack-flex/monitor`, do not raise permissions. Give the user the command to run in their own terminal; in Claude Code they can type it after `!`. +If `start` fails because the sandbox blocks a local port or `~/.pstack-flex`, do not raise permissions. Give the user the command to run; in Claude Code they can type it after `!`.