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.
package/src/index.ts CHANGED
@@ -1,79 +1,222 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { readDeskLinkConfig } from "./config.ts";
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { open } from "node:fs/promises";
3
+ import { readOdkLinkConfigs } from "./config.ts";
3
4
  import { registerConsoleExtension } from "./console-extension.ts";
4
5
  import { createControlTcpTransport } from "./control-transport.ts";
6
+ import { aggregateDeskSnapshots, selectConsoleDesk } from "./desk-set.ts";
5
7
  import { eventsFromMessage, promptFromMessage } from "./events.ts";
6
8
  import { scanDirectorySessions, SessionDiscovery } from "./discovery.ts";
7
- import { DeskReporter } from "./reporter.ts";
9
+ import { DeskReporter, type OdkLinkSnapshot } from "./reporter.ts";
10
+ import { SessionHost } from "./session-host.ts";
11
+ import { CurrentSessionReplay } from "./session-replay.ts";
8
12
  import { createTcpTransport } from "./transport.ts";
9
13
 
10
- const CONFIG_HINT = "set ODK_DESK_LINK_ADDRESS and ODK_DESK_LINK_TOKEN to report this machine";
14
+ const CONFIG_HINT = "set ODK_LINK_ADDRESS and ODK_LINK_TOKEN, or list desks in ~/.config/odk/desks.json, to report this machine";
15
+ /** Read only a bounded tail before it enters the reporter's own 300-event/1MiB retention pipeline. */
16
+ const MAX_DURABLE_SESSION_BYTES = 4 * 1024 * 1024;
11
17
 
12
18
  function workspaceName(cwd: string): string {
13
19
  const parts = cwd.split(/[/\\]/).filter((part) => part.length > 0);
14
20
  return parts.length > 0 ? (parts[parts.length - 1] ?? cwd) : cwd;
15
21
  }
16
22
 
23
+ /**
24
+ * What a running session is doing, in the card's own vocabulary: the tool and the
25
+ * one argument that says what it is working on, as `read: src/example.js` does.
26
+ * A tool whose arguments carry no such line is named and nothing more, so no
27
+ * argument is echoed beyond the summary the desk shows.
28
+ */
29
+ function toolSummary(event: { toolName?: unknown; args?: unknown }): string {
30
+ const name = typeof event.toolName === "string" && event.toolName.length > 0 ? event.toolName : "tool";
31
+ const args = (event.args ?? {}) as Record<string, unknown>;
32
+ const hint = ["command", "path", "file", "pattern", "url", "query"]
33
+ .map((key) => args[key])
34
+ .find((value): value is string => typeof value === "string" && value.trim().length > 0);
35
+ return hint === undefined ? name : `${name}: ${hint.trim()}`;
36
+ }
37
+
38
+ /**
39
+ * The current session manager points at its own trusted durable log. Read its
40
+ * bounded tail in chronological order; other sessions never enter this path.
41
+ */
42
+ async function durableSessionEvents(sessionFile: string | undefined): Promise<ReturnType<typeof eventsFromMessage>> {
43
+ if (!sessionFile) return [];
44
+ try {
45
+ const handle = await open(sessionFile, "r");
46
+ try {
47
+ const stat = await handle.stat();
48
+ if (!stat.isFile() || stat.size <= 0) return [];
49
+ const bytes = Math.min(stat.size, MAX_DURABLE_SESSION_BYTES);
50
+ const tail = Buffer.alloc(bytes);
51
+ await handle.read(tail, 0, bytes, stat.size - bytes);
52
+ const text = tail.toString("utf8");
53
+ const lines = text.split("\n");
54
+ // A writer can have produced valid JSON before its terminating newline.
55
+ // It is still an incomplete append and must wait for message_end instead
56
+ // of being replayed now and then sent a second time live.
57
+ if (!text.endsWith("\n")) lines.pop();
58
+ // A bounded tail can begin mid-record; that first fragment is never an entry.
59
+ if (stat.size > bytes) lines.shift();
60
+ return lines.flatMap((line) => {
61
+ if (!line.trim()) return [];
62
+ try {
63
+ const entry = JSON.parse(line) as { type?: unknown; id?: unknown; message?: unknown };
64
+ return entry.type === "message" && entry.message !== undefined ? eventsFromMessage(entry.message, entry.id) : [];
65
+ } catch {
66
+ return [];
67
+ }
68
+ });
69
+ } finally {
70
+ await handle.close();
71
+ }
72
+ } catch {
73
+ return [];
74
+ }
75
+ }
76
+
17
77
  /**
18
78
  * Report this machine's Pi sessions and, with an independent credential, drive
19
- * Hosted Pi sessions over a separate Desk Link v2 control connection.
79
+ * Hosted Pi sessions over a separate Open DeskOS Link v2 control connection.
20
80
  */
21
81
  export default function (pi: ExtensionAPI): void {
22
- const config = readDeskLinkConfig();
23
- const reporter = config === null ? null : new DeskReporter({ config, createTransport: createTcpTransport });
24
- const discovery = reporter === null ? null : new SessionDiscovery({
82
+ const { configs, refusals } = readOdkLinkConfigs();
83
+ const reporters = configs.map((config) => new DeskReporter({ config, createTransport: createTcpTransport }));
84
+ const endpoints = configs.map((config) => `${config.host}:${config.port}`);
85
+ // This is one machine reporting, however many desks listen: discovery runs
86
+ // once and every desk receives the same session, status, and event updates,
87
+ // so one desk being unreachable never stops the others.
88
+ const each = (run: (reporter: DeskReporter) => void): void => {
89
+ for (const reporter of reporters) run(reporter);
90
+ };
91
+ const aggregate = (): OdkLinkSnapshot | null =>
92
+ aggregateDeskSnapshots(reporters.map((reporter, index) => ({ ...reporter.snapshot(), endpoint: endpoints[index] ?? "desk" })));
93
+ const discovery = reporters.length === 0 ? null : new SessionDiscovery({
25
94
  discover: () => scanDirectorySessions(),
26
- onSnapshot: (sessions) => reporter.replaceDiscoveredSessions(sessions),
95
+ onSnapshot: (sessions) => each((reporter) => reporter.replaceDiscoveredSessions(sessions)),
27
96
  });
97
+ const replay = reporters.length === 0 ? null : new CurrentSessionReplay(durableSessionEvents);
98
+ // A desk's voice agent can drive the session this process is, from inside this process: the
99
+ // same lifecycle events, a private socket, and no new process or credential. It is created
100
+ // here and bound only from session_start, so loading the extension binds nothing.
101
+ //
102
+ // It is opt-in, because an endpoint that lets something else hand this session work is a
103
+ // capability rather than a default: a machine that wants one names the directory it belongs
104
+ // in with ODK_SESSION_HOST_SOCKET, and a machine that does not is never reachable.
105
+ const sessionHost = new SessionHost({ pi });
106
+ const sessionHostEnabled = (process.env.ODK_SESSION_HOST_SOCKET ?? "").length > 0;
28
107
  let currentSessionId = "";
108
+ let sessionGeneration = 0;
109
+ let pendingTurn: { generation: number; sessionId: string; previousMessageId: string | undefined } | null = null;
110
+ const lastMessage = (ctx: ExtensionContext) => ctx.sessionManager.getBranch().filter(entry => entry.type === "message").at(-1);
111
+ let hostRefusal = "";
29
112
 
30
- if (reporter && discovery) {
31
- function beginSession(ctx: Parameters<Parameters<ExtensionAPI["on"]>[1]>[1]): void {
113
+ if (reporters.length > 0 || sessionHostEnabled) {
114
+ function beginSession(ctx: Parameters<Parameters<ExtensionAPI["on"]>[1]>[1]): string {
32
115
  if (currentSessionId.length > 0 && currentSessionId !== ctx.sessionManager.getSessionId()) {
33
- reporter?.markStatus(currentSessionId, "exited");
116
+ each((reporter) => reporter.markStatus(currentSessionId, "exited"));
34
117
  }
118
+ pendingTurn = null;
35
119
  currentSessionId = ctx.sessionManager.getSessionId();
36
120
  const name = ctx.sessionManager.getSessionName();
37
121
  const timestamp = ctx.sessionManager.getHeader()?.timestamp;
38
122
  const startedAt = timestamp ? Date.parse(timestamp) : Number.NaN;
39
- reporter?.recordSession({
123
+ each((reporter) => reporter.recordSession({
40
124
  sessionId: currentSessionId,
41
125
  ...(name === undefined ? {} : { name }),
42
126
  cwd: ctx.cwd,
43
127
  workspaceName: workspaceName(ctx.cwd),
44
128
  status: ctx.isIdle() ? "settled" : "running",
45
129
  startedAt: Number.isFinite(startedAt) ? startedAt : Date.now(),
46
- });
130
+ }));
131
+ return currentSessionId;
47
132
  }
48
133
 
49
- pi.on("session_start", (_event, ctx) => {
50
- beginSession(ctx);
51
- reporter.start();
52
- discovery.start();
134
+ pi.on("session_start", async (event, ctx) => {
135
+ const generation = ++sessionGeneration;
136
+ const sessionId = beginSession(ctx);
137
+ each((reporter) => reporter.start());
138
+ discovery?.start();
139
+ // A resumed/reloaded Pi can have a durable JSONL history that is longer
140
+ // than its in-memory branch. Start the gate for every session; only the
141
+ // resumed/reloaded/forked cases provide a durable tail. New sessions still
142
+ // queue their first message_end event behind this empty replay boundary.
143
+ const replayFile = ["resume", "reload", "fork"].includes((event as { reason?: string }).reason ?? "")
144
+ ? ctx.sessionManager.getSessionFile?.()
145
+ : undefined;
146
+ await replay?.start(sessionId, replayFile, (id, events) => each((reporter) => reporter.recordEvents(id, events)));
147
+ // A reload replaces the previous endpoint cleanly, so a desk never finds a
148
+ // half-replaced one, and a machine that cannot spare an endpoint still reports.
149
+ if (generation !== sessionGeneration) return;
150
+ if (sessionHostEnabled) {
151
+ const started = await sessionHost.start(ctx);
152
+ if (generation !== sessionGeneration) return;
153
+ const previous = refusals.indexOf(hostRefusal);
154
+ if (previous >= 0) refusals.splice(previous, 1);
155
+ hostRefusal = started.reason ? `current-session endpoint: ${started.reason}` : "";
156
+ if (hostRefusal) refusals.push(hostRefusal);
157
+ }
53
158
  });
54
159
  pi.on("session_info_changed", (event) => {
55
- if (currentSessionId.length > 0) reporter.renameSession(currentSessionId, event.name);
160
+ if (currentSessionId.length > 0) each((reporter) => reporter.renameSession(currentSessionId, event.name));
161
+ });
162
+ pi.on("agent_start", (_event, ctx) => {
163
+ pendingTurn = { generation: sessionGeneration, sessionId: ctx.sessionManager.getSessionId(), previousMessageId: lastMessage(ctx)?.id };
164
+ sessionHost.markRunning();
165
+ each((reporter) => reporter.markStatus(currentSessionId, "running"));
166
+ });
167
+ pi.on("agent_settled", (event, ctx) => {
168
+ const turn = pendingTurn;
169
+ pendingTurn = null;
170
+ sessionHost.markSettled();
171
+ each((reporter) => reporter.markStatus(currentSessionId, "settled"));
172
+ if (!turn || event.aborted || turn.generation !== sessionGeneration || turn.sessionId !== currentSessionId
173
+ || ctx.sessionManager.getSessionId() !== currentSessionId) return;
174
+ // message_end runs before appendMessage. Only settled exposes this turn's
175
+ // persisted identity, never the preceding leaf or a text-derived surrogate.
176
+ const entry = lastMessage(ctx);
177
+ if (!entry || entry.id === turn.previousMessageId || entry.message.role !== "assistant" || entry.message.stopReason !== "stop") return;
178
+ const completed = eventsFromMessage(entry.message, entry.id).filter(value => value.kind === "assistant");
179
+ replay?.append(currentSessionId, completed, (id, fresh) => each(reporter => reporter.recordEvents(id, fresh)));
180
+ });
181
+ // A turn runs several tools before its message ends, and until that message
182
+ // the card would keep showing the prompt that started the turn. The tool that
183
+ // is running now is the newest thing the session is doing, so it is what the
184
+ // card states.
185
+ pi.on("tool_execution_start", (event) => {
186
+ if (currentSessionId.length === 0) return;
187
+ const activity = toolSummary(event);
188
+ sessionHost.observeActivity(activity);
189
+ each((reporter) => reporter.recordActivity(currentSessionId, activity));
56
190
  });
57
- pi.on("agent_start", () => reporter.markStatus(currentSessionId, "running"));
58
- pi.on("agent_settled", () => reporter.markStatus(currentSessionId, "settled"));
59
191
  pi.on("message_end", (event) => {
192
+ sessionHost.observeMessage(event.message);
60
193
  if (currentSessionId.length === 0) return;
61
194
  const prompt = promptFromMessage(event.message);
62
- if (prompt.length > 0) reporter.recordSession({ sessionId: currentSessionId, latestGoal: prompt });
63
- reporter.recordEvents(currentSessionId, eventsFromMessage(event.message));
195
+ if (prompt.length > 0) each((reporter) => reporter.recordSession({ sessionId: currentSessionId, latestGoal: prompt }));
196
+ const events = eventsFromMessage(event.message);
197
+ replay?.append(currentSessionId, events, (id, fresh) => each((reporter) => reporter.recordEvents(id, fresh)));
64
198
  });
65
- pi.on("session_shutdown", () => {
66
- discovery.stop();
67
- reporter.markStatus(currentSessionId, "exited");
68
- reporter.disconnect();
199
+ pi.on("session_shutdown", async () => {
200
+ sessionGeneration += 1;
201
+ pendingTurn = null;
202
+ replay?.invalidate();
203
+ discovery?.stop();
204
+ each((reporter) => reporter.markStatus(currentSessionId, "exited"));
205
+ currentSessionId = "";
206
+ each((reporter) => reporter.disconnect());
207
+ await sessionHost.close();
69
208
  });
70
209
  }
71
210
 
72
211
  registerConsoleExtension({
73
212
  pi,
74
- config,
213
+ // The Console drives one desk: the one that issued a control credential,
214
+ // unless the operator named the desk they mean.
215
+ config: selectConsoleDesk(configs, process.env.ODK_LINK_CONSOLE_DESK ?? ""),
216
+ desks: configs,
217
+ refusals,
75
218
  createControlTransport: createControlTcpTransport,
76
- reporterSnapshot: () => reporter?.snapshot() ?? null,
219
+ reporterSnapshot: aggregate,
77
220
  configHint: CONFIG_HINT,
78
221
  });
79
222
  }
package/src/processes.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { execFile } from "node:child_process";
2
- import { basename } from "node:path";
3
2
 
4
3
  /** A Pi process identity from one bounded, read-only OS process snapshot. */
5
4
  export interface PiProcess {
@@ -9,24 +8,85 @@ export interface PiProcess {
9
8
 
10
9
  const MAX_PROCESS_BYTES = 1024 * 1024;
11
10
  const PROCESS_TIMEOUT_MS = 2000;
11
+ const WINDOWS_PROCESS_SCRIPT = [
12
+ '$ErrorActionPreference = "Stop";',
13
+ '[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false);',
14
+ 'Get-CimInstance Win32_Process |',
15
+ 'Select-Object ProcessId, Name, CommandLine,',
16
+ "@{n='startedAtMs'; e={ [int64]([DateTimeOffset]$_.CreationDate).ToUnixTimeMilliseconds() }} |",
17
+ 'ConvertTo-Json -Compress -Depth 3',
18
+ ].join(" ");
19
+
20
+ function executableName(value: string): string {
21
+ return (value.split(/[/\\]/).at(-1) ?? "").replace(/\.exe$/i, "");
22
+ }
12
23
 
13
24
  function piExecutable(value: string): boolean {
14
- return /^pi(?:-rpc)?(?:\.[cm]?js)?$/i.test(basename(value));
25
+ return /^pi(?:-rpc)?(?:\.[cm]?js)?$/i.test(executableName(value));
15
26
  }
16
27
 
17
- function isPiInvocation(comm: string, args: string): boolean {
28
+ function isPiInvocation(comm: string, args: string, suppliedTokens?: string[]): boolean {
18
29
  if (piExecutable(comm)) return true;
19
- const tokens = args.trim().split(/\s+/);
30
+ const tokens = suppliedTokens ?? args.trim().split(/\s+/);
20
31
  const executable = tokens[0] ?? "";
21
32
  if (piExecutable(executable)) return true;
22
33
  // Inspect the executable/script positions only, never arbitrary arguments
23
34
  // (a grep or shell command mentioning pi is not evidence of a Pi process).
24
- if (!["node", "bun", "deno"].includes(basename(executable))) return false;
35
+ if (!["node", "bun", "deno"].includes(executableName(executable).toLowerCase())) return false;
25
36
  const script = tokens[1] ?? "";
26
37
  return piExecutable(script)
27
38
  || /[/\\]@earendil-works[/\\]pi-coding-agent[/\\]dist[/\\](?:bundle[/\\])?(?:cli|rpc-entry)\.js$/.test(script);
28
39
  }
29
40
 
41
+ /** Windows quoting keeps paths with spaces together; backslashes escape quotes only. */
42
+ function windowsCommandTokens(value: string): string[] {
43
+ const tokens: string[] = [];
44
+ let token = "", quoted = false, started = false;
45
+ for (let index = 0; index < value.length;) {
46
+ const character = value[index]!;
47
+ if (character === "\\") {
48
+ let count = 0;
49
+ while (value[index] === "\\") { count += 1; index += 1; }
50
+ if (value[index] === '"') {
51
+ token += "\\".repeat(Math.floor(count / 2));
52
+ if (count % 2) token += '"';
53
+ else quoted = !quoted;
54
+ index += 1;
55
+ } else token += "\\".repeat(count);
56
+ started = true;
57
+ continue;
58
+ }
59
+ if (character === '"') { quoted = !quoted; started = true; index += 1; continue; }
60
+ if (!quoted && /[ \t]/.test(character)) {
61
+ if (started) tokens.push(token);
62
+ token = ""; started = false; index += 1; continue;
63
+ }
64
+ token += character; started = true; index += 1;
65
+ }
66
+ if (quoted) return [];
67
+ if (started) tokens.push(token);
68
+ return tokens;
69
+ }
70
+
71
+ /** Read only the projected WMI identity, never disclose command lines. */
72
+ export function parseWindowsPiProcesses(output: string, now: number): PiProcess[] {
73
+ if (Buffer.byteLength(output) > MAX_PROCESS_BYTES) return [];
74
+ let decoded: unknown;
75
+ try { decoded = JSON.parse(output.replace(/^\uFEFF/, "")); } catch { return []; }
76
+ const processes: PiProcess[] = [];
77
+ for (const item of Array.isArray(decoded) ? decoded : [decoded]) {
78
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
79
+ const row = item as Record<string, unknown>;
80
+ const pid = row.ProcessId, startedAt = row.startedAtMs;
81
+ if (typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0 || pid > 0x7fffffff
82
+ || typeof startedAt !== "number" || !Number.isSafeInteger(startedAt) || startedAt <= 0 || startedAt > now) continue;
83
+ const command = typeof row.CommandLine === "string" ? row.CommandLine : "";
84
+ const name = typeof row.Name === "string" ? row.Name : "";
85
+ if (isPiInvocation(name, command, windowsCommandTokens(command))) processes.push({ pid, startedAt });
86
+ }
87
+ return processes;
88
+ }
89
+
30
90
  function elapsedSeconds(value: string): number | null {
31
91
  if (/^\d+$/.test(value)) return Number(value);
32
92
  const match = value.match(/^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/);
@@ -48,14 +108,58 @@ export function parsePiProcesses(output: string, now: number): PiProcess[] {
48
108
  }
49
109
 
50
110
  /** Never log command lines; discard the bounded process table after parsing. */
51
- export function listPiProcesses(): Promise<PiProcess[]> {
52
- const now = Date.now();
53
- const args = process.platform === "darwin"
111
+ export function readProcessSnapshot(command: string, args: string[], options: {
112
+ timeoutMs?: number;
113
+ execute?: typeof execFile;
114
+ } = {}): Promise<string> {
115
+ const timeoutMs = options.timeoutMs ?? PROCESS_TIMEOUT_MS;
116
+ return new Promise((resolve, reject) => {
117
+ let settled = false;
118
+ let deadline: ReturnType<typeof setTimeout> | undefined;
119
+ const finish = (error: Error | null, output = ""): void => {
120
+ if (settled) return;
121
+ settled = true;
122
+ if (deadline !== undefined) clearTimeout(deadline);
123
+ if (error) reject(error);
124
+ else resolve(output);
125
+ };
126
+ // execFile's timeout waits for child close; a stuck OS query may never close.
127
+ const child = (options.execute ?? execFile)(command, args, {
128
+ encoding: "utf8", maxBuffer: MAX_PROCESS_BYTES, timeout: timeoutMs, windowsHide: true,
129
+ }, (error, stdout) => finish(error, stdout));
130
+ if (settled) return;
131
+ deadline = setTimeout(() => {
132
+ if (settled) return;
133
+ child.kill("SIGKILL");
134
+ child.stdout?.destroy();
135
+ child.stderr?.destroy();
136
+ child.unref();
137
+ finish(new Error("process snapshot deadline exceeded"));
138
+ }, timeoutMs);
139
+ deadline.unref?.();
140
+ });
141
+ }
142
+
143
+ /** Return an unavailable inventory on every bounded query failure. */
144
+ export async function listPiProcesses(options: {
145
+ platform?: NodeJS.Platform;
146
+ now?: () => number;
147
+ readSnapshot?: (command: string, args: string[]) => Promise<string>;
148
+ } = {}): Promise<PiProcess[]> {
149
+ const platform = options.platform ?? process.platform;
150
+ const now = (options.now ?? Date.now)();
151
+ const command = platform === "win32" ? "powershell.exe" : "ps";
152
+ const args = platform === "win32"
153
+ ? ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_PROCESS_SCRIPT]
154
+ : platform === "darwin"
54
155
  ? ["-axo", "pid=,etime=,stat=,comm=,args="]
55
156
  : ["-eo", "pid=,etimes=,stat=,comm=,args="];
56
- return new Promise((resolve) => {
57
- execFile("ps", args, { encoding: "utf8", maxBuffer: MAX_PROCESS_BYTES, timeout: PROCESS_TIMEOUT_MS }, (error, stdout) => {
58
- resolve(error ? [] : parsePiProcesses(stdout, now));
59
- });
60
- });
157
+ const read = options.readSnapshot ?? ((executable, arguments_) => readProcessSnapshot(executable, arguments_, {
158
+ timeoutMs: platform === "win32" ? 5000 : PROCESS_TIMEOUT_MS,
159
+ }));
160
+ try {
161
+ const output = await read(command, args);
162
+ if (Buffer.byteLength(output) > MAX_PROCESS_BYTES) return [];
163
+ return platform === "win32" ? parseWindowsPiProcesses(output, now) : parsePiProcesses(output, now);
164
+ } catch { return []; }
61
165
  }