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/src/index.ts CHANGED
@@ -1,15 +1,17 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { open } from "node:fs/promises";
3
- import { readDeskLinkConfig } from "./config.ts";
3
+ import { readOdkLinkConfigs } from "./config.ts";
4
4
  import { registerConsoleExtension } from "./console-extension.ts";
5
5
  import { createControlTcpTransport } from "./control-transport.ts";
6
+ import { aggregateDeskSnapshots, selectConsoleDesk } from "./desk-set.ts";
6
7
  import { eventsFromMessage, promptFromMessage } from "./events.ts";
7
8
  import { scanDirectorySessions, SessionDiscovery } from "./discovery.ts";
8
- import { DeskReporter } from "./reporter.ts";
9
+ import { DeskReporter, type OdkLinkSnapshot } from "./reporter.ts";
10
+ import { SessionHost } from "./session-host.ts";
9
11
  import { CurrentSessionReplay } from "./session-replay.ts";
10
12
  import { createTcpTransport } from "./transport.ts";
11
13
 
12
- 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";
13
15
  /** Read only a bounded tail before it enters the reporter's own 300-event/1MiB retention pipeline. */
14
16
  const MAX_DURABLE_SESSION_BYTES = 4 * 1024 * 1024;
15
17
 
@@ -18,6 +20,21 @@ function workspaceName(cwd: string): string {
18
20
  return parts.length > 0 ? (parts[parts.length - 1] ?? cwd) : cwd;
19
21
  }
20
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
+
21
38
  /**
22
39
  * The current session manager points at its own trusted durable log. Read its
23
40
  * bounded tail in chronological order; other sessions never enter this path.
@@ -43,8 +60,8 @@ async function durableSessionEvents(sessionFile: string | undefined): Promise<Re
43
60
  return lines.flatMap((line) => {
44
61
  if (!line.trim()) return [];
45
62
  try {
46
- const entry = JSON.parse(line) as { type?: unknown; message?: unknown };
47
- return entry.type === "message" && entry.message !== undefined ? eventsFromMessage(entry.message) : [];
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) : [];
48
65
  } catch {
49
66
  return [];
50
67
  }
@@ -59,42 +76,66 @@ async function durableSessionEvents(sessionFile: string | undefined): Promise<Re
59
76
 
60
77
  /**
61
78
  * Report this machine's Pi sessions and, with an independent credential, drive
62
- * Hosted Pi sessions over a separate Desk Link v2 control connection.
79
+ * Hosted Pi sessions over a separate Open DeskOS Link v2 control connection.
63
80
  */
64
81
  export default function (pi: ExtensionAPI): void {
65
- const config = readDeskLinkConfig();
66
- const reporter = config === null ? null : new DeskReporter({ config, createTransport: createTcpTransport });
67
- 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({
68
94
  discover: () => scanDirectorySessions(),
69
- onSnapshot: (sessions) => reporter.replaceDiscoveredSessions(sessions),
95
+ onSnapshot: (sessions) => each((reporter) => reporter.replaceDiscoveredSessions(sessions)),
70
96
  });
71
- const replay = reporter === null ? null : new CurrentSessionReplay(durableSessionEvents);
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;
72
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 = "";
73
112
 
74
- if (reporter && discovery) {
113
+ if (reporters.length > 0 || sessionHostEnabled) {
75
114
  function beginSession(ctx: Parameters<Parameters<ExtensionAPI["on"]>[1]>[1]): string {
76
115
  if (currentSessionId.length > 0 && currentSessionId !== ctx.sessionManager.getSessionId()) {
77
- reporter?.markStatus(currentSessionId, "exited");
116
+ each((reporter) => reporter.markStatus(currentSessionId, "exited"));
78
117
  }
118
+ pendingTurn = null;
79
119
  currentSessionId = ctx.sessionManager.getSessionId();
80
120
  const name = ctx.sessionManager.getSessionName();
81
121
  const timestamp = ctx.sessionManager.getHeader()?.timestamp;
82
122
  const startedAt = timestamp ? Date.parse(timestamp) : Number.NaN;
83
- reporter?.recordSession({
123
+ each((reporter) => reporter.recordSession({
84
124
  sessionId: currentSessionId,
85
125
  ...(name === undefined ? {} : { name }),
86
126
  cwd: ctx.cwd,
87
127
  workspaceName: workspaceName(ctx.cwd),
88
128
  status: ctx.isIdle() ? "settled" : "running",
89
129
  startedAt: Number.isFinite(startedAt) ? startedAt : Date.now(),
90
- });
130
+ }));
91
131
  return currentSessionId;
92
132
  }
93
133
 
94
134
  pi.on("session_start", async (event, ctx) => {
135
+ const generation = ++sessionGeneration;
95
136
  const sessionId = beginSession(ctx);
96
- reporter.start();
97
- discovery.start();
137
+ each((reporter) => reporter.start());
138
+ discovery?.start();
98
139
  // A resumed/reloaded Pi can have a durable JSONL history that is longer
99
140
  // than its in-memory branch. Start the gate for every session; only the
100
141
  // resumed/reloaded/forked cases provide a durable tail. New sessions still
@@ -102,33 +143,80 @@ export default function (pi: ExtensionAPI): void {
102
143
  const replayFile = ["resume", "reload", "fork"].includes((event as { reason?: string }).reason ?? "")
103
144
  ? ctx.sessionManager.getSessionFile?.()
104
145
  : undefined;
105
- await replay?.start(sessionId, replayFile, (id, events) => reporter?.recordEvents(id, events));
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
+ }
106
158
  });
107
159
  pi.on("session_info_changed", (event) => {
108
- 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));
109
190
  });
110
- pi.on("agent_start", () => reporter.markStatus(currentSessionId, "running"));
111
- pi.on("agent_settled", () => reporter.markStatus(currentSessionId, "settled"));
112
191
  pi.on("message_end", (event) => {
192
+ sessionHost.observeMessage(event.message);
113
193
  if (currentSessionId.length === 0) return;
114
194
  const prompt = promptFromMessage(event.message);
115
- if (prompt.length > 0) reporter.recordSession({ sessionId: currentSessionId, latestGoal: prompt });
195
+ if (prompt.length > 0) each((reporter) => reporter.recordSession({ sessionId: currentSessionId, latestGoal: prompt }));
116
196
  const events = eventsFromMessage(event.message);
117
- replay?.append(currentSessionId, events, (id, fresh) => reporter?.recordEvents(id, fresh));
197
+ replay?.append(currentSessionId, events, (id, fresh) => each((reporter) => reporter.recordEvents(id, fresh)));
118
198
  });
119
- pi.on("session_shutdown", () => {
199
+ pi.on("session_shutdown", async () => {
200
+ sessionGeneration += 1;
201
+ pendingTurn = null;
120
202
  replay?.invalidate();
121
- discovery.stop();
122
- reporter.markStatus(currentSessionId, "exited");
123
- reporter.disconnect();
203
+ discovery?.stop();
204
+ each((reporter) => reporter.markStatus(currentSessionId, "exited"));
205
+ currentSessionId = "";
206
+ each((reporter) => reporter.disconnect());
207
+ await sessionHost.close();
124
208
  });
125
209
  }
126
210
 
127
211
  registerConsoleExtension({
128
212
  pi,
129
- 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,
130
218
  createControlTransport: createControlTcpTransport,
131
- reporterSnapshot: () => reporter?.snapshot() ?? null,
219
+ reporterSnapshot: aggregate,
132
220
  configHint: CONFIG_HINT,
133
221
  });
134
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
  }