@frebreco/canvas 0.6.0-next.2 → 0.6.0-next.4

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frebreco/canvas",
3
- "version": "0.6.0-next.2",
3
+ "version": "0.6.0-next.4",
4
4
  "description": "A multiplayer canvas for coding agents that run on your machine.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -45,6 +45,8 @@ import type {
45
45
  SessionMeta,
46
46
  SessionSnapshot,
47
47
  } from "../shared/protocol";
48
+ import { PLAN_EVENT } from "../shared/protocol";
49
+ import { todoPlan } from "./todo-plan";
48
50
  import { fromAcp, pendingChanges, settingsOf } from "./agent-config";
49
51
  import { SKILLS_DIR } from "./skills";
50
52
  import { trimEvent } from "./trim-event";
@@ -187,12 +189,18 @@ export class AgentManager {
187
189
  const session = this.sessions.get(sessionId);
188
190
  if (!session) throw new Error(`no session ${sessionId}`);
189
191
  const live = await this.connect(session);
190
- const response = await live.acp.setSessionConfigOption({
191
- sessionId: live.sessionId,
192
- configId,
193
- ...(typeof value === "boolean" ? { type: "boolean" as const, value } : { value }),
194
- });
195
- this.setOptions(session, response.configOptions);
192
+ try {
193
+ const response = await live.acp.setSessionConfigOption({
194
+ sessionId: live.sessionId,
195
+ configId,
196
+ ...(typeof value === "boolean" ? { type: "boolean" as const, value } : { value }),
197
+ });
198
+ // Sent even unchanged: it is the answer the browser waits for.
199
+ this.setOptions(session, response.configOptions, true);
200
+ } catch (cause) {
201
+ if (session.options) this.options.onOptions(session.meta.id, session.options);
202
+ throw cause;
203
+ }
196
204
  this.touch(session);
197
205
  }
198
206
 
@@ -264,6 +272,7 @@ export class AgentManager {
264
272
  labels: {
265
273
  sessionIdEvent: `${session.meta.agent}.session-id`,
266
274
  contentEvent: `${session.meta.agent}.message-content`,
275
+ planEvent: PLAN_EVENT,
267
276
  },
268
277
  });
269
278
  for await (const chunk of chunks) {
@@ -280,6 +289,7 @@ export class AgentManager {
280
289
  this.append(session, {
281
290
  kind: "turn-end",
282
291
  turnId,
292
+ at: Date.now(),
283
293
  ...(error !== undefined && !cancelled && { error }),
284
294
  ...(cancelled && { cancelled: true }),
285
295
  });
@@ -424,7 +434,19 @@ export class AgentManager {
424
434
  private onUpdate(session: Session, update: SessionUpdate): void {
425
435
  if (update.sessionUpdate === "config_option_update")
426
436
  return this.setOptions(session, update.configOptions);
437
+ if (update.sessionUpdate === "usage_update") {
438
+ const { used, size, cost } = update;
439
+ const usage = {
440
+ used,
441
+ size,
442
+ ...(cost && { cost: { amount: cost.amount, currency: cost.currency } }),
443
+ };
444
+ session.meta = { ...session.meta, usage };
445
+ return this.options.onMeta(session.meta);
446
+ }
427
447
  session.turn?.queue.push({ kind: "update", update: update as AcpSessionUpdate });
448
+ const plan = todoPlan(update);
449
+ if (plan) session.turn?.queue.push({ kind: "update", update: plan as AcpSessionUpdate });
428
450
  }
429
451
 
430
452
  /** Park the agent's ask until the host answers it in the browser. */
@@ -457,9 +479,13 @@ export class AgentManager {
457
479
  };
458
480
  }
459
481
 
460
- private setOptions(session: Session, raw: ReadonlyArray<SessionConfigOption>): void {
482
+ private setOptions(
483
+ session: Session,
484
+ raw: ReadonlyArray<SessionConfigOption>,
485
+ always = false,
486
+ ): void {
461
487
  const options = fromAcp(raw);
462
- if (JSON.stringify(options) !== JSON.stringify(session.options)) {
488
+ if (always || JSON.stringify(options) !== JSON.stringify(session.options)) {
463
489
  session.options = options;
464
490
  this.options.onOptions(session.meta.id, options);
465
491
  }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Agents that keep a todo list without sending ACP `plan` updates (opencode's
3
+ * `todowrite`) still have a plan: the list, in the tool call's input. This
4
+ * reads it as the plan update the agent would have sent, so a thread knows
5
+ * one kind of plan only.
6
+ */
7
+
8
+ import type { PlanEntry, SessionUpdate } from "@agentclientprotocol/sdk";
9
+
10
+ const STATUSES = new Set(["pending", "in_progress", "completed"]);
11
+
12
+ export function todoPlan(update: SessionUpdate): SessionUpdate | null {
13
+ if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update")
14
+ return null;
15
+ const input = update.rawInput as { todos?: unknown } | null | undefined;
16
+ if (!Array.isArray(input?.todos)) return null;
17
+ const entries = input.todos.flatMap((todo: unknown): PlanEntry[] => {
18
+ const { content, status, priority } = (todo ?? {}) as Record<string, unknown>;
19
+ if (typeof content !== "string" || typeof status !== "string" || !STATUSES.has(status))
20
+ return [];
21
+ return [
22
+ {
23
+ content,
24
+ status: status as PlanEntry["status"],
25
+ priority: priority === "high" || priority === "low" ? priority : "medium",
26
+ },
27
+ ];
28
+ });
29
+ return entries.length ? { sessionUpdate: "plan", entries } : null;
30
+ }
@@ -37,6 +37,17 @@ export interface SessionMeta {
37
37
  * shown while the agent is not connected, and re-applied when it reconnects.
38
38
  */
39
39
  readonly settings?: ReadonlyArray<AgentSetting>;
40
+ /** How full the agent's context is, as it last said (ACP `usage_update`); not every agent does. */
41
+ readonly usage?: ContextUsage;
42
+ }
43
+
44
+ export interface ContextUsage {
45
+ /** Tokens in the context now. */
46
+ readonly used: number;
47
+ /** The context window, in tokens. */
48
+ readonly size: number;
49
+ /** The session's cost so far, if the agent knows it. */
50
+ readonly cost?: { readonly amount: number; readonly currency: string };
40
51
  }
41
52
 
42
53
  export type AgentConfigValue = string | boolean;
@@ -115,10 +126,24 @@ export type AgentEvent =
115
126
  | {
116
127
  readonly kind: "turn-end";
117
128
  readonly turnId: string;
129
+ /** When it ended (absent in logs from before it was kept). */
130
+ readonly at?: number;
118
131
  readonly error?: string;
119
132
  readonly cancelled?: boolean;
120
133
  };
121
134
 
135
+ /**
136
+ * The name of the AG-UI `CUSTOM` chunk that carries the agent's plan (an ACP
137
+ * `plan` update): `{ entries: PlanEntry[] }`, the whole plan each time.
138
+ */
139
+ export const PLAN_EVENT = "plan";
140
+
141
+ export interface PlanEntry {
142
+ readonly content: string;
143
+ readonly status: "pending" | "in_progress" | "completed";
144
+ readonly priority?: "high" | "medium" | "low";
145
+ }
146
+
122
147
  export interface SessionSnapshot {
123
148
  readonly meta: SessionMeta;
124
149
  readonly events: ReadonlyArray<AgentEvent>;