pi-open-deskos 0.3.0 → 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.
- package/README.md +86 -17
- package/README.zh-CN.md +74 -17
- package/fixtures/control-v2.json +2 -2
- package/package.json +7 -6
- package/session-control +38 -0
- package/src/config.ts +135 -15
- package/src/console-client.ts +47 -21
- package/src/console-extension.ts +84 -43
- package/src/control-transport.ts +3 -3
- package/src/desk-set.ts +69 -0
- package/src/events.ts +60 -17
- package/src/index.ts +118 -30
- package/src/processes.ts +117 -13
- package/src/reporter.ts +117 -39
- package/src/session-host-client.mjs +274 -0
- package/src/session-host.ts +632 -0
- package/src/transport.ts +34 -11
- package/src/types.ts +12 -9
package/src/console-extension.ts
CHANGED
|
@@ -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 {
|
|
6
|
-
import type {
|
|
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 = "
|
|
10
|
-
const CONSOLE_CONTEXT_MESSAGE = "
|
|
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:
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
118
|
-
: `${configHint}; set
|
|
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
|
|
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
|
-
|
|
154
|
+
attachment = null;
|
|
155
|
+
const next = await owner.attach({
|
|
145
156
|
sessionId,
|
|
146
157
|
...(position === undefined ? {} : { position }),
|
|
147
|
-
onEvent
|
|
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", "[
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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
|
-
|
|
225
|
-
|
|
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: "
|
|
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
|
-
|
|
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 ${
|
|
268
|
+
return textResult(`Cancelled the running turn on ${joined.sessionId}.`, requestState);
|
|
245
269
|
},
|
|
246
270
|
});
|
|
247
271
|
pi.registerTool({
|
|
248
|
-
name: "
|
|
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
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
attachment
|
|
259
|
-
|
|
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: "
|
|
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 =
|
|
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
|
-
|
|
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("[
|
|
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
|
|
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(`[
|
|
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(`[
|
|
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(`[
|
|
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"))) {
|
|
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("
|
|
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(`[
|
|
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:
|
|
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" },
|
package/src/control-transport.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { connect, type Socket } from "node:net";
|
|
2
2
|
import { StringDecoder } from "node:string_decoder";
|
|
3
|
-
import type {
|
|
3
|
+
import type { OdkLinkConfig } from "./types.ts";
|
|
4
4
|
|
|
5
|
-
export const
|
|
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:
|
|
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> = [];
|
package/src/desk-set.ts
ADDED
|
@@ -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
|
|
@@ -58,6 +98,7 @@ export function boundSessionEvent(event: SessionEvent): SessionEvent | null {
|
|
|
58
98
|
return {
|
|
59
99
|
kind: event.kind,
|
|
60
100
|
text,
|
|
101
|
+
...(event.kind === "assistant" ? assistantMetadata(event.eventId, event.stopReason) : {}),
|
|
61
102
|
...(toolName ? { toolName } : {}),
|
|
62
103
|
...(toolCallId ? { toolCallId } : {}),
|
|
63
104
|
...(event.kind === "result" && event.isError === true ? { isError: true } : {}),
|
|
@@ -71,7 +112,8 @@ export function retainEvents(events: readonly SessionEvent[]): SessionEvent[] {
|
|
|
71
112
|
let bytes = 0;
|
|
72
113
|
for (let index = events.length - 1; index >= 0 && retained.length < MAX_EVENTS_PER_SESSION; index -= 1) {
|
|
73
114
|
const event = events[index]!;
|
|
74
|
-
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 ?? "");
|
|
75
117
|
if (bytes + size > MAX_SESSION_EVENT_BYTES) break;
|
|
76
118
|
retained.push(event);
|
|
77
119
|
bytes += size;
|
|
@@ -91,19 +133,18 @@ function readArguments(args: unknown): Record<string, unknown> {
|
|
|
91
133
|
return typeof args === "object" && args !== null ? (args as Record<string, unknown>) : {};
|
|
92
134
|
}
|
|
93
135
|
|
|
94
|
-
/** The command,
|
|
136
|
+
/** The command, path, pattern, or query a tool call names, as Pi issued it. */
|
|
95
137
|
export function summarizeToolCall(name: string, args: unknown): string {
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
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
|
+
}
|
|
107
148
|
}
|
|
108
149
|
|
|
109
150
|
function isText(part: unknown): part is TextPart {
|
|
@@ -123,6 +164,7 @@ interface MessageView {
|
|
|
123
164
|
toolName?: string;
|
|
124
165
|
toolCallId?: string;
|
|
125
166
|
isError?: boolean;
|
|
167
|
+
stopReason?: unknown;
|
|
126
168
|
content?: unknown;
|
|
127
169
|
}
|
|
128
170
|
|
|
@@ -146,13 +188,13 @@ export function promptFromMessage(message: unknown): string {
|
|
|
146
188
|
* The Session Events one finalized message contributes. A tool result joins
|
|
147
189
|
* all text parts into one bounded Markdown body; other events stay summaries.
|
|
148
190
|
*/
|
|
149
|
-
export function eventsFromMessage(message: unknown): SessionEvent[] {
|
|
191
|
+
export function eventsFromMessage(message: unknown, persistedEntryId?: unknown): SessionEvent[] {
|
|
150
192
|
const view = asMessage(message);
|
|
151
193
|
if (!view) return [];
|
|
152
194
|
// A user message may carry plain text rather than parts.
|
|
153
195
|
if (typeof view.content === "string") {
|
|
154
|
-
const
|
|
155
|
-
return
|
|
196
|
+
const event = view.role === "user" ? boundSessionEvent({ kind: "user", text: view.content }) : null;
|
|
197
|
+
return event ? [event] : [];
|
|
156
198
|
}
|
|
157
199
|
const parts = Array.isArray(view.content) ? view.content : [];
|
|
158
200
|
if (view.role === "toolResult") {
|
|
@@ -190,7 +232,8 @@ export function eventsFromMessage(message: unknown): SessionEvent[] {
|
|
|
190
232
|
if (view.role === "assistant") {
|
|
191
233
|
// One reply may stream as several text parts; Pi reads them as one body.
|
|
192
234
|
const said = parts.filter((part): part is TextPart => isText(part) && part.text.trim().length > 0);
|
|
193
|
-
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) });
|
|
194
237
|
}
|
|
195
238
|
return events;
|
|
196
239
|
}
|