@saadnvd1/agent-os 0.3.737 → 0.3.742

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/components/Chat/Activity.test.ts +2 -1
  2. package/components/Chat/Activity.tsx +102 -64
  3. package/components/Chat/ChatPanel.tsx +16 -3
  4. package/components/Chat/LiveOutput.test.ts +36 -0
  5. package/components/Chat/Messages.test.ts +13 -1
  6. package/components/Chat/Messages.tsx +19 -2
  7. package/components/Chat/StepOutcomes.tsx +66 -0
  8. package/components/Chat/Tools.test.ts +52 -0
  9. package/components/Chat/Tools.tsx +63 -9
  10. package/data/chat/useChat.ts +10 -1
  11. package/lib/chat/drivers/claude-mapper.ts +10 -0
  12. package/lib/chat/drivers/claude.ts +9 -1
  13. package/lib/chat/drivers/edit-guard.ts +311 -0
  14. package/lib/chat/drivers/opencode-config.ts +111 -0
  15. package/lib/chat/drivers/opencode-mapper.ts +125 -13
  16. package/lib/chat/drivers/opencode-plugin.ts +78 -0
  17. package/lib/chat/drivers/opencode-server.ts +7 -2
  18. package/lib/chat/drivers/opencode.ts +108 -9
  19. package/lib/chat/drivers/pi-edit-guard.ts +87 -0
  20. package/lib/chat/drivers/pi-mapper.ts +91 -2
  21. package/lib/chat/drivers/pi.ts +95 -4
  22. package/lib/chat/drivers/transient-retry.ts +87 -0
  23. package/lib/chat/drivers/turn-items.ts +74 -1
  24. package/lib/chat/drivers/turn-outcome.ts +73 -1
  25. package/lib/chat/events.ts +17 -1
  26. package/lib/chat/group.ts +23 -3
  27. package/lib/chat/model-wait-live.ts +19 -0
  28. package/lib/chat/model-wait.ts +88 -0
  29. package/lib/chat/page.ts +3 -0
  30. package/lib/chat/progress.ts +169 -0
  31. package/lib/chat/registry.ts +5 -0
  32. package/lib/chat/runner-watch.ts +68 -0
  33. package/lib/chat/runner.ts +4 -70
  34. package/lib/chat/tool-outcome.ts +45 -0
  35. package/lib/chat/tools.ts +14 -0
  36. package/package.json +1 -1
  37. package/scripts/screenshots/chat-verify.ts +68 -0
  38. package/scripts/screenshots/seed.ts +9 -0
@@ -185,6 +185,8 @@ export type ChatItem =
185
185
  input: unknown;
186
186
  status: ToolStatus;
187
187
  output?: string;
188
+ // How it ended ("exit 1", "HTTP 404"), kept when the output is deferred.
189
+ outcome?: string;
188
190
  diff?: FileDiff;
189
191
  // When it finished, to show how long a step took.
190
192
  endedAt?: number;
@@ -320,6 +322,14 @@ export interface Compacting {
320
322
  before?: number;
321
323
  }
322
324
 
325
+ // A model call the turn is waiting on: since when, the last measured prompt
326
+ // size (a floor: the next prompt is at least that big), and a retry under way.
327
+ export interface ModelWait {
328
+ since: number;
329
+ promptTokens?: number;
330
+ retry?: { attempt: number; message?: string };
331
+ }
332
+
323
333
  export interface UndoPreview {
324
334
  canUndo: boolean;
325
335
  error?: string;
@@ -345,7 +355,11 @@ export type DriverEvent =
345
355
  | { type: "at_rest" } // it has run every message it was given
346
356
  | { type: "state"; state: ChatState }
347
357
  // The agent started (true) or finished (false) compacting its context.
348
- | { type: "compacting"; active: boolean };
358
+ | { type: "compacting"; active: boolean }
359
+ // One model response's measured prompt (input plus cache), in tokens.
360
+ | { type: "prompt_tokens"; tokens: number }
361
+ // The agent is retrying a failed model request.
362
+ | { type: "model_retry"; attempt: number; message?: string };
349
363
 
350
364
  // A tool call's output and diff, loaded when it's opened.
351
365
  export interface ToolBody {
@@ -366,6 +380,7 @@ export type ChatServerMessage =
366
380
  tasks: ChatItem[];
367
381
  state: ChatState;
368
382
  compacting?: Compacting | null;
383
+ modelWait?: ModelWait | null;
369
384
  queue: QueuedMessage[];
370
385
  suggestion: string | null;
371
386
  }
@@ -391,6 +406,7 @@ export type ChatServerMessage =
391
406
  | { type: "delta"; id: string; text: string }
392
407
  | { type: "state"; state: ChatState }
393
408
  | { type: "compacting"; compacting: Compacting | null }
409
+ | { type: "model_wait"; wait: ModelWait | null }
394
410
  | {
395
411
  type: "capabilities";
396
412
  commands: ChatCommand[];
package/lib/chat/group.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  import type { ChatItem } from "./events";
2
2
  import { readingOrder } from "./reading-order";
3
+ import { stepCounts, type StepCounts } from "./tool-outcome";
3
4
  import { eventTurnAt, type EventTurn } from "./turns";
4
5
 
5
6
  export type ToolItem = Extract<ChatItem, { kind: "tool" }>;
6
7
  export type UndoItem = Extract<ChatItem, { kind: "undo" }>;
7
8
 
8
9
  export type TimelineBlock =
9
- | { type: "item"; item: ChatItem }
10
+ // `steps` rides on the end of a stopped or failed turn: what ran in it.
11
+ | { type: "item"; item: ChatItem; steps?: StepCounts }
10
12
  | { type: "tools"; id: string; tools: ToolItem[] }
11
13
  | { type: "undone"; id: string; undo: UndoItem; items: ChatItem[] }
12
14
  // Consecutive turns an event started, with the reply to each.
@@ -30,6 +32,8 @@ export function groupTimeline(
30
32
  const items = opts.foldEvents ? readingOrder(all) : all;
31
33
  const blocks: TimelineBlock[] = [];
32
34
  let tools: ToolItem[] = [];
35
+ // Every tool call since the last turn ended, grouped or not.
36
+ let turnTools: ToolItem[] = [];
33
37
  const flush = () => {
34
38
  if (tools.length) blocks.push({ type: "tools", id: tools[0].id, tools });
35
39
  tools = [];
@@ -74,8 +78,10 @@ export function groupTimeline(
74
78
  i = end;
75
79
  } else if (item.kind === "tool" && STANDALONE_TOOLS.has(item.name)) {
76
80
  flush();
81
+ turnTools.push(item);
77
82
  blocks.push({ type: "item", item });
78
83
  } else if (item.kind === "tool") {
84
+ turnTools.push(item);
79
85
  tools.push(item);
80
86
  } else if (item.kind === "reasoning" && tools.length) {
81
87
  continue;
@@ -91,7 +97,15 @@ export function groupTimeline(
91
97
  continue;
92
98
  } else {
93
99
  flush();
94
- blocks.push({ type: "item", item });
100
+ const cut =
101
+ item.kind === "turn_end" &&
102
+ (item.interrupted || item.ended?.reason === "error");
103
+ blocks.push(
104
+ cut && turnTools.length
105
+ ? { type: "item", item, steps: stepCounts(turnTools) }
106
+ : { type: "item", item }
107
+ );
108
+ if (item.kind === "turn_end") turnTools = [];
95
109
  }
96
110
  }
97
111
  flush();
@@ -123,7 +137,13 @@ function undoneRanges(items: ChatItem[]): Map<number, number> {
123
137
  // until they change, so a streamed delta re-renders only its own block.
124
138
  export function sameBlock(a: TimelineBlock, b: TimelineBlock): boolean {
125
139
  if (a === b) return true;
126
- if (a.type === "item" && b.type === "item") return a.item === b.item;
140
+ if (a.type === "item" && b.type === "item")
141
+ return (
142
+ a.item === b.item &&
143
+ a.steps?.total === b.steps?.total &&
144
+ a.steps?.ok === b.steps?.ok &&
145
+ a.steps?.failed === b.steps?.failed
146
+ );
127
147
  if (a.type === "tools" && b.type === "tools")
128
148
  return sameItems(a.tools, b.tools);
129
149
  if (a.type === "undone" && b.type === "undone")
@@ -0,0 +1,19 @@
1
+ import { emit, type Live } from "./registry";
2
+ import { ModelWaitTracker } from "./model-wait";
3
+ import type { WorkerEvent } from "./worker/protocol";
4
+
5
+ // Keeps the model call the turn waits on current; the events that only feed
6
+ // it (prompt size, a retry) go no further.
7
+ export function trackModelWait(
8
+ sessionId: string,
9
+ live: Live,
10
+ e: WorkerEvent
11
+ ): boolean {
12
+ const tracker = (live.waitTracker ??= new ModelWaitTracker());
13
+ const changed = tracker.on(e);
14
+ if (changed) {
15
+ live.modelWait = tracker.wait;
16
+ emit(sessionId, { type: "model_wait", wait: tracker.wait });
17
+ }
18
+ return e.type === "prompt_tokens" || e.type === "model_retry";
19
+ }
@@ -0,0 +1,88 @@
1
+ import type { ModelWait } from "./events";
2
+ import type { WorkerEvent } from "./worker/protocol";
3
+
4
+ // Follows a running turn's events to know when it is waiting on a model
5
+ // call: no tool running, nothing streaming, not compacting. Only what a
6
+ // driver measured is kept as the prompt size; nothing is guessed.
7
+ export class ModelWaitTracker {
8
+ wait: ModelWait | null = null;
9
+ private lastPrompt: number | undefined;
10
+ private running = new Set<string>();
11
+ private active = false;
12
+
13
+ // Returns whether `wait` changed.
14
+ on(e: WorkerEvent, now = Date.now()): boolean {
15
+ const before = this.wait;
16
+ switch (e.type) {
17
+ case "state":
18
+ if (e.state === "running") {
19
+ if (!this.active) {
20
+ this.active = true;
21
+ this.begin(now);
22
+ }
23
+ } else this.end();
24
+ break;
25
+ case "item": {
26
+ const i = e.item;
27
+ if (i.kind === "turn_end") this.end();
28
+ else if (i.kind === "compacted") this.lastPrompt = undefined;
29
+ else if (i.kind === "tool") {
30
+ if (i.status === "running") {
31
+ this.running.add(i.id);
32
+ this.wait = null;
33
+ } else {
34
+ this.running.delete(i.id);
35
+ if (this.active && !this.running.size) this.begin(now);
36
+ }
37
+ } else if (i.kind === "assistant" || i.kind === "reasoning") {
38
+ if (i.streaming) this.wait = null;
39
+ else if (this.active && !this.running.size && !this.wait)
40
+ this.begin(now);
41
+ }
42
+ break;
43
+ }
44
+ case "delta":
45
+ this.wait = null;
46
+ break;
47
+ case "compacting":
48
+ if (e.compacting) {
49
+ this.wait = null;
50
+ this.lastPrompt = undefined;
51
+ } else if (this.active && !this.running.size) this.begin(now);
52
+ break;
53
+ case "prompt_tokens":
54
+ this.lastPrompt = e.tokens;
55
+ break;
56
+ case "context":
57
+ this.lastPrompt = e.context.usedTokens || this.lastPrompt;
58
+ break;
59
+ case "model_retry":
60
+ if (this.active)
61
+ this.wait = {
62
+ ...(this.wait ?? this.fresh(now)),
63
+ retry: { attempt: e.attempt, message: e.message },
64
+ };
65
+ break;
66
+ }
67
+ return (
68
+ before !== this.wait &&
69
+ JSON.stringify(before) !== JSON.stringify(this.wait)
70
+ );
71
+ }
72
+
73
+ private fresh(now: number): ModelWait {
74
+ return this.lastPrompt
75
+ ? { since: now, promptTokens: this.lastPrompt }
76
+ : { since: now };
77
+ }
78
+
79
+ private begin(now: number) {
80
+ this.wait = this.fresh(now);
81
+ }
82
+
83
+ private end() {
84
+ this.active = false;
85
+ this.wait = null;
86
+ this.running.clear();
87
+ }
88
+ }
package/lib/chat/page.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { db } from "../db";
2
2
  import type { ChatItem, ToolBody } from "./events";
3
+ import { toolCode } from "./tool-outcome";
3
4
 
4
5
  // A conversation opens on its latest items and pages older ones in as the
5
6
  // reader scrolls up, so a long one costs what's on screen, not its history.
@@ -23,8 +24,10 @@ export function lighten(item: ChatItem): ChatItem {
23
24
  if (item.kind !== "tool" || (item.output === undefined && !item.diff))
24
25
  return item;
25
26
  const { output, diff, ...rest } = item;
27
+ const outcome = toolCode(output);
26
28
  return {
27
29
  ...rest,
30
+ ...(outcome ? { outcome } : {}),
28
31
  deferred: true,
29
32
  ...(diff ? { hasDiff: true } : {}),
30
33
  ...(output ? { hasOutput: true } : {}),
@@ -0,0 +1,169 @@
1
+ import type { ChatItem, ChatState, Compacting, ModelWait } from "./events";
2
+ import { formatTokens } from "./context";
3
+
4
+ type Tool = Extract<ChatItem, { kind: "tool" }>;
5
+
6
+ export type Phase = "planning" | "coding" | "testing" | "error";
7
+
8
+ // A wait on one model call this long gets a hint that it may be normal.
9
+ export const LONG_WAIT_MS = 2 * 60_000;
10
+ export const LONG_WAIT_HINT =
11
+ "Still waiting on the model; large prompts on local models can take minutes";
12
+
13
+ const TEST_RE =
14
+ /\b(test|tests|vitest|jest|pytest|mocha|playwright|tsc|typecheck|lint|eslint|build|check)\b|cargo (test|build|check)|go (test|build|vet)/;
15
+ const EDIT_TOOLS = new Set([
16
+ "Edit",
17
+ "Write",
18
+ "MultiEdit",
19
+ "NotebookEdit",
20
+ "apply_patch",
21
+ ]);
22
+ const VERBS: Record<string, string> = {
23
+ Read: "Reading",
24
+ Edit: "Editing",
25
+ Write: "Writing",
26
+ MultiEdit: "Editing",
27
+ NotebookEdit: "Editing",
28
+ Search: "Searching",
29
+ Find: "Finding",
30
+ Fetch: "Fetching",
31
+ List: "Listing",
32
+ Run: "Running",
33
+ };
34
+
35
+ const str = (v: unknown) => (typeof v === "string" ? v : "");
36
+ const input = (t: Tool) => (t.input ?? {}) as Record<string, unknown>;
37
+ const command = (t: Tool) => str(input(t).command);
38
+
39
+ // "ollama/qwen3-coder:30b" -> "qwen3-coder:30b"; nothing known -> "the model".
40
+ export function shortModel(label: string | undefined): string {
41
+ let m = (label ?? "").trim().split("/").at(-1) ?? "";
42
+ m = m.replace(/:latest$/, "").replace(/-\d{8}$/, "");
43
+ if (!m || m.toLowerCase() === "default") return "the model";
44
+ return m.length > 24 ? `${m.slice(0, 23)}…` : m;
45
+ }
46
+
47
+ const tail = (path: string) => path.split("/").slice(-3).join("/");
48
+ const clip = (s: string, n = 60) =>
49
+ s.length > n ? `${s.slice(0, n - 1)}…` : s;
50
+
51
+ // A tool call in plain words: "Running npm test", "Editing src/a/b.ts".
52
+ export function stepLabel(t: Tool): string {
53
+ const cmd = command(t);
54
+ if (cmd) return `Running ${clip(cmd.replace(/\s+/g, " ").trim())}`;
55
+ const path = str(input(t).file_path);
56
+ if (path && EDIT_TOOLS.has(t.name)) {
57
+ const verb = VERBS[t.name] ?? "Editing";
58
+ return `${verb} ${tail(path)}`;
59
+ }
60
+ const [first, ...rest] = t.title.split(" ");
61
+ return VERBS[first] && rest.length
62
+ ? `${VERBS[first]} ${rest.join(" ")}`
63
+ : t.title;
64
+ }
65
+
66
+ export function turnTools(items: ChatItem[]): Tool[] {
67
+ let start = 0;
68
+ items.forEach((i, n) => {
69
+ if (i.kind === "user" && !i.midTurn) start = n + 1;
70
+ });
71
+ return items.slice(start).filter((i): i is Tool => i.kind === "tool");
72
+ }
73
+
74
+ const edited = (t: Tool) => EDIT_TOOLS.has(t.name) || !!t.diff;
75
+
76
+ // Planning, coding, testing or error, from the turn's tools: the current
77
+ // step (running, else the last one) decides error and testing; having
78
+ // edited anything in the turn makes the rest coding.
79
+ export function phaseOf(tools: Tool[]): Phase {
80
+ const step = tools.findLast((t) => t.status === "running") ?? tools.at(-1);
81
+ if (!step) return "planning";
82
+ if (step.status === "error") return "error";
83
+ if (TEST_RE.test(command(step))) return "testing";
84
+ return tools.some(edited) ? "coding" : "planning";
85
+ }
86
+
87
+ export interface Activity {
88
+ label: string;
89
+ since?: number;
90
+ compacting?: boolean;
91
+ kind: "answer" | "compacting" | "tool" | "writing" | "waiting" | "retry";
92
+ // The last measured prompt, when a driver reported one.
93
+ tokens?: number;
94
+ phase?: Phase;
95
+ steps: number;
96
+ // The last finished step, while no tool runs.
97
+ lastStep?: string;
98
+ turnSince?: number;
99
+ hint?: string;
100
+ }
101
+
102
+ // What the turn is doing now and for how long, from its items.
103
+ export function currentActivity(
104
+ items: ChatItem[],
105
+ state: ChatState,
106
+ compacting: Compacting | null = null,
107
+ modelWait: ModelWait | null = null,
108
+ model?: string,
109
+ now: number = Date.now()
110
+ ): Activity | null {
111
+ if (state === "waiting")
112
+ return { label: "Waiting for your answer", kind: "answer", steps: 0 };
113
+ if (state !== "running") return null;
114
+ if (compacting)
115
+ return {
116
+ label: `Compacting context…${compacting.before ? ` ${formatTokens(compacting.before)} tokens` : ""}`,
117
+ since: compacting.since,
118
+ compacting: true,
119
+ kind: "compacting",
120
+ steps: 0,
121
+ };
122
+ const lastUser = items.findLast((i) => i.kind === "user" && !i.midTurn);
123
+ const tools = turnTools(items);
124
+ const base = {
125
+ steps: tools.length,
126
+ turnSince: lastUser?.createdAt,
127
+ phase: tools.length ? phaseOf(tools) : undefined,
128
+ };
129
+ const tool = tools.findLast((t) => t.status === "running");
130
+ if (tool)
131
+ return {
132
+ ...base,
133
+ label: stepLabel(tool),
134
+ since: tool.createdAt,
135
+ kind: "tool",
136
+ };
137
+ const last = items.at(-1);
138
+ if (
139
+ last &&
140
+ (last.kind === "assistant" || last.kind === "reasoning") &&
141
+ last.streaming
142
+ )
143
+ return {
144
+ ...base,
145
+ label: last.kind === "assistant" ? "Writing" : "Thinking",
146
+ since: lastUser?.createdAt,
147
+ kind: "writing",
148
+ };
149
+ const done = tools.findLast((t) => t.status !== "running");
150
+ const since =
151
+ modelWait?.since ?? done?.endedAt ?? done?.createdAt ?? lastUser?.createdAt;
152
+ const retry = modelWait?.retry;
153
+ return {
154
+ ...base,
155
+ label: retry
156
+ ? `Retrying model request (attempt ${retry.attempt})`
157
+ : `Waiting for ${shortModel(model)}`,
158
+ since,
159
+ kind: retry ? "retry" : "waiting",
160
+ tokens: modelWait?.promptTokens,
161
+ lastStep: done
162
+ ? `${stepLabel(done)}${done.status === "error" ? " failed" : ""}`
163
+ : undefined,
164
+ hint:
165
+ since !== undefined && now - since > LONG_WAIT_MS
166
+ ? LONG_WAIT_HINT
167
+ : undefined,
168
+ };
169
+ }
@@ -10,9 +10,11 @@ import type {
10
10
  ChatServerMessage,
11
11
  ChatState,
12
12
  Compacting,
13
+ ModelWait,
13
14
  } from "./events";
14
15
  import { markStatusDirty } from "../status/dirty";
15
16
  import { FLUSH_MS } from "./store";
17
+ import type { ModelWaitTracker } from "./model-wait";
16
18
 
17
19
  // Long enough for the worker's buffered write of an item to land.
18
20
  const EMIT_SETTLE_MS = 2 * FLUSH_MS;
@@ -23,6 +25,9 @@ export interface Live {
23
25
  state: ChatState;
24
26
  // Set while its agent compacts the context, over a "running" state.
25
27
  compacting?: Compacting | null;
28
+ // The model call the turn is waiting on, if any (lib/chat/model-wait).
29
+ modelWait?: ModelWait | null;
30
+ waitTracker?: ModelWaitTracker;
26
31
  // Streamed items carry their latest text here, ahead of SQLite.
27
32
  streaming: Map<string, ChatItem>;
28
33
  // What the turn is doing right now, for the session list.
@@ -0,0 +1,68 @@
1
+ import type { ChatContext, ChatItem } from "./events";
2
+ import { getSession, registry, type Listener } from "./registry";
3
+ import { releaseUnwatchedSoon, sendCapabilities } from "./settings";
4
+ import { recentTasks, settle } from "./store";
5
+ import { readPage, toolBody, type ItemPage } from "./page";
6
+ import { listQueue } from "./queued";
7
+ import { runningWorkers } from "./worker/client";
8
+
9
+ function savedContext(sessionId: string): ChatContext | null {
10
+ const raw = getSession(sessionId).chat_context;
11
+ if (!raw) return null;
12
+ try {
13
+ return JSON.parse(raw) as ChatContext;
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+
19
+ // What a watcher is sent of stored items: the live text of what's still
20
+ // streaming, or with no conversation running, nothing left running.
21
+ function present(sessionId: string, items: ChatItem[]): ChatItem[] {
22
+ const live = registry.live.get(sessionId);
23
+ if (live) return items.map((i) => live.streaming.get(i.id) ?? i);
24
+ return runningWorkers().includes(sessionId) ? items : settle(items);
25
+ }
26
+
27
+ // Snapshot then live updates. Returns the unsubscribe function. Every
28
+ // client gets the latest page and asks for older ones.
29
+ export function watchChat(sessionId: string, listener: Listener): () => void {
30
+ const live = registry.live.get(sessionId);
31
+ const page = readPage(sessionId);
32
+ const onPage = new Set(page.items.map((i) => i.id));
33
+ // The composer lists background work still going or just done, even when
34
+ // it started before the page.
35
+ const recent = Date.now() - 60 * 60 * 1000;
36
+ const tasks = recentTasks(sessionId, recent).filter((t) => !onPage.has(t.id));
37
+ listener({
38
+ type: "snapshot",
39
+ items: present(sessionId, page.items),
40
+ cursor: page.cursor,
41
+ hasMore: page.hasMore,
42
+ tasks: present(sessionId, tasks),
43
+ state: live?.state ?? "idle",
44
+ compacting: live?.compacting ?? null,
45
+ modelWait: live?.modelWait ?? null,
46
+ queue: listQueue(sessionId),
47
+ suggestion: getSession(sessionId).chat_suggestion ?? null,
48
+ });
49
+ listener({ type: "context", context: savedContext(sessionId) });
50
+ sendCapabilities(sessionId, listener).catch((error) =>
51
+ console.error(`[chat ${sessionId}] capabilities not sent:`, error)
52
+ );
53
+ let set = registry.listeners.get(sessionId);
54
+ if (!set) registry.listeners.set(sessionId, (set = new Set()));
55
+ set.add(listener);
56
+ return () => {
57
+ set?.delete(listener);
58
+ releaseUnwatchedSoon();
59
+ };
60
+ }
61
+
62
+ // The page before `before`, as the reader scrolls up.
63
+ export function chatHistory(sessionId: string, before: number): ItemPage {
64
+ const page = readPage(sessionId, before);
65
+ return { ...page, items: present(sessionId, page.items) };
66
+ }
67
+
68
+ export const chatToolBody = toolBody;
@@ -16,9 +16,9 @@ import { randomUUID } from "crypto";
16
16
  import os from "os";
17
17
  import { db, type Session } from "../db";
18
18
  import type { CompactFirst } from "./stale";
19
+ import { trackModelWait } from "./model-wait-live";
19
20
  import type {
20
21
  ApprovalDecision,
21
- ChatContext,
22
22
  ChatFile,
23
23
  ChatImage,
24
24
  ChatServerMessage,
@@ -34,26 +34,17 @@ import {
34
34
  type Listener,
35
35
  type Live,
36
36
  } from "./registry";
37
- import {
38
- capsKey,
39
- emitCapabilities,
40
- releaseUnwatchedSoon,
41
- sendCapabilities,
42
- setChatPlan,
43
- } from "./settings";
37
+ import { capsKey, emitCapabilities, setChatPlan } from "./settings";
44
38
  import {
45
39
  endOrphanedTasks,
46
40
  endRunningTasks,
47
41
  getItem,
48
42
  hasItem,
49
43
  itemsOfKind,
50
- recentTasks,
51
44
  sessionsWithRunningTasks,
52
45
  saveItem,
53
- settle,
54
46
  } from "./store";
55
47
  import { noteToolResult } from "../health/env/recordHit";
56
- import { readPage, toolBody, type ItemPage } from "./page";
57
48
  import {
58
49
  deleteQueued,
59
50
  editQueued,
@@ -101,16 +92,6 @@ export {
101
92
  setChatPlan,
102
93
  } from "./settings";
103
94
 
104
- function savedContext(sessionId: string): ChatContext | null {
105
- const raw = getSession(sessionId).chat_context;
106
- if (!raw) return null;
107
- try {
108
- return JSON.parse(raw) as ChatContext;
109
- } catch {
110
- return null;
111
- }
112
- }
113
-
114
95
  const CARRY_OUT = "Carry out the plan.";
115
96
 
116
97
  // Leaves plan mode and starts the plan. The message's id comes from the
@@ -192,6 +173,7 @@ function emitEnded(
192
173
 
193
174
  function onWorkerEvent(sessionId: string, live: Live, e: WorkerEvent): void {
194
175
  track(live, e);
176
+ if (trackModelWait(sessionId, live, e)) return;
195
177
  if (e.type === "item" && e.item.kind === "tool")
196
178
  noteToolResult(sessionId, e.item);
197
179
  if (
@@ -968,55 +950,7 @@ export function chatTaskOutput(
968
950
  return taskOutputTail(taskId, task.outputFile);
969
951
  }
970
952
 
971
- // What a watcher is sent of stored items: the live text of what's still
972
- // streaming, or with no conversation running, nothing left running.
973
- function present(sessionId: string, items: ChatItem[]): ChatItem[] {
974
- const live = registry.live.get(sessionId);
975
- if (live) return items.map((i) => live.streaming.get(i.id) ?? i);
976
- return runningWorkers().includes(sessionId) ? items : settle(items);
977
- }
978
-
979
- // Snapshot then live updates. Returns the unsubscribe function. Every
980
- // client gets the latest page and asks for older ones.
981
- export function watchChat(sessionId: string, listener: Listener): () => void {
982
- const live = registry.live.get(sessionId);
983
- const page = readPage(sessionId);
984
- const onPage = new Set(page.items.map((i) => i.id));
985
- // The composer lists background work still going or just done, even when
986
- // it started before the page.
987
- const recent = Date.now() - 60 * 60 * 1000;
988
- const tasks = recentTasks(sessionId, recent).filter((t) => !onPage.has(t.id));
989
- listener({
990
- type: "snapshot",
991
- items: present(sessionId, page.items),
992
- cursor: page.cursor,
993
- hasMore: page.hasMore,
994
- tasks: present(sessionId, tasks),
995
- state: live?.state ?? "idle",
996
- compacting: live?.compacting ?? null,
997
- queue: listQueue(sessionId),
998
- suggestion: getSession(sessionId).chat_suggestion ?? null,
999
- });
1000
- listener({ type: "context", context: savedContext(sessionId) });
1001
- sendCapabilities(sessionId, listener).catch((error) =>
1002
- console.error(`[chat ${sessionId}] capabilities not sent:`, error)
1003
- );
1004
- let set = registry.listeners.get(sessionId);
1005
- if (!set) registry.listeners.set(sessionId, (set = new Set()));
1006
- set.add(listener);
1007
- return () => {
1008
- set?.delete(listener);
1009
- releaseUnwatchedSoon();
1010
- };
1011
- }
1012
-
1013
- // The page before `before`, as the reader scrolls up.
1014
- export function chatHistory(sessionId: string, before: number): ItemPage {
1015
- const page = readPage(sessionId, before);
1016
- return { ...page, items: present(sessionId, page.items) };
1017
- }
1018
-
1019
- export const chatToolBody = toolBody;
953
+ export { chatHistory, chatToolBody, watchChat } from "./runner-watch";
1020
954
 
1021
955
  // After a restart: reconnect to every worker still running a conversation,
1022
956
  // and send the queues of conversations whose worker is gone.
@@ -0,0 +1,45 @@
1
+ import type { ToolItem } from "./group";
2
+
3
+ const EDGE = 4096;
4
+
5
+ // A short label for how a command ended, read from its output: the last HTTP
6
+ // status line (a redirect chain reports where it landed) or an exit code.
7
+ export function toolCode(output?: string): string | undefined {
8
+ if (!output) return undefined;
9
+ const text =
10
+ output.length > EDGE * 2
11
+ ? `${output.slice(0, EDGE)}\n${output.slice(-EDGE)}`
12
+ : output;
13
+ const http = [...text.matchAll(/^HTTP\/[\d.]+ (\d{3})/gm)].pop();
14
+ if (http) return `HTTP ${http[1]}`;
15
+ const exit = [
16
+ ...text.matchAll(
17
+ /(?:^Exit code |Command exited with code |exit (?:code|status)[:= ]+)(\d+)/gim
18
+ ),
19
+ ].pop();
20
+ return exit ? `exit ${exit[1]}` : undefined;
21
+ }
22
+
23
+ export const toolOutcome = (tool: ToolItem): string | undefined =>
24
+ tool.outcome ?? toolCode(tool.output);
25
+
26
+ export interface StepCounts {
27
+ total: number;
28
+ ok: number;
29
+ failed: number;
30
+ // Never finished: the turn stopped first.
31
+ stopped: number;
32
+ running: number;
33
+ }
34
+
35
+ export function stepCounts(tools: ToolItem[]): StepCounts {
36
+ const n = (s: ToolItem["status"]) =>
37
+ tools.filter((t) => t.status === s).length;
38
+ return {
39
+ total: tools.length,
40
+ ok: n("done"),
41
+ failed: n("error"),
42
+ stopped: n("stopped"),
43
+ running: n("running"),
44
+ };
45
+ }
package/lib/chat/tools.ts CHANGED
@@ -81,3 +81,17 @@ export function clipOutput(text: string): string {
81
81
  ? `${text.slice(0, MAX_TOOL_OUTPUT)}\n… (${text.length - MAX_TOOL_OUTPUT} more characters)`
82
82
  : text;
83
83
  }
84
+
85
+ export const LIVE_OUTPUT_LINES = 200;
86
+
87
+ // A running step's output so far: the last lines, never more than a
88
+ // finished step keeps. The end of a long build is what the reader wants.
89
+ export function tailOutput(text: string): string {
90
+ const lines = text.split("\n");
91
+ let tail =
92
+ lines.length > LIVE_OUTPUT_LINES
93
+ ? lines.slice(-LIVE_OUTPUT_LINES).join("\n")
94
+ : text;
95
+ if (tail.length > MAX_TOOL_OUTPUT) tail = tail.slice(-MAX_TOOL_OUTPUT);
96
+ return tail === text ? tail : `…\n${tail}`;
97
+ }