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/reporter.ts CHANGED
@@ -1,28 +1,30 @@
1
1
  import {
2
- DESK_LINK_PROTOCOL,
3
- type DeskLinkConfig,
4
- type DeskLinkRecord,
2
+ ODK_LINK_PROTOCOL,
3
+ type OdkLinkConfig,
4
+ type OdkLinkRecord,
5
5
  type ReportedSession,
6
6
  type SessionEvent,
7
7
  type SessionStatus,
8
8
  } from "./types.ts";
9
- import { boundEventText, boundSessionEvent, retainEvents } from "./events.ts";
9
+ import { boundEventLines, boundEventText, boundSessionEvent, MAX_EVENT_LINES, retainEvents } from "./events.ts";
10
10
  import { normalizeSessionId } from "./session-identity.ts";
11
11
  import { type DeskTransport } from "./transport.ts";
12
12
 
13
13
  export type LinkState = "offline" | "connecting" | "connected";
14
14
 
15
15
  export interface DeskReporterOptions {
16
- config: DeskLinkConfig;
17
- createTransport: (config: DeskLinkConfig) => DeskTransport;
16
+ config: OdkLinkConfig;
17
+ createTransport: (config: OdkLinkConfig) => DeskTransport;
18
18
  now?: () => number;
19
19
  /** Injected so reconnect scheduling is asserted without waiting. */
20
20
  schedule?: (run: () => void, delayMs: number) => (() => void) | void;
21
+ /** Authentication deadline, separate from capped reconnect scheduling. */
22
+ scheduleHandshake?: (run: () => void, delayMs: number) => (() => void) | void;
21
23
  /** Called on every link-state change, so a surface can stop showing a stale one. */
22
- onChange?: (snapshot: DeskLinkSnapshot) => void;
24
+ onChange?: (snapshot: OdkLinkSnapshot) => void;
23
25
  }
24
26
 
25
- export interface DeskLinkSnapshot {
27
+ export interface OdkLinkSnapshot {
26
28
  link: LinkState;
27
29
  machine: string;
28
30
  sessions: number;
@@ -30,10 +32,17 @@ export interface DeskLinkSnapshot {
30
32
  attempts: number;
31
33
  omittedSessions: number;
32
34
  lastError?: string;
35
+ /**
36
+ * Present when this machine reports to more than one desk: each desk's own
37
+ * endpoint and link state, so a set that is only half up cannot be stated as
38
+ * a single healthy link.
39
+ */
40
+ desks?: { endpoint: string; link: LinkState; lastError?: string }[];
33
41
  }
34
42
 
35
43
  const RECONNECT_BASE_MS = 1000;
36
44
  const RECONNECT_MAX_MS = 30000;
45
+ const HANDSHAKE_TIMEOUT_MS = 10000;
37
46
  const MAX_RECORD_BYTES = 65536;
38
47
  const MAX_EVENTS_RECORD_BYTES = 1024 * 1024;
39
48
  /** Sessions one machine may report at once. */
@@ -70,11 +79,12 @@ interface SessionEntry {
70
79
  * An outage therefore costs freshness, never an unbounded backlog.
71
80
  */
72
81
  export class DeskReporter {
73
- readonly #config: DeskLinkConfig;
74
- readonly #createTransport: (config: DeskLinkConfig) => DeskTransport;
82
+ readonly #config: OdkLinkConfig;
83
+ readonly #createTransport: (config: OdkLinkConfig) => DeskTransport;
75
84
  readonly #now: () => number;
76
85
  readonly #schedule: NonNullable<DeskReporterOptions["schedule"]>;
77
- readonly #onChange: ((snapshot: DeskLinkSnapshot) => void) | undefined;
86
+ readonly #scheduleHandshake: NonNullable<DeskReporterOptions["scheduleHandshake"]>;
87
+ readonly #onChange: ((snapshot: OdkLinkSnapshot) => void) | undefined;
78
88
  readonly #sessions = new Map<string, SessionEntry>();
79
89
 
80
90
  #transport: DeskTransport | null = null;
@@ -86,6 +96,7 @@ export class DeskReporter {
86
96
  #active = false;
87
97
  #generation = 0;
88
98
  #cancelReconnect: (() => void) | void = undefined;
99
+ #cancelHandshake: (() => void) | void = undefined;
89
100
  #currentSessionId = "";
90
101
 
91
102
  constructor(options: DeskReporterOptions) {
@@ -98,6 +109,11 @@ export class DeskReporter {
98
109
  timer.unref?.();
99
110
  return () => clearTimeout(timer);
100
111
  });
112
+ this.#scheduleHandshake = options.scheduleHandshake ?? ((run, delayMs) => {
113
+ const timer = setTimeout(run, delayMs);
114
+ timer.unref?.();
115
+ return () => clearTimeout(timer);
116
+ });
101
117
  }
102
118
 
103
119
  start(): void {
@@ -128,7 +144,9 @@ export class DeskReporter {
128
144
  this.#generation += 1;
129
145
  this.#cancelReconnect?.();
130
146
  this.#cancelReconnect = undefined;
131
- if (this.#link === "connected") this.#send({ v: DESK_LINK_PROTOCOL, type: "bye", machine: this.#config.machine });
147
+ this.#cancelHandshake?.();
148
+ this.#cancelHandshake = undefined;
149
+ if (this.#link === "connected") this.#send({ v: ODK_LINK_PROTOCOL, type: "bye", machine: this.#config.machine });
132
150
  const transport = this.#transport;
133
151
  this.#transport = null;
134
152
  transport?.close();
@@ -231,6 +249,23 @@ export class DeskReporter {
231
249
  entry.events = retainEvents([...entry.events, ...bounded]);
232
250
  entry.pending = retainEvents([...entry.pending, ...bounded]);
233
251
  entry.session = { ...entry.session, activity: boundEventText(bounded[bounded.length - 1]?.text) || entry.session.activity, updatedAt: this.#now() };
252
+ this.#sessionsDirty = true;
253
+ this.#flush();
254
+ }
255
+
256
+ /**
257
+ * What the session is doing right now, as the desk's card shows it. A running
258
+ * session executes several tools inside one turn, and until that turn ends its
259
+ * last message is still the prompt that started it: without this the card showed
260
+ * that prompt for the whole turn, which is what the desk owner reported.
261
+ */
262
+ recordActivity(sessionId: string, text: string): void {
263
+ const entry = this.#sessions.get(sessionId);
264
+ if (entry === undefined) return;
265
+ const activity = boundEventText(text);
266
+ if (activity.length === 0 || activity === entry.session.activity) return;
267
+ entry.session = { ...entry.session, activity, updatedAt: this.#now() };
268
+ this.#sessionsDirty = true;
234
269
  this.#flush();
235
270
  }
236
271
 
@@ -246,7 +281,7 @@ export class DeskReporter {
246
281
  return this.#sessions.get(sessionId)?.events ?? [];
247
282
  }
248
283
 
249
- snapshot(): DeskLinkSnapshot {
284
+ snapshot(): OdkLinkSnapshot {
250
285
  const record = this.#sessionRecord();
251
286
  let events = 0;
252
287
  for (const entry of this.#sessions.values()) events += entry.events.length;
@@ -269,41 +304,73 @@ export class DeskReporter {
269
304
  if (!this.#active || this.#stopped || this.#transport !== null) return;
270
305
  this.#link = "connecting";
271
306
  this.#notify();
272
- const transport = this.#createTransport(this.#config);
307
+ let transport: DeskTransport;
308
+ try {
309
+ transport = this.#createTransport(this.#config);
310
+ } catch (error) {
311
+ this.#link = "offline";
312
+ this.#lastError = error instanceof Error ? error.message : "transport creation failed";
313
+ this.#notify();
314
+ this.#scheduleReconnect();
315
+ return;
316
+ }
273
317
  this.#transport = transport;
318
+ let opened = false;
274
319
  transport.onOpen(() => {
275
- if (!this.#active || this.#transport !== transport) return;
276
- this.#lastError = undefined;
277
- this.#link = "connected";
278
- this.#notify();
279
- this.#send({ v: DESK_LINK_PROTOCOL, type: "hello", machine: this.#config.machine, token: this.#config.token });
280
- // The service discards machine state after its last link closes. Replay
281
- // the bounded tail, not pending plus history. v1 has no event IDs, so a
282
- // surviving sibling link can cause duplicate retained events on replay.
283
- for (const entry of this.#sessions.values()) entry.pending = [...entry.events];
284
- this.#sessionsDirty = true;
285
- this.#flush();
320
+ if (!this.#active || this.#transport !== transport || opened) return;
321
+ opened = true;
322
+ this.#send({ v: ODK_LINK_PROTOCOL, type: "hello", machine: this.#config.machine, token: this.#config.token });
323
+ if (this.#transport !== transport || this.#link === "connected") return;
324
+ this.#cancelHandshake = this.#scheduleHandshake(() => {
325
+ if (this.#transport !== transport || this.#link !== "connecting") return;
326
+ this.#failTransport(transport, "reporting acknowledgement timeout");
327
+ }, HANDSHAKE_TIMEOUT_MS);
286
328
  });
287
329
  transport.onClose((reason) => {
288
330
  if (!this.#active || this.#transport !== transport) return;
289
- this.#link = "offline";
290
- this.#lastError = reason;
291
- this.#notify();
292
- if (this.#transport === transport) this.#transport = null;
293
- this.#scheduleReconnect();
331
+ this.#failTransport(transport, reason);
294
332
  });
295
333
  transport.onRecord((record) => {
296
334
  if (!this.#active || this.#transport !== transport) return;
297
335
  // The service's ack is the only proof the link was accepted. Resetting the
298
336
  // backoff on a bare TCP connect would make a refused token retry forever.
299
- if (typeof record === "object" && record !== null && (record as { type?: unknown }).type === "ack") {
337
+ if (typeof record !== "object" || record === null) return;
338
+ const reply = record as { v?: unknown; type?: unknown; reason?: unknown };
339
+ if (reply.v !== ODK_LINK_PROTOCOL) return;
340
+ if (reply.type === "error") {
341
+ this.#failTransport(transport, boundEventText(reply.reason) || "reporting link refused");
342
+ return;
343
+ }
344
+ if (reply.type === "ack" && opened && this.#link === "connecting") {
345
+ this.#cancelHandshake?.();
346
+ this.#cancelHandshake = undefined;
300
347
  this.#attempts = 0;
348
+ this.#lastError = undefined;
349
+ this.#link = "connected";
350
+ this.#notify();
351
+ // A server restart forgets the machine's state: replay one retained tail.
352
+ // v1 has no IDs, so a surviving sibling link can retain duplicates.
353
+ for (const entry of this.#sessions.values()) entry.pending = [...entry.events];
354
+ this.#sessionsDirty = true;
355
+ this.#flush();
301
356
  }
302
357
  // v1 is report-only. A future `prompt` record is the reserved control hook
303
358
  // and is deliberately not handled here.
304
359
  });
305
360
  }
306
361
 
362
+ #failTransport(transport: DeskTransport, reason: string): void {
363
+ if (this.#transport !== transport) return;
364
+ this.#cancelHandshake?.();
365
+ this.#cancelHandshake = undefined;
366
+ this.#transport = null;
367
+ this.#link = "offline";
368
+ this.#lastError = reason;
369
+ transport.close();
370
+ this.#notify();
371
+ this.#scheduleReconnect();
372
+ }
373
+
307
374
  #scheduleReconnect(): void {
308
375
  if (this.#stopped || !this.#active) return;
309
376
  const generation = this.#generation;
@@ -316,9 +383,9 @@ export class DeskReporter {
316
383
  }, delayMs);
317
384
  }
318
385
 
319
- #sessionRecord(): Extract<DeskLinkRecord, { type: "sessions" }> {
320
- const record: Extract<DeskLinkRecord, { type: "sessions" }> = {
321
- v: DESK_LINK_PROTOCOL,
386
+ #sessionRecord(): Extract<OdkLinkRecord, { type: "sessions" }> {
387
+ const record: Extract<OdkLinkRecord, { type: "sessions" }> = {
388
+ v: ODK_LINK_PROTOCOL,
322
389
  type: "sessions",
323
390
  machine: this.#config.machine,
324
391
  sessions: [...this.#sessions.values()].map((entry) => entry.session),
@@ -331,10 +398,15 @@ export class DeskReporter {
331
398
  record.sessions = record.sessions.map((session) => {
332
399
  if (!session.discovered) return session;
333
400
  const short = (text: string): string => max === 0 ? "" : boundEventText(text, max);
401
+ // The prompt keeps its lines up to the card's own reading budget: a person
402
+ // who pastes several lines expects the desk to show the lines rather than
403
+ // the first one and nothing else. The activity stays the single short line
404
+ // it is for, because it answers what the session is doing right now.
405
+ const prompt = (text: string): string => max === 0 ? "" : boundEventLines(text, MAX_EVENT_LINES, max);
334
406
  return {
335
407
  ...session,
336
408
  ...(session.name === undefined ? {} : { name: short(session.name) }),
337
- ...(session.latestGoal === undefined ? {} : { latestGoal: short(session.latestGoal) }),
409
+ ...(session.latestGoal === undefined ? {} : { latestGoal: prompt(session.latestGoal) }),
338
410
  ...(session.activity === undefined ? {} : { activity: short(session.activity) }),
339
411
  };
340
412
  });
@@ -370,8 +442,8 @@ export class DeskReporter {
370
442
 
371
443
  /** Unlike replacing sessions frames, append-only events may split between events. */
372
444
  #sendEventBatches(sessionId: string, events: readonly SessionEvent[]): void {
373
- const record: Extract<DeskLinkRecord, { type: "events" }> = {
374
- v: DESK_LINK_PROTOCOL,
445
+ const record: Extract<OdkLinkRecord, { type: "events" }> = {
446
+ v: ODK_LINK_PROTOCOL,
375
447
  type: "events",
376
448
  machine: this.#config.machine,
377
449
  sessionId,
@@ -400,7 +472,13 @@ export class DeskReporter {
400
472
  if (record.events.length > 0 && this.#link === "connected") this.#send(record);
401
473
  }
402
474
 
403
- #send(record: DeskLinkRecord): void {
404
- this.#transport?.send(record);
475
+ #send(record: OdkLinkRecord): void {
476
+ const transport = this.#transport;
477
+ if (transport === null) return;
478
+ try {
479
+ transport.send(record);
480
+ } catch (error) {
481
+ this.#failTransport(transport, error instanceof Error ? error.message : "transport write failed");
482
+ }
405
483
  }
406
484
  }
@@ -0,0 +1,274 @@
1
+ #!/usr/bin/env node
2
+ // One-shot session-control relay. A desk runs this on the far side of its own SSH session, so it
3
+ // reads one bounded JSON frame from stdin, writes it to the session's socket, prints the reply
4
+ // frame, and exits. It needs no build step, no configuration of its own, and no credential:
5
+ // ownership of the socket file is the whole gate.
6
+ //
7
+ // The frame contract is `integrations/personal-bot/src/task-protocol.mjs` in the Open DeskOS
8
+ // repository, spoken here rather than forked.
9
+ import { createConnection } from 'node:net'
10
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
11
+ import { realpath, stat } from 'node:fs/promises'
12
+ import { homedir } from 'node:os'
13
+ import { isAbsolute, join, resolve as resolvePath, sep } from 'node:path'
14
+ import { StringDecoder } from 'node:string_decoder'
15
+
16
+ const REQUEST_LIMIT = 64 * 1024
17
+ const RESPONSE_LIMIT = 256 * 1024
18
+ const REQUEST_TIMEOUT = 8000
19
+ const DEADLINE = REQUEST_TIMEOUT + 1000
20
+ const FAILURE = '会话控制通信失败,结果未知'
21
+ const USAGE = '用法: session-host-client.mjs <session-control socket path> | --find,stdin 上一个 JSON 请求帧'
22
+ // The protocol's own admission refusal, so a project no session serves is answered in
23
+ // the frame a desk already reads rather than as a client usage error.
24
+ const PROJECT_NOT_SERVED = '项目不在允许的开发目录内'
25
+ const TASK_NOT_FOUND = '未找到任务'
26
+ const NOT_A_SESSION_HOST = '此端点是当前 Pi 会话,不是 Hosted Pi 任务服务'
27
+ const ENDPOINT_LIMIT = 64
28
+ const ROOT_LIMIT = 16
29
+ const PROTOCOL_INVALID = '任务协议无效'
30
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
31
+
32
+ /** Fixed operator arguments only; SSH_ORIGINAL_COMMAND is never an input. */
33
+ async function relayOptions(args) {
34
+ const restricted = args.some(arg => arg.startsWith('--allow-root'))
35
+ if (!restricted) return { restricted: false, endpoint: args[0] ?? '--find', roots: [] }
36
+ const roots = []
37
+ const remaining = [...args]
38
+ const endpoint = remaining[0] === '--allow-root' ? '--find' : remaining.shift()
39
+ try {
40
+ while (remaining.length) {
41
+ if (remaining.shift() !== '--allow-root' || roots.length >= ROOT_LIMIT) throw Error(PROTOCOL_INVALID)
42
+ const root = remaining.shift()
43
+ if (typeof root !== 'string' || !isAbsolute(root) || /[\x00-\x1f\x7f]/.test(root)) throw Error(PROTOCOL_INVALID)
44
+ const canonical = await realpath(root)
45
+ if (!(await stat(canonical)).isDirectory()) throw Error(PROTOCOL_INVALID)
46
+ roots.push(canonical)
47
+ }
48
+ if (endpoint !== '--find' && (typeof endpoint !== 'string' || !isAbsolute(endpoint) || /[\x00-\x1f\x7f]/.test(endpoint))) throw Error(PROTOCOL_INVALID)
49
+ return { restricted: true, endpoint, roots }
50
+ } catch { return { restricted: true, endpoint, roots: [], invalid: true } }
51
+ }
52
+
53
+ /** Admit one protocol request before discovery or socket contact. */
54
+ async function restrictedRefusal(request, options) {
55
+ if (options.invalid || !request || typeof request !== 'object' || Array.isArray(request)
56
+ || request.version !== 1 || typeof request.requestId !== 'string' || !request.requestId.length || request.requestId.length > 128
57
+ || !['list', 'status', 'prompt', 'cancel'].includes(request.command)) return PROTOCOL_INVALID
58
+ const fields = ['version', 'requestId', 'command', 'project', ...(request.command === 'list' ? [] : ['taskId']),
59
+ ...(['prompt', 'cancel'].includes(request.command) ? ['mutationId'] : []), ...(request.command === 'prompt' ? ['prompt', 'streamingBehavior'] : [])]
60
+ if (Object.keys(request).some(key => !fields.includes(key))) return PROTOCOL_INVALID
61
+ if (request.command !== 'list' && (typeof request.taskId !== 'string' || !UUID.test(request.taskId))) return PROTOCOL_INVALID
62
+ if (request.mutationId !== undefined && (typeof request.mutationId !== 'string' || !UUID.test(request.mutationId))) return PROTOCOL_INVALID
63
+ if (request.command === 'prompt' && (typeof request.prompt !== 'string' || !request.prompt.trim() || request.prompt.length > 16_384
64
+ || Buffer.from(request.prompt).toString('utf8') !== request.prompt || (request.streamingBehavior !== undefined && !['steer', 'followUp'].includes(request.streamingBehavior)))) return PROTOCOL_INVALID
65
+ if (typeof request.project !== 'string' || !isAbsolute(request.project) || /[\x00-\x1f\x7f]/.test(request.project)) return PROJECT_NOT_SERVED
66
+ try {
67
+ const project = await realpath(request.project)
68
+ if (!(await stat(project)).isDirectory() || !options.roots.some(root => admits(root, project))) return PROJECT_NOT_SERVED
69
+ } catch { return PROJECT_NOT_SERVED }
70
+ return undefined
71
+ }
72
+
73
+ function refusal(request, error) {
74
+ const requestId = typeof request?.requestId === 'string' && request.requestId.length <= 128 ? request.requestId : ''
75
+ return { version: 1, requestId, ok: false, error }
76
+ }
77
+
78
+ /** Read exactly one bounded JSON line, as the protocol's own one-shot client does. */
79
+ function readFrame(stream) {
80
+ return new Promise((resolve, reject) => {
81
+ const decoder = new StringDecoder('utf8')
82
+ let pending = ''
83
+ const cleanup = () => { stream.off('data', onData); stream.off('end', onEnd); stream.off('close', onEnd); stream.off('error', onError) }
84
+ const fail = () => { cleanup(); reject(new Error(FAILURE)) }
85
+ const onEnd = () => fail()
86
+ const onError = () => fail()
87
+ const onData = chunk => {
88
+ pending += decoder.write(chunk)
89
+ if (Buffer.byteLength(pending) > REQUEST_LIMIT) return fail()
90
+ const newline = pending.indexOf('\n')
91
+ if (newline < 0) return
92
+ if (pending.slice(newline + 1).trim()) return fail()
93
+ const line = pending.slice(0, newline)
94
+ let request
95
+ try { request = JSON.parse(line) } catch { return fail() }
96
+ cleanup()
97
+ resolve(request)
98
+ }
99
+ stream.on('data', onData)
100
+ stream.once('end', onEnd)
101
+ stream.once('close', onEnd)
102
+ stream.once('error', onError)
103
+ })
104
+ }
105
+
106
+ /** Connect, write the one frame, and read the one frame back. */
107
+ function exchange(socketPath, request) {
108
+ return new Promise((resolve, reject) => {
109
+ const frame = `${JSON.stringify(request)}\n`
110
+ const socket = createConnection(socketPath)
111
+ const decoder = new StringDecoder('utf8')
112
+ let pending = ''
113
+ const cleanup = () => { clearTimeout(timer); socket.off('data', onData); socket.off('end', onEnd); socket.off('close', onEnd); socket.off('error', onError) }
114
+ const fail = () => { cleanup(); socket.destroy(); reject(new Error(FAILURE)) }
115
+ const onEnd = () => fail()
116
+ const onError = () => fail()
117
+ const onData = chunk => {
118
+ pending += decoder.write(chunk)
119
+ if (Buffer.byteLength(pending) > RESPONSE_LIMIT) return fail()
120
+ const newline = pending.indexOf('\n')
121
+ if (newline < 0) return
122
+ cleanup()
123
+ socket.destroy()
124
+ resolve(pending.slice(0, newline))
125
+ }
126
+ const timer = setTimeout(fail, REQUEST_TIMEOUT)
127
+ socket.on('data', onData)
128
+ socket.once('end', onEnd)
129
+ socket.once('close', onEnd)
130
+ socket.once('error', onError)
131
+ socket.once('connect', () => socket.write(frame))
132
+ })
133
+ }
134
+
135
+ /** The directory this machine's sessions publish into, by the same rule the host binds with. */
136
+ function sessionsDir() {
137
+ const override = process.env.ODK_SESSION_HOST_SOCKET ?? ''
138
+ if (isAbsolute(override)) return override
139
+ const runtimeDir = process.env.XDG_RUNTIME_DIR
140
+ if (typeof runtimeDir === 'string' && runtimeDir.length > 0 && isAbsolute(runtimeDir)) return join(runtimeDir, 'odk', 'sessions')
141
+ return process.platform === 'win32'
142
+ ? join(process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local'), 'odk', 'sessions')
143
+ : join(homedir(), '.local', 'run', 'odk', 'sessions')
144
+ }
145
+
146
+ /** True when a requested project is a session's own project or lies inside it. */
147
+ function admits(declared, requested) {
148
+ if (typeof declared !== 'string' || declared.length === 0) return false
149
+ if (typeof requested !== 'string' || requested.length === 0) return false
150
+ const root = resolvePath(declared)
151
+ const target = resolvePath(requested)
152
+ return target === root || target.startsWith(root.endsWith(sep) ? root : `${root}${sep}`)
153
+ }
154
+
155
+ /**
156
+ * Which of this machine's sessions answers this frame. Every session publishes its own
157
+ * project and its own socket, so the choice follows what each one declares; a project no
158
+ * session serves has no answer, which the desk reads as the host's own project refusal
159
+ * rather than as some other session's reply.
160
+ */
161
+ async function findSessions(request) {
162
+ const project = typeof request?.project === 'string' ? request.project : ''
163
+ if (!isAbsolute(project) || /[\x00-\x1f\x7f]/.test(project)) return []
164
+ let scope
165
+ try { scope = await realpath(project) } catch { return [] }
166
+ let names
167
+ try {
168
+ names = readdirSync(sessionsDir()).filter((name) => name.endsWith('.json')).sort()
169
+ } catch {
170
+ return []
171
+ }
172
+ const sessions = []
173
+ for (const name of names) {
174
+ let descriptor
175
+ try {
176
+ const file = join(sessionsDir(), name)
177
+ if (statSync(file).size > REQUEST_LIMIT) continue
178
+ descriptor = JSON.parse(readFileSync(file, 'utf8'))
179
+ } catch {
180
+ continue
181
+ }
182
+ if (descriptor?.version !== 1) continue
183
+ if (request.command !== 'list' && descriptor.sessionId !== request.taskId) continue
184
+ if (typeof descriptor.socketPath !== 'string' || !isAbsolute(descriptor.socketPath)) continue
185
+ if (request.command === 'list') {
186
+ let declared
187
+ try { declared = await realpath(descriptor.project) } catch { continue }
188
+ if (!admits(scope, declared)) continue
189
+ } else if (descriptor.project !== project) continue
190
+ sessions.push(descriptor)
191
+ }
192
+ return sessions
193
+ }
194
+
195
+ async function discoveredResponse(request) {
196
+ if (['start', 'launch', 'end', 'history'].includes(request.command)) {
197
+ return { version: 1, requestId: request.requestId, ok: false, error: NOT_A_SESSION_HOST }
198
+ }
199
+ if (request.command !== 'list' && typeof request.taskId !== 'string') {
200
+ return { version: 1, requestId: request.requestId, ok: false, error: TASK_NOT_FOUND }
201
+ }
202
+ const descriptors = await findSessions(request)
203
+ if (!descriptors.length) return { version: 1, requestId: request.requestId, ok: false, error: request.command === 'list' ? PROJECT_NOT_SERVED : TASK_NOT_FOUND }
204
+ if (request.command !== 'list') {
205
+ if (descriptors.length !== 1) return { version: 1, requestId: request.requestId, ok: false, error: TASK_NOT_FOUND }
206
+ return JSON.parse(await exchange(descriptors[0].socketPath, request))
207
+ }
208
+ const frames = await Promise.all(descriptors.slice(0, ENDPOINT_LIMIT).map(async descriptor => {
209
+ try {
210
+ const frame = JSON.parse(await exchange(descriptor.socketPath, { ...request, project: descriptor.project }))
211
+ if (frame?.version !== 1 || frame.requestId !== request.requestId || frame.ok !== true || !Array.isArray(frame.tasks)) return { tasks: [], unavailable: true }
212
+ const tasks = frame.tasks.filter(task => task?.taskId === descriptor.sessionId && task.project === descriptor.project)
213
+ return { tasks, unavailable: tasks.length === 0, incomplete: frame.truncated === true || frame.incomplete === true }
214
+ } catch { return { tasks: [], unavailable: true } }
215
+ }))
216
+ const tasks = []
217
+ const unavailable = frames.filter(frame => frame.unavailable).length
218
+ let truncated = descriptors.length > ENDPOINT_LIMIT
219
+ for (const task of frames.flatMap(frame => frame.tasks)) {
220
+ // false is one byte longer than true. Budget both longest metadata values,
221
+ // including the newline, so a complete inventory also fits the wire bound.
222
+ if (Buffer.byteLength(JSON.stringify({ version: 1, requestId: request.requestId, ok: true, tasks: [...tasks, task], truncated: false, unavailable, incomplete: false })) + 1 > RESPONSE_LIMIT) { truncated = true; break }
223
+ tasks.push(task)
224
+ }
225
+ return { version: 1, requestId: request.requestId, ok: true, tasks, truncated, unavailable, incomplete: truncated || unavailable > 0 || frames.some(frame => frame.incomplete) }
226
+ }
227
+
228
+ let options = { restricted: process.argv.slice(2).some(arg => arg.startsWith('--allow-root')) }
229
+ let admitted = false
230
+ const deadline = setTimeout(() => {
231
+ process.stderr.write(`${FAILURE}\n`)
232
+ process.exit(1)
233
+ }, DEADLINE)
234
+ try {
235
+ options = await relayOptions(process.argv.slice(2))
236
+ const request = await readFrame(process.stdin)
237
+ process.stdin.pause()
238
+ const rejected = options.restricted ? await restrictedRefusal(request, options) : undefined
239
+ if (rejected) {
240
+ process.stdout.write(`${JSON.stringify(refusal(request, rejected))}\n`)
241
+ } else {
242
+ admitted = true
243
+ // A desk declares one executable and sends one frame, so finding the session is this
244
+ // client's work: --find asks each session's own descriptor which one owns the requested
245
+ // project and relays the frame to that session. A socket path named directly still
246
+ // addresses exactly one session.
247
+ // A desk declares one executable and sends one frame, so with no socket named the
248
+ // client finds the session that owns the frame's project by itself.
249
+ const first = options.endpoint
250
+ const finding = first === '--find'
251
+ const socketPath = first
252
+ if (!finding && (typeof socketPath !== 'string' || !socketPath.length || !isAbsolute(socketPath) || /[\x00-\x1f\x7f]/.test(socketPath))) {
253
+ process.stderr.write(`${USAGE}\n`)
254
+ process.exitCode = 2
255
+ } else {
256
+ const response = finding ? JSON.stringify(await discoveredResponse(request)) : await exchange(socketPath, request)
257
+ // A frame that is not this request's answer is not an answer: printing it would let a desk
258
+ // read someone else's reply as its own.
259
+ if (response === '' || Buffer.byteLength(response) > RESPONSE_LIMIT) throw new Error(FAILURE)
260
+ const answer = JSON.parse(response)
261
+ if (answer?.version !== 1 || answer.requestId !== request?.requestId || typeof answer.ok !== 'boolean') throw new Error(FAILURE)
262
+ process.stdout.write(`${response}\n`)
263
+ }
264
+ }
265
+ } catch {
266
+ if (options.restricted && !admitted) process.stdout.write(`${JSON.stringify(refusal(undefined, PROTOCOL_INVALID))}\n`)
267
+ else {
268
+ process.stderr.write(`${FAILURE}\n`)
269
+ process.exitCode = 1
270
+ }
271
+ } finally {
272
+ clearTimeout(deadline)
273
+ process.stdin.destroy()
274
+ }