pi-open-deskos 0.2.1 → 0.4.0

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.
@@ -2,21 +2,25 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
2
2
  import { Container, Input, Key, SelectList, Text, matchesKey, truncateToWidth, type Focusable, type SelectItem } from "@earendil-works/pi-tui";
3
3
  import { Type } from "typebox";
4
4
  import { DeskConsoleClient, type ConsoleAttachment } from "./console-client.ts";
5
- import type { DeskLinkSnapshot } from "./reporter.ts";
6
- import type { DeskLinkConfig, HistoryPage, HostedPiSession, PositionedSessionEvent } from "./types.ts";
5
+ import type { OdkLinkSnapshot } from "./reporter.ts";
6
+ import type { OdkLinkConfig, HistoryPage, HostedPiSession, PositionedSessionEvent } from "./types.ts";
7
7
  import type { ControlTransport } from "./control-transport.ts";
8
8
 
9
- const CONSOLE_STATE_ENTRY = "open-deskos-console-state";
10
- const CONSOLE_CONTEXT_MESSAGE = "open-deskos-hosted-pi";
9
+ const CONSOLE_STATE_ENTRY = "odk-console-state";
10
+ const CONSOLE_CONTEXT_MESSAGE = "odk-hosted-pi";
11
11
  const CONTEXT_EVENTS = 24;
12
12
  const CONTEXT_BYTES = 24 * 1024;
13
13
  const TOOL_OUTPUT_BYTES = 48 * 1024;
14
14
 
15
15
  export interface ConsoleExtensionOptions {
16
16
  pi: ExtensionAPI;
17
- config: DeskLinkConfig | null;
18
- createControlTransport: (config: DeskLinkConfig) => ControlTransport;
19
- reporterSnapshot: () => DeskLinkSnapshot | null;
17
+ config: OdkLinkConfig | null;
18
+ /** Every desk this machine reports to, in configuration order. */
19
+ desks?: OdkLinkConfig[];
20
+ /** Entries the configuration stated and this machine refuses to use, with the reason. */
21
+ refusals?: string[];
22
+ createControlTransport: (config: OdkLinkConfig) => ControlTransport;
23
+ reporterSnapshot: () => OdkLinkSnapshot | null;
20
24
  configHint: string;
21
25
  }
22
26
 
@@ -105,17 +109,20 @@ export function formatHistory(page: HistoryPage): string {
105
109
 
106
110
  export function registerConsoleExtension(options: ConsoleExtensionOptions): void {
107
111
  const { pi, config, createControlTransport, reporterSnapshot, configHint } = options;
112
+ const desks = options.desks ?? (config === null ? [] : [config]);
113
+ const refusals = options.refusals ?? [];
108
114
  let currentConsoleSessionId = "";
109
115
  let state: PersistedConsoleState = { lastApplied: 0 };
110
116
  let client: DeskConsoleClient | null = null;
111
117
  let attachment: ConsoleAttachment | null = null;
118
+ let generation = 0;
112
119
  let tail: PositionedSessionEvent[] = [];
113
120
  const surfaceListeners = new Set<(entry: PositionedSessionEvent) => void>();
114
121
 
115
122
  const controlConfigured = (): boolean => config !== null && Boolean(config.controlToken);
116
123
  const unavailable = (): string => config !== null
117
- ? "set ODK_DESK_LINK_CONTROL_TOKEN to enable the Console"
118
- : `${configHint}; set ODK_DESK_LINK_CONTROL_TOKEN to enable the Console`;
124
+ ? "set ODK_LINK_CONTROL_TOKEN to enable the Console"
125
+ : `${configHint}; set ODK_LINK_CONTROL_TOKEN to enable the Console`;
119
126
  const requireClient = (): DeskConsoleClient => {
120
127
  if (!controlConfigured() || !client) throw new Error(unavailable());
121
128
  return client;
@@ -127,7 +134,7 @@ export function registerConsoleExtension(options: ConsoleExtensionOptions): void
127
134
  state.sessionId ? `Hosted Pi ${state.sessionId}` : "Hosted Pi",
128
135
  ...tail.map(eventLine),
129
136
  ...(terminal === undefined ? [] : [`terminal ${JSON.stringify(terminal)}`]),
130
- "Complete content remains available through desk_history.",
137
+ "Complete content remains available through odk_history.",
131
138
  ].join("\n");
132
139
  // Persist the bounded observation in context without autonomously starting
133
140
  // another model turn. The next user turn sees it, and a terminal result is
@@ -140,17 +147,26 @@ export function registerConsoleExtension(options: ConsoleExtensionOptions): void
140
147
  for (const listener of surfaceListeners) listener(entry);
141
148
  };
142
149
  const attach = async (sessionId: string, position = state.sessionId === sessionId ? state.lastApplied : undefined): Promise<ConsoleAttachment> => {
150
+ const owner = requireClient();
151
+ const attachingGeneration = ++generation;
152
+ injectContext();
143
153
  attachment?.close();
144
- const next = await requireClient().attach({
154
+ attachment = null;
155
+ const next = await owner.attach({
145
156
  sessionId,
146
157
  ...(position === undefined ? {} : { position }),
147
- onEvent: applyEvent,
158
+ onEvent(entry) { if (attachingGeneration === generation) applyEvent(entry); },
148
159
  onPosition(lastApplied) {
160
+ if (attachingGeneration !== generation) return;
149
161
  state = { sessionId, ...(state.attachmentId ? { attachmentId: state.attachmentId } : {}), lastApplied };
150
162
  persist();
151
163
  },
152
- onTerminal(record) { injectContext(record); },
164
+ onTerminal(record) { if (attachingGeneration === generation) injectContext(record); },
153
165
  });
166
+ if (attachingGeneration !== generation || owner !== client) {
167
+ next.close();
168
+ throw new Error("Console session changed during attach");
169
+ }
154
170
  attachment = next;
155
171
  state = { sessionId, attachmentId: next.attachmentId, lastApplied: next.lastApplied() };
156
172
  persist();
@@ -159,9 +175,12 @@ export function registerConsoleExtension(options: ConsoleExtensionOptions): void
159
175
 
160
176
  if (controlConfigured()) {
161
177
  pi.registerMessageRenderer(CONSOLE_CONTEXT_MESSAGE, (message, _renderOptions, theme) =>
162
- new Text(`${theme.fg("customMessageLabel", "[open-deskos] ")}${theme.fg("customMessageText", firstLine(typeof message.content === "string" ? message.content : "Hosted Pi update", 160))}`, 0, 0));
178
+ new Text(`${theme.fg("customMessageLabel", "[odk] ")}${theme.fg("customMessageText", firstLine(typeof message.content === "string" ? message.content : "Hosted Pi update", 160))}`, 0, 0));
163
179
 
164
180
  pi.on("session_start", (_event, ctx) => {
181
+ generation += 1;
182
+ attachment?.close();
183
+ client?.closeAttachment();
165
184
  currentConsoleSessionId = ctx.sessionManager.getSessionId();
166
185
  state = restoreState(ctx);
167
186
  tail = [];
@@ -172,6 +191,7 @@ export function registerConsoleExtension(options: ConsoleExtensionOptions): void
172
191
  });
173
192
  pi.on("agent_settled", () => injectContext());
174
193
  pi.on("session_shutdown", () => {
194
+ generation += 1;
175
195
  attachment?.close();
176
196
  attachment = null;
177
197
  client?.closeAttachment();
@@ -179,7 +199,7 @@ export function registerConsoleExtension(options: ConsoleExtensionOptions): void
179
199
  });
180
200
 
181
201
  pi.registerTool({
182
- name: "desk_list",
202
+ name: "odk_list",
183
203
  label: "Desk List",
184
204
  description: "List the Hosted Pi sessions on Open DeskOS, including state, project, goal, and age.",
185
205
  parameters: Type.Object({}),
@@ -190,7 +210,7 @@ export function registerConsoleExtension(options: ConsoleExtensionOptions): void
190
210
  },
191
211
  });
192
212
  pi.registerTool({
193
- name: "desk_start",
213
+ name: "odk_start",
194
214
  label: "Desk Start",
195
215
  description: "Start a Hosted Pi on Open DeskOS with a project and prompt. Returns its durable identity immediately.",
196
216
  parameters: Type.Object({ project: Type.String(), prompt: Type.String(), session_id: Type.Optional(Type.String()), mutation_id: Type.Optional(Type.String()) }),
@@ -205,7 +225,7 @@ export function registerConsoleExtension(options: ConsoleExtensionOptions): void
205
225
  },
206
226
  });
207
227
  pi.registerTool({
208
- name: "desk_attach",
228
+ name: "odk_attach",
209
229
  label: "Desk Attach",
210
230
  description: "Attach this Pi session to one Hosted Pi and follow it from the last applied log position.",
211
231
  parameters: Type.Object({ session_id: Type.String(), position: Type.Optional(Type.Integer({ minimum: 0 })) }),
@@ -215,18 +235,20 @@ export function registerConsoleExtension(options: ConsoleExtensionOptions): void
215
235
  },
216
236
  });
217
237
  pi.registerTool({
218
- name: "desk_prompt",
238
+ name: "odk_prompt",
219
239
  label: "Desk Prompt",
220
240
  description: "Send a further instruction to the attached Hosted Pi; while running this steers the current turn.",
221
241
  parameters: Type.Object({ prompt: Type.String(), mutation_id: Type.Optional(Type.String()) }),
222
242
  async execute(_id, params) {
223
243
  if (!attachment) throw new Error("attach to a Hosted Pi first");
224
- await attachment.prompt(params.prompt, params.mutation_id ? { mutationId: params.mutation_id } : {});
225
- return textResult(`Prompt delivered to ${attachment.sessionId}.`, state);
244
+ const joined = attachment;
245
+ const requestState = { ...state };
246
+ await joined.prompt(params.prompt, params.mutation_id ? { mutationId: params.mutation_id } : {});
247
+ return textResult(`Prompt delivered to ${joined.sessionId}.`, requestState);
226
248
  },
227
249
  });
228
250
  pi.registerTool({
229
- name: "desk_cancel",
251
+ name: "odk_cancel",
230
252
  label: "Desk Cancel",
231
253
  description: "Cancel the attached Hosted Pi's running turn without ending the session.",
232
254
  parameters: Type.Object({
@@ -236,31 +258,37 @@ export function registerConsoleExtension(options: ConsoleExtensionOptions): void
236
258
  }),
237
259
  async execute(_id, params) {
238
260
  if (!attachment) throw new Error("attach to a Hosted Pi first");
239
- await attachment.cancel({
261
+ const joined = attachment;
262
+ const requestState = { ...state };
263
+ await joined.cancel({
240
264
  ...(params.mutation_id ? { mutationId: params.mutation_id } : {}),
241
265
  ...(params.turn_id ? { turnId: params.turn_id } : {}),
242
266
  ...(params.precondition ? { precondition: params.precondition } : {}),
243
267
  });
244
- return textResult(`Cancelled the running turn on ${attachment.sessionId}.`, state);
268
+ return textResult(`Cancelled the running turn on ${joined.sessionId}.`, requestState);
245
269
  },
246
270
  });
247
271
  pi.registerTool({
248
- name: "desk_end",
272
+ name: "odk_end",
249
273
  label: "Desk End",
250
274
  description: "End the attached Hosted Pi and release its host slot.",
251
275
  parameters: Type.Object({ mutation_id: Type.Optional(Type.String()) }),
252
276
  async execute(_id, params) {
253
277
  if (!attachment) throw new Error("attach to a Hosted Pi first");
254
- const ended = attachment.sessionId;
255
- await attachment.end(params.mutation_id ? { mutationId: params.mutation_id } : {});
256
- injectContext({ type: "ended", sessionId: ended });
257
- attachment.close();
258
- attachment = null;
259
- return textResult(`Ended Hosted Pi ${ended}.`, state);
278
+ const joined = attachment;
279
+ const requestGeneration = generation;
280
+ const requestState = { ...state };
281
+ await joined.end(params.mutation_id ? { mutationId: params.mutation_id } : {});
282
+ if (generation === requestGeneration && attachment === joined) {
283
+ injectContext({ type: "ended", sessionId: joined.sessionId });
284
+ joined.close();
285
+ attachment = null;
286
+ }
287
+ return textResult(`Ended Hosted Pi ${joined.sessionId}.`, requestState);
260
288
  },
261
289
  });
262
290
  pi.registerTool({
263
- name: "desk_history",
291
+ name: "odk_history",
264
292
  label: "Desk History",
265
293
  description: "Read a bounded page of complete Hosted Pi history from a session-log position.",
266
294
  parameters: Type.Object({ session_id: Type.String(), position: Type.Optional(Type.Integer({ minimum: 0 })) }),
@@ -273,16 +301,20 @@ export function registerConsoleExtension(options: ConsoleExtensionOptions): void
273
301
 
274
302
  const showStatus = (ctx: ExtensionContext): void => {
275
303
  const snapshot = reporterSnapshot();
276
- const endpoint = config !== null ? `${config.host}:${config.port}` : "not configured";
304
+ const endpoint = desks.length > 0 ? desks.map((desk) => `${desk.host}:${desk.port}`).join(", ") : "not configured";
277
305
  const report = snapshot ? `${snapshot.link} · ${snapshot.sessions} session(s) · ${snapshot.events} event(s)` : "reporting inactive";
278
306
  const control = controlConfigured() ? `Console ready${state.sessionId ? ` · attached ${state.sessionId} @ ${state.lastApplied}` : ""}` : unavailable();
279
- ctx.ui.notify(`[open-deskos] ${snapshot?.link ?? "offline"} · ${config?.machine ?? "machine"} → ${endpoint} · ${report.replace(/^(offline|connecting|connected) · /, "")} · ${control}${snapshot?.lastError ? ` · ${snapshot.lastError}` : ""}`, snapshot?.link === "connected" ? "info" : "warning");
307
+ // A machine that reports to several desks states each one, so a desk that is down
308
+ // cannot hide behind a set that is otherwise healthy, and a refused entry is said out loud.
309
+ const perDesk = snapshot?.desks && snapshot.desks.length > 1 ? ` · ${snapshot.desks.map((desk) => `${desk.endpoint} ${desk.link}`).join(", ")}` : "";
310
+ const refused = refusals.length > 0 ? ` · ${refusals[0]}` : "";
311
+ ctx.ui.notify(`[odk] ${snapshot?.link ?? "offline"} · ${desks[0]?.machine ?? config?.machine ?? "machine"} → ${endpoint} · ${report.replace(/^(offline|connecting|connected) · /, "")}${perDesk}${refused} · ${control}${snapshot?.lastError ? ` · ${snapshot.lastError}` : ""}`, snapshot?.link === "connected" ? "info" : "warning");
280
312
  };
281
313
 
282
314
  const chooseSession = async (ctx: ExtensionContext): Promise<string | undefined> => {
283
315
  const sessions = (await requireClient().list()).sessions;
284
316
  if (sessions.length === 0) {
285
- ctx.ui.notify("[open-deskos] no Hosted Pi sessions", "info");
317
+ ctx.ui.notify("[odk] no Hosted Pi sessions", "info");
286
318
  return undefined;
287
319
  }
288
320
  const labels = sessions.map((session) => `${session.sessionId} · ${sessionLabel(session)}`);
@@ -292,23 +324,23 @@ export function registerConsoleExtension(options: ConsoleExtensionOptions): void
292
324
  };
293
325
 
294
326
  const launchInteractive = async (ctx: ExtensionContext): Promise<void> => {
295
- if (!ctx.hasUI) throw new Error("launch requires interactive UI or desk_start");
327
+ if (!ctx.hasUI) throw new Error("launch requires interactive UI or odk_start");
296
328
  const project = await ctx.ui.input("Hosted Pi project", ctx.cwd);
297
329
  if (!project) return;
298
330
  const prompt = await ctx.ui.editor("Hosted Pi prompt", "");
299
331
  if (!prompt?.trim()) return;
300
332
  const reply = await requireClient().launch({ project, prompt });
301
- ctx.ui.notify(`[open-deskos] started ${String(reply.sessionId ?? "Hosted Pi")}`, "info");
333
+ ctx.ui.notify(`[odk] started ${String(reply.sessionId ?? "Hosted Pi")}`, "info");
302
334
  };
303
335
 
304
336
  const openConsole = async (ctx: ExtensionContext): Promise<void> => {
305
337
  if (!controlConfigured()) {
306
- ctx.ui.notify(`[open-deskos] Console unavailable · ${unavailable()}`, "warning");
338
+ ctx.ui.notify(`[odk] Console unavailable · ${unavailable()}`, "warning");
307
339
  return;
308
340
  }
309
341
  if (ctx.mode !== "tui") {
310
342
  const sessions = await requireClient().list();
311
- ctx.ui.notify(`[open-deskos] ${sessions.sessions.length} Hosted Pi session(s)`, "info");
343
+ ctx.ui.notify(`[odk] ${sessions.sessions.length} Hosted Pi session(s)`, "info");
312
344
  return;
313
345
  }
314
346
  const sessions = (await requireClient().list()).sessions;
@@ -336,7 +368,7 @@ export function registerConsoleExtension(options: ConsoleExtensionOptions): void
336
368
  truncateToWidth(theme.fg("accent", theme.bold("Open DeskOS Console")), width),
337
369
  ...body,
338
370
  ...(attachment ? ["", truncateToWidth(theme.fg("success", `attached ${attachment.sessionId} @ ${attachment.lastApplied()}`), width), ...(historyLines.length ? [theme.fg("muted", "History"), ...historyLines] : events), ...promptInput.render(width)] : []),
339
- ...(error ? [truncateToWidth(theme.fg("error", error), width)] : []),
371
+ ...((error || attachment?.error()) ? [truncateToWidth(theme.fg("error", error || attachment?.error() || ""), width)] : []),
340
372
  truncateToWidth(theme.fg("dim", attachment ? "enter send · ctrl+h history · ctrl+x cancel · ctrl+e end · esc close" : "up/down select · enter attach · n launch · r refresh · esc close"), width),
341
373
  ];
342
374
  };
@@ -356,7 +388,16 @@ export function registerConsoleExtension(options: ConsoleExtensionOptions): void
356
388
  return;
357
389
  }
358
390
  if (attachment && matchesKey(data, Key.ctrl("x"))) { void attachment.cancel().catch((cause) => { error = String(cause); tui.requestRender(); }); return; }
359
- if (attachment && matchesKey(data, Key.ctrl("e"))) { void attachment.end().then(() => { attachment?.close(); attachment = null; }).catch((cause) => { error = String(cause); }).finally(() => tui.requestRender()); return; }
391
+ if (attachment && matchesKey(data, Key.ctrl("e"))) {
392
+ const joined = attachment;
393
+ const requestGeneration = generation;
394
+ void joined.end().then(() => {
395
+ if (generation !== requestGeneration || attachment !== joined) return;
396
+ joined.close();
397
+ attachment = null;
398
+ }).catch((cause) => { error = String(cause); }).finally(() => tui.requestRender());
399
+ return;
400
+ }
360
401
  if (matchesKey(data, Key.enter)) {
361
402
  const value = promptInput.getValue().trim();
362
403
  if (attachment && value) {
@@ -385,7 +426,7 @@ export function registerConsoleExtension(options: ConsoleExtensionOptions): void
385
426
  });
386
427
  };
387
428
 
388
- pi.registerCommand("open-deskos", {
429
+ pi.registerCommand("odk", {
389
430
  description: "Open DeskOS menu or use console/status/attach/launch/cancel/history fast paths",
390
431
  handler: async (raw, ctx) => {
391
432
  const [action, ...rest] = raw.trim().split(/\s+/).filter(Boolean);
@@ -402,7 +443,7 @@ export function registerConsoleExtension(options: ConsoleExtensionOptions): void
402
443
  const sessionId = rest[0] ?? state.sessionId ?? await chooseSession(ctx);
403
444
  if (!sessionId) return;
404
445
  const page = await requireClient().history({ sessionId, position: Number.parseInt(rest[1] ?? "0", 10) || 0 });
405
- ctx.ui.notify(`[open-deskos] ${formatHistory(page)}`, "info");
446
+ ctx.ui.notify(`[odk] ${formatHistory(page)}`, "info");
406
447
  return;
407
448
  }
408
449
  if (ctx.mode !== "tui") {
@@ -411,7 +452,7 @@ export function registerConsoleExtension(options: ConsoleExtensionOptions): void
411
452
  }
412
453
  const rows: SelectItem[] = [
413
454
  { value: "console", label: "Console", description: controlConfigured() ? "List, launch, attach, and drive Hosted Pi" : unavailable() },
414
- { value: "status", label: "Link status", description: config !== null ? `${config.machine} → ${config.host}:${config.port}` : configHint },
455
+ { value: "status", label: "Link status", description: desks.length > 0 ? `${desks[0]?.machine ?? "machine"} → ${desks.map((desk) => `${desk.host}:${desk.port}`).join(", ")}${refusals.length > 0 ? ` · ${refusals[0]}` : ""}` : `${configHint}${refusals.length > 0 ? ` · ${refusals[0]}` : ""}` },
415
456
  { value: "attach", label: "Attach", description: controlConfigured() ? "Attach to a Hosted Pi" : unavailable() },
416
457
  { value: "launch", label: "Launch", description: controlConfigured() ? "Start a Hosted Pi" : unavailable() },
417
458
  { value: "cancel", label: "Cancel", description: attachment ? `Cancel ${attachment.sessionId}` : "Attach first" },
@@ -1,8 +1,8 @@
1
1
  import { connect, type Socket } from "node:net";
2
2
  import { StringDecoder } from "node:string_decoder";
3
- import type { DeskLinkConfig } from "./types.ts";
3
+ import type { OdkLinkConfig } from "./types.ts";
4
4
 
5
- export const DESK_LINK_CONTROL_PROTOCOL = 2;
5
+ export const ODK_LINK_CONTROL_PROTOCOL = 2;
6
6
  export const MAX_CONTROL_RECORD_BYTES = 1024 * 1024;
7
7
 
8
8
  export interface ControlTransport {
@@ -52,7 +52,7 @@ export function createControlRecordReader(maxBytes = MAX_CONTROL_RECORD_BYTES):
52
52
  };
53
53
  }
54
54
 
55
- export function createControlTcpTransport(config: DeskLinkConfig): ControlTransport {
55
+ export function createControlTcpTransport(config: OdkLinkConfig): ControlTransport {
56
56
  const openHandlers: Array<() => void> = [];
57
57
  const closeHandlers: Array<(reason: string) => void> = [];
58
58
  const recordHandlers: Array<(record: unknown) => void> = [];
@@ -0,0 +1,69 @@
1
+ import type { OdkLinkSnapshot } from "./reporter.ts";
2
+ import type { OdkLinkConfig } from "./types.ts";
3
+
4
+ /** A desk's own snapshot plus the endpoint it belongs to, so an aggregate can name it. */
5
+ export interface DeskSnapshot extends OdkLinkSnapshot {
6
+ endpoint: string;
7
+ }
8
+
9
+ /**
10
+ * The desk the Console drives. Only a desk that issued a control credential can
11
+ * be driven, so that desk is chosen by default; an operator who names one gets
12
+ * that one, even when it has no credential, because quietly driving a different
13
+ * desk than the one asked for would be worse than saying a credential is needed.
14
+ */
15
+ export function selectConsoleDesk(configs: OdkLinkConfig[], preferred = ""): OdkLinkConfig | null {
16
+ const wanted = preferred.trim();
17
+ if (wanted.length > 0) {
18
+ const exact = configs.find((config) => `${config.host}:${config.port}` === wanted
19
+ || `[${config.host}]:${config.port}` === wanted);
20
+ if (exact) return exact;
21
+ const matchingHosts = configs.filter((config) => config.host === wanted);
22
+ return matchingHosts.length === 1 ? matchingHosts[0]! : null;
23
+ }
24
+ return configs.find((config) => Boolean(config.controlToken)) ?? null;
25
+ }
26
+
27
+ /**
28
+ * One state for a machine that reports to several desks.
29
+ *
30
+ * `connected` is claimed only when every desk is connected, and `offline` only
31
+ * when every desk is offline, because a set that is half up is neither. Counts
32
+ * come from the desks that are up, so a desk that is down cannot make this
33
+ * machine look like it is reporting nothing.
34
+ */
35
+ export function aggregateDeskSnapshots(snapshots: DeskSnapshot[]): OdkLinkSnapshot | null {
36
+ if (snapshots.length === 0) return null;
37
+ if (snapshots.length === 1) {
38
+ const only = snapshots[0];
39
+ if (only === undefined) return null;
40
+ const { endpoint: _endpoint, ...snapshot } = only;
41
+ return { ...snapshot, desks: [{ endpoint: only.endpoint, link: only.link, ...(only.lastError === undefined ? {} : { lastError: only.lastError }) }] };
42
+ }
43
+
44
+ const connected = snapshots.filter((snapshot) => snapshot.link === "connected");
45
+ const link: OdkLinkSnapshot["link"] = connected.length === snapshots.length
46
+ ? "connected"
47
+ : connected.length === 0 && snapshots.every((snapshot) => snapshot.link === "offline")
48
+ ? "offline"
49
+ : "connecting";
50
+
51
+ const reporting = connected.length > 0 ? connected : snapshots;
52
+ const total = (pick: (snapshot: DeskSnapshot) => number): number => reporting.reduce((sum, snapshot) => sum + pick(snapshot), 0);
53
+ const firstError = snapshots.find((snapshot) => snapshot.lastError !== undefined)?.lastError;
54
+
55
+ return {
56
+ link,
57
+ machine: snapshots[0]?.machine ?? "",
58
+ sessions: total((snapshot) => snapshot.sessions),
59
+ events: total((snapshot) => snapshot.events),
60
+ attempts: snapshots.reduce((most, snapshot) => Math.max(most, snapshot.attempts), 0),
61
+ omittedSessions: total((snapshot) => snapshot.omittedSessions),
62
+ ...(firstError === undefined ? {} : { lastError: firstError }),
63
+ desks: snapshots.map((snapshot) => ({
64
+ endpoint: snapshot.endpoint,
65
+ link: snapshot.link,
66
+ ...(snapshot.lastError === undefined ? {} : { lastError: snapshot.lastError }),
67
+ })),
68
+ };
69
+ }
package/src/events.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { classifyToolActivity } from "@fradser/pi-kit";
1
2
  import {
2
3
  MAX_EVENT_TEXT,
3
4
  MAX_EVENTS_PER_SESSION,
@@ -24,6 +25,37 @@ export function boundEventText(value: unknown, max = MAX_EVENT_TEXT): string {
24
25
  return "";
25
26
  }
26
27
 
28
+ /** How many lines a prompt or a content line may arrive with. */
29
+ export const MAX_EVENT_LINES = 4;
30
+
31
+ /**
32
+ * A prompt or a content line as the desk reads it: the lines the person wrote,
33
+ * up to a budget, then an ellipsis. Returning only the first non-empty line threw
34
+ * away everything pasted after it, which is the report the desk owner made: a card
35
+ * showed 提交。 for a message that carried much more. Blank lines separate rather
36
+ * than count, and each line is collapsed, so a wrapped paste does not arrive with
37
+ * a terminal's own line breaks in it.
38
+ */
39
+ export function boundEventLines(value: unknown, maxLines = MAX_EVENT_LINES, max = MAX_EVENT_TEXT): string {
40
+ if (typeof value !== "string") return "";
41
+ const budget = Math.max(1, maxLines);
42
+ const lines: string[] = [];
43
+ let truncated = false;
44
+ for (const raw of value.split(/\r?\n/)) {
45
+ const line = raw.replace(/\s+/g, " ").trim();
46
+ if (line.length === 0) continue;
47
+ if (lines.length >= budget) {
48
+ truncated = true;
49
+ break;
50
+ }
51
+ lines.push(line);
52
+ }
53
+ if (lines.length === 0) return "";
54
+ const joined = lines.join("\n");
55
+ if (joined.length > max) return `${joined.slice(0, Math.max(1, max - 1))}…`;
56
+ return truncated ? `${joined}\n…` : joined;
57
+ }
58
+
27
59
  /** The bound each kind's body is held to. */
28
60
  const BODY_BYTES: Record<SessionEvent["kind"], number> = {
29
61
  result: MAX_RESULT_BYTES,
@@ -33,6 +65,14 @@ const BODY_BYTES: Record<SessionEvent["kind"], number> = {
33
65
  tool: MAX_TOOL_BYTES,
34
66
  };
35
67
 
68
+ const STOP_REASONS = new Set(["stop", "toolUse", "length", "error", "aborted"]);
69
+
70
+ function assistantMetadata(entryId: unknown, stopReason: unknown): Pick<SessionEvent, "eventId" | "stopReason"> {
71
+ if (typeof entryId !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(entryId)) return {};
72
+ return { eventId: entryId, ...(typeof stopReason === "string" && STOP_REASONS.has(stopReason)
73
+ ? { stopReason: stopReason as SessionEvent["stopReason"] } : {}) };
74
+ }
75
+
36
76
  /**
37
77
  * Apply the body bound without flattening or prefixing its Markdown. Every kind
38
78
  * keeps its line structure so the desk can render it the way Pi does: a bash
@@ -53,7 +93,17 @@ export function boundSessionEvent(event: SessionEvent): SessionEvent | null {
53
93
  truncated = true;
54
94
  }
55
95
  const toolName = event.kind === "result" ? boundEventText(event.toolName) : "";
56
- return { kind: event.kind, text, ...(toolName ? { toolName } : {}), ...(truncated ? { truncated: true } : {}) };
96
+ // A call identity is an opaque short line, never a body, so it is bounded as one.
97
+ const toolCallId = event.kind === "result" || event.kind === "tool" ? boundEventText(event.toolCallId) : "";
98
+ return {
99
+ kind: event.kind,
100
+ text,
101
+ ...(event.kind === "assistant" ? assistantMetadata(event.eventId, event.stopReason) : {}),
102
+ ...(toolName ? { toolName } : {}),
103
+ ...(toolCallId ? { toolCallId } : {}),
104
+ ...(event.kind === "result" && event.isError === true ? { isError: true } : {}),
105
+ ...(truncated ? { truncated: true } : {}),
106
+ };
57
107
  }
58
108
 
59
109
  /** Keep a contiguous newest tail within both the event-count and UTF-8 budgets. */
@@ -62,7 +112,8 @@ export function retainEvents(events: readonly SessionEvent[]): SessionEvent[] {
62
112
  let bytes = 0;
63
113
  for (let index = events.length - 1; index >= 0 && retained.length < MAX_EVENTS_PER_SESSION; index -= 1) {
64
114
  const event = events[index]!;
65
- const size = Buffer.byteLength(event.text) + Buffer.byteLength(event.toolName ?? "");
115
+ const size = Buffer.byteLength(event.text) + Buffer.byteLength(event.toolName ?? "")
116
+ + Buffer.byteLength(event.eventId ?? "") + Buffer.byteLength(event.stopReason ?? "");
66
117
  if (bytes + size > MAX_SESSION_EVENT_BYTES) break;
67
118
  retained.push(event);
68
119
  bytes += size;
@@ -82,19 +133,18 @@ function readArguments(args: unknown): Record<string, unknown> {
82
133
  return typeof args === "object" && args !== null ? (args as Record<string, unknown>) : {};
83
134
  }
84
135
 
85
- /** The command, file, or query a tool call names, as Pi issued it. */
136
+ /** The command, path, pattern, or query a tool call names, as Pi issued it. */
86
137
  export function summarizeToolCall(name: string, args: unknown): string {
87
- const parsed = readArguments(args);
88
- const asText = (value: unknown): string => (typeof value === "string" ? value.trim() : "");
89
- const command = asText(parsed.command);
90
- if (command) return `bash: ${command}`;
91
- const file = asText(parsed.path);
92
- if (file) return `${name}: ${file}`;
93
- const query = asText(parsed.query);
94
- if (query) return `search: ${query}`;
95
- const subject = asText(parsed.subject);
96
- if (subject) return `${name}: ${subject}`;
97
- return name;
138
+ const activity = classifyToolActivity(readArguments(args));
139
+ if (!activity) return name;
140
+ switch (activity.kind) {
141
+ case "command": return `bash: ${activity.value}`;
142
+ case "search": return `search: ${activity.value}`;
143
+ // A delivery target names a recipient rather than work done, so the row
144
+ // falls back to the tool name.
145
+ case "target": return name;
146
+ default: return `${name}: ${activity.value}`;
147
+ }
98
148
  }
99
149
 
100
150
  function isText(part: unknown): part is TextPart {
@@ -112,6 +162,9 @@ function isToolCall(part: unknown): part is ToolCallPart {
112
162
  interface MessageView {
113
163
  role?: string;
114
164
  toolName?: string;
165
+ toolCallId?: string;
166
+ isError?: boolean;
167
+ stopReason?: unknown;
115
168
  content?: unknown;
116
169
  }
117
170
 
@@ -135,13 +188,13 @@ export function promptFromMessage(message: unknown): string {
135
188
  * The Session Events one finalized message contributes. A tool result joins
136
189
  * all text parts into one bounded Markdown body; other events stay summaries.
137
190
  */
138
- export function eventsFromMessage(message: unknown): SessionEvent[] {
191
+ export function eventsFromMessage(message: unknown, persistedEntryId?: unknown): SessionEvent[] {
139
192
  const view = asMessage(message);
140
193
  if (!view) return [];
141
194
  // A user message may carry plain text rather than parts.
142
195
  if (typeof view.content === "string") {
143
- const text = boundEventText(view.content);
144
- return text.length > 0 && view.role === "user" ? [{ kind: "user", text }] : [];
196
+ const event = view.role === "user" ? boundSessionEvent({ kind: "user", text: view.content }) : null;
197
+ return event ? [event] : [];
145
198
  }
146
199
  const parts = Array.isArray(view.content) ? view.content : [];
147
200
  if (view.role === "toolResult") {
@@ -149,6 +202,10 @@ export function eventsFromMessage(message: unknown): SessionEvent[] {
149
202
  kind: "result",
150
203
  text: parts.filter(isText).map((part) => part.text).join("\n\n"),
151
204
  ...(typeof view.toolName === "string" ? { toolName: view.toolName } : {}),
205
+ // The call this result answers, and whether Pi recorded it as an error:
206
+ // a desk reads the outcome from this record instead of guessing one.
207
+ ...(typeof view.toolCallId === "string" ? { toolCallId: view.toolCallId } : {}),
208
+ ...(view.isError === true ? { isError: true } : {}),
152
209
  });
153
210
  return event ? [event] : [];
154
211
  }
@@ -167,7 +224,7 @@ export function eventsFromMessage(message: unknown): SessionEvent[] {
167
224
  // content. The one-line activity summary is made separately, in the
168
225
  // reporter, and never replaces this body.
169
226
  const text = summarizeToolCall(part.name, part.arguments).trim();
170
- if (text) events.push({ kind: "tool", text });
227
+ if (text) events.push({ kind: "tool", text, ...(typeof part.id === "string" && part.id ? { toolCallId: part.id } : {}) });
171
228
  continue;
172
229
  }
173
230
  // The reply body is added once, after its thinking and tool calls.
@@ -175,7 +232,8 @@ export function eventsFromMessage(message: unknown): SessionEvent[] {
175
232
  if (view.role === "assistant") {
176
233
  // One reply may stream as several text parts; Pi reads them as one body.
177
234
  const said = parts.filter((part): part is TextPart => isText(part) && part.text.trim().length > 0);
178
- if (said.length > 0) events.push({ kind: "assistant", text: said.map((part) => part.text).join("\n\n") });
235
+ if (said.length > 0) events.push({ kind: "assistant", text: said.map((part) => part.text).join("\n\n"),
236
+ ...assistantMetadata(persistedEntryId, view.stopReason) });
179
237
  }
180
238
  return events;
181
239
  }