@anusornneal/chat-relay 0.7.1 → 0.8.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.
@@ -1,8 +1,12 @@
1
1
  import WebSocket from "ws";
2
- import { DesktopManager } from "./desktop-manager.mjs";
2
+ import { DesktopManager, createDesktopPlatformAdapter } from "./desktop-manager.mjs";
3
3
  import { FileManager } from "./file-manager.mjs";
4
4
  import { ProcessManager } from "./process-manager.mjs";
5
5
  import { TerminalManager } from "./terminal-manager.mjs";
6
+ import { CapabilityScheduler } from "./capability-scheduler.mjs";
7
+ import { AgentConnectionState } from "./connection-state.mjs";
8
+ import { buildAgentHello } from "./protocol.mjs";
9
+ import { AgentLifecycle, AGENT_RESTART_EXIT_CODE } from "./lifecycle.mjs";
6
10
 
7
11
  const relayUrl = process.env.RELAY_URL;
8
12
  const agentToken = process.env.AGENT_TOKEN;
@@ -11,16 +15,59 @@ const agentName = process.env.AGENT_NAME || agentId;
11
15
  const reconnectMs = Number(process.env.RECONNECT_MS ?? 2000);
12
16
  const terminalEnabled = process.env.TERMINAL_ENABLED === "1";
13
17
  const desktopEnabled = process.env.DESKTOP_ENABLED === "1";
18
+ const desktopAdapter = createDesktopPlatformAdapter({ platform: process.platform });
19
+ const agentHello = buildAgentHello({
20
+ platform: desktopAdapter.platform,
21
+ terminalEnabled,
22
+ desktopEnabled: desktopEnabled && desktopAdapter.supported,
23
+ });
14
24
  const MAX_RESPONSE_BYTES = 60 * 1024;
15
25
  const terminals = new TerminalManager({
16
26
  batchConcurrency: process.env.TERMINAL_BATCH_CONCURRENCY,
17
27
  maxQueuedJobs: process.env.TERMINAL_BATCH_MAX_QUEUED,
18
28
  });
29
+ const scheduler = new CapabilityScheduler({
30
+ maxQueued: process.env.AGENT_MAX_QUEUED,
31
+ queueTimeoutMs: process.env.AGENT_QUEUE_TIMEOUT_MS,
32
+ fileConcurrency: process.env.AGENT_FILE_CONCURRENCY,
33
+ processConcurrency: process.env.AGENT_PROCESS_CONCURRENCY,
34
+ terminalExecConcurrency: process.env.AGENT_TERMINAL_EXEC_CONCURRENCY,
35
+ terminalControlConcurrency: process.env.AGENT_TERMINAL_CONTROL_CONCURRENCY,
36
+ desktopReadConcurrency: process.env.AGENT_DESKTOP_READ_CONCURRENCY,
37
+ });
38
+ const connectionState = new AgentConnectionState({
39
+ baseReconnectMs: reconnectMs,
40
+ maxReconnectMs: process.env.RECONNECT_MAX_MS,
41
+ heartbeatMs: process.env.AGENT_HEARTBEAT_MS,
42
+ });
19
43
  const files = new FileManager(process.env.ALLOWED_ROOTS);
20
44
  const processes = new ProcessManager();
21
- const desktop = new DesktopManager({ enabled: desktopEnabled });
45
+ const desktop = new DesktopManager({
46
+ enabled: desktopEnabled,
47
+ adapter: desktopAdapter,
48
+ controlMaxQueued: process.env.DESKTOP_CONTROL_MAX_QUEUED,
49
+ controlQueueTimeoutMs: process.env.AGENT_QUEUE_TIMEOUT_MS,
50
+ });
22
51
  const recentCalls = [];
23
52
  let reauthorizationRequired = false;
53
+ let activeSocket = null;
54
+ let reconnectTimer = null;
55
+ let heartbeatTimer = null;
56
+ let stopping = false;
57
+
58
+ function currentWorkSummary() {
59
+ const terminal = terminals.observability();
60
+ const terminalExec = scheduler.snapshot().terminalExec || {};
61
+ return {
62
+ activeSessions: terminal.sessions.filter((item) => item.status === "running").length,
63
+ activeBatchJobs: terminal.activeExecJobs,
64
+ queuedBatchJobs: terminal.queuedJobs,
65
+ activeTerminalExecs: terminalExec.active,
66
+ queuedTerminalExecs: terminalExec.queued,
67
+ };
68
+ }
69
+
70
+ const lifecycle = new AgentLifecycle({ workSummary: currentWorkSummary });
24
71
 
25
72
  if (!relayUrl || !agentToken) {
26
73
  console.error("RELAY_URL and AGENT_TOKEN are required");
@@ -30,8 +77,8 @@ if (!relayUrl || !agentToken) {
30
77
  const wsUrl = relayUrl.replace(/^http/, "ws").replace(/\/$/, "") +
31
78
  `/agent?agentId=${encodeURIComponent(agentId)}`;
32
79
 
33
- function serializeResponse(requestId, payload) {
34
- const response = JSON.stringify({ requestId, payload });
80
+ function serializeResponse(requestId, payload, meta) {
81
+ const response = JSON.stringify({ requestId, payload, ...(meta ? { meta } : {}) });
35
82
  if (Buffer.byteLength(response, "utf8") <= MAX_RESPONSE_BYTES) return response;
36
83
  return JSON.stringify({
37
84
  requestId,
@@ -45,8 +92,11 @@ function serializeResponse(requestId, payload) {
45
92
 
46
93
  async function handlePayload(payload) {
47
94
  if (!payload || typeof payload !== "object") return payload;
95
+ const action = String(payload.action ?? "");
96
+ const lifecycleGate = lifecycle.guard(action);
97
+ if (lifecycleGate) return lifecycleGate;
48
98
 
49
- switch (payload.action) {
99
+ switch (action) {
50
100
  case "ping":
51
101
  return { ok: true, action: "pong", at: new Date().toISOString(), agentId, agentName };
52
102
 
@@ -56,21 +106,50 @@ async function handlePayload(payload) {
56
106
  agentName,
57
107
  terminalEnabled,
58
108
  terminalBatch: terminals.getBatchConfig(),
109
+ concurrency: scheduler.snapshot(),
59
110
  desktop: desktop.getConfig(),
60
111
  allowedRoots: files.getRoots(),
61
- reconnectMs,
112
+ reconnectMs: connectionState.baseReconnectMs,
113
+ connection: { ...connectionState.snapshot(), processId: process.pid },
114
+ lifecycle: lifecycle.snapshot(),
115
+ terminalContinuity: {
116
+ processId: process.pid,
117
+ activeSessions: terminals.list().filter((session) => session.status === "running").length,
118
+ },
119
+ protocol: {
120
+ protocolVersion: agentHello.protocolVersion,
121
+ agentVersion: agentHello.agentVersion,
122
+ platform: agentHello.platform,
123
+ arch: agentHello.arch,
124
+ capabilities: agentHello.capabilities,
125
+ },
62
126
  });
63
127
  case "agent.recentCalls":
64
128
  return recentCalls.slice(-(Math.min(Math.max(Number(payload.limit) || 50, 1), 100)));
129
+ case "agent.lifecycle.status":
130
+ return { ok: true, lifecycle: lifecycle.snapshot() };
131
+ case "agent.lifecycle.drain":
132
+ return lifecycle.drain();
133
+ case "agent.lifecycle.resume":
134
+ return lifecycle.resume();
135
+ case "agent.lifecycle.restart": {
136
+ const result = lifecycle.requestRestart();
137
+ if (result.ok) setTimeout(restartAgentProcess, 150);
138
+ return result;
139
+ }
65
140
 
66
141
  case "fs.stat":
67
142
  return files.stat(payload.path);
68
143
  case "fs.list":
69
- return files.list(payload.path, payload.depth);
144
+ return files.list(payload.path, payload.depth, payload.offset, payload.limit, payload.maxBytes);
70
145
  case "fs.read":
71
146
  return files.read(payload.path, payload.offset, payload.length, payload.maxBytes);
72
147
  case "fs.readMany":
73
148
  return files.readMany(payload.paths, payload.maxTotalBytes);
149
+ case "fs.batch":
150
+ return files.batch(payload.operations, payload.maxTotalBytes);
151
+ case "fs.artifact":
152
+ return files.exportArtifact(payload.path, payload.maxBytes);
74
153
  case "fs.write":
75
154
  return files.write(payload.path, payload.content, payload.mode);
76
155
  case "fs.edit":
@@ -93,17 +172,27 @@ async function handlePayload(payload) {
93
172
 
94
173
  case "desktop.screenshot":
95
174
  return desktop.screenshot(payload);
175
+ case "desktop.clipboard.read":
176
+ return desktop.clipboardRead(payload);
177
+ case "desktop.clipboard.write":
178
+ return desktop.clipboardWrite(payload);
179
+ case "desktop.window.list":
180
+ return desktop.listWindows(payload);
181
+ case "desktop.window.focus":
182
+ return desktop.focusWindow(payload);
96
183
  case "desktop.mouse.click":
97
184
  return desktop.mouseClick(payload);
98
185
  case "desktop.keyboard.input":
99
186
  return desktop.keyboardInput(payload);
187
+ case "desktop.step":
188
+ return desktop.step(payload);
100
189
 
101
190
  case "terminal.exec":
102
191
  if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
103
192
  return terminals.exec(payload.command, payload.cwd, payload.timeoutMs);
104
193
  case "terminal.batch.start":
105
194
  if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
106
- return terminals.batchStart(payload.jobs, { cwd: payload.cwd, timeoutMs: payload.timeoutMs, concurrency: payload.concurrency });
195
+ return terminals.batchStart(payload.jobs, { cwd: payload.cwd, timeoutMs: payload.timeoutMs, concurrency: payload.concurrency, observability: payload.observability });
107
196
  case "terminal.batch.status":
108
197
  if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
109
198
  return terminals.batchStatus(payload.batchId);
@@ -115,10 +204,10 @@ async function handlePayload(payload) {
115
204
  return terminals.batchCancel(payload.batchId);
116
205
  case "terminal.start":
117
206
  if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
118
- return terminals.start(payload.command, payload.cwd);
207
+ return terminals.start(payload.command, payload.cwd, payload.observability);
119
208
  case "terminal.shell.start":
120
209
  if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
121
- return terminals.startShell(payload.cwd);
210
+ return terminals.startShell(payload.cwd, payload.observability);
122
211
  case "terminal.read":
123
212
  if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
124
213
  return terminals.read(payload.sessionId, payload.afterSeq, payload.maxChars);
@@ -131,32 +220,129 @@ async function handlePayload(payload) {
131
220
  case "terminal.list":
132
221
  if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
133
222
  return terminals.list();
223
+ case "terminal.observability":
224
+ if (!terminalEnabled) return { ok: false, error: "terminal_disabled" };
225
+ return terminals.observability();
134
226
 
135
227
  default:
136
228
  return { ok: false, error: "unknown_action" };
137
229
  }
138
230
  }
139
231
 
140
- function requireReauthorization() {
232
+ function clearReconnectTimer() {
233
+ if (!reconnectTimer) return;
234
+ clearTimeout(reconnectTimer);
235
+ reconnectTimer = null;
236
+ }
237
+
238
+ function stopHeartbeat() {
239
+ if (!heartbeatTimer) return;
240
+ clearInterval(heartbeatTimer);
241
+ heartbeatTimer = null;
242
+ }
243
+
244
+ function sendHeartbeat(socket) {
245
+ if (socket !== activeSocket || socket.readyState !== WebSocket.OPEN) return;
246
+ const now = Date.now();
247
+ try {
248
+ socket.send(JSON.stringify({
249
+ control: "agent_heartbeat",
250
+ at: now,
251
+ agentId,
252
+ processId: process.pid,
253
+ heartbeatMs: connectionState.heartbeatMs,
254
+ lifecycle: lifecycle.snapshot(),
255
+ health: {
256
+ reconnectCount: connectionState.reconnectCount,
257
+ reconnectAttempt: connectionState.reconnectAttempt,
258
+ lastDisconnectReason: connectionState.lastDisconnectReason,
259
+ queues: scheduler.snapshot(),
260
+ },
261
+ }));
262
+ connectionState.markHeartbeat(now);
263
+ } catch {}
264
+ }
265
+
266
+ function startHeartbeat(socket) {
267
+ stopHeartbeat();
268
+ sendHeartbeat(socket);
269
+ heartbeatTimer = setInterval(() => sendHeartbeat(socket), connectionState.heartbeatMs);
270
+ }
271
+
272
+ function sendSocketResponse(socket, requestId, payload, meta) {
273
+ if (socket !== activeSocket || socket.readyState !== WebSocket.OPEN) return;
274
+ try { socket.send(serializeResponse(requestId, payload, meta)); } catch {}
275
+ }
276
+
277
+ function scheduleReconnect(code, reason) {
278
+ if (stopping || reauthorizationRequired) return;
279
+ connectionState.markDisconnected(code, reason);
280
+ const delay = connectionState.nextDelay();
281
+ connectionState.scheduleReconnect(delay);
282
+ clearReconnectTimer();
283
+ console.log(`Reconnect scheduled in ${delay}ms`);
284
+ reconnectTimer = setTimeout(() => {
285
+ reconnectTimer = null;
286
+ connect();
287
+ }, delay);
288
+ }
289
+
290
+
291
+ function requireProtocolUpdate(details = {}) {
292
+ if (stopping) return;
293
+ stopping = true;
294
+ connectionState.markStopping("protocol_incompatible");
295
+ clearReconnectTimer();
296
+ stopHeartbeat();
297
+ const socket = activeSocket;
298
+ activeSocket = null;
299
+ try { socket?.terminate(); } catch {}
300
+ desktop.close();
301
+ const expected = details.expectedProtocolVersion ?? "current";
302
+ const received = details.receivedProtocolVersion ?? agentHello.protocolVersion;
303
+ console.error(`Agent protocol is incompatible (local=${received}, relay=${expected}). Update Chat Relay, then reconnect.`);
304
+ setImmediate(() => process.exit(3));
305
+ }
306
+
307
+ function requireReauthorization(reason = "credential_revoked") {
141
308
  if (reauthorizationRequired) return;
142
309
  reauthorizationRequired = true;
143
- process.exitCode = 2;
310
+ connectionState.markReauthorization(reason);
311
+ clearReconnectTimer();
312
+ stopHeartbeat();
313
+ const socket = activeSocket;
314
+ activeSocket = null;
315
+ try { socket?.terminate(); } catch {}
316
+ desktop.close();
144
317
  console.error("Agent credential was revoked or rejected.");
145
318
  console.error('Recovery: run "chat-relay login --force", then "chat-relay remote".');
319
+ setImmediate(() => process.exit(2));
146
320
  }
147
321
 
148
322
  function connect() {
149
- if (reauthorizationRequired) return;
323
+ if (stopping || reauthorizationRequired) return;
324
+ clearReconnectTimer();
325
+ connectionState.markConnecting();
150
326
  console.log(`Connecting to ${wsUrl}`);
151
327
  console.log(`Terminal access: ${terminalEnabled ? "enabled" : "disabled"}`);
152
- console.log("Desktop access: " + (desktopEnabled ? "enabled" : "disabled"));
328
+ const desktopConfig = desktop.getConfig();
329
+ console.log("Desktop access: " + (!desktopEnabled ? "disabled" : desktopConfig.supported ? "enabled" : `unsupported (${desktopConfig.platform})`));
330
+
153
331
  const socket = new WebSocket(wsUrl, {
154
332
  headers: { Authorization: `Bearer ${agentToken}` },
155
333
  });
334
+ activeSocket = socket;
156
335
 
157
- socket.on("open", () => console.log("Agent connected"));
336
+ socket.on("open", () => {
337
+ if (socket !== activeSocket) return;
338
+ socket.send(JSON.stringify(agentHello));
339
+ connectionState.markConnected();
340
+ startHeartbeat(socket);
341
+ console.log("Agent connected");
342
+ });
158
343
 
159
344
  socket.on("message", async (raw) => {
345
+ if (socket !== activeSocket) return;
160
346
  let message;
161
347
  try {
162
348
  message = JSON.parse(raw.toString());
@@ -165,8 +351,11 @@ function connect() {
165
351
  }
166
352
 
167
353
  if (message?.control === "credential_revoked") {
168
- requireReauthorization();
169
- try { socket.close(4001, "credential_revoked"); } catch {}
354
+ requireReauthorization("credential_revoked");
355
+ return;
356
+ }
357
+ if (message?.control === "protocol_incompatible") {
358
+ requireProtocolUpdate(message);
170
359
  return;
171
360
  }
172
361
 
@@ -176,53 +365,111 @@ function connect() {
176
365
  const action = message.payload && typeof message.payload === "object"
177
366
  ? String(message.payload.action ?? "unknown")
178
367
  : "unknown";
368
+ let scheduleMeta = { lane: "unknown", queueWaitMs: 0, queueDepthAtStart: 0, activeAtStart: 0 };
369
+ let handlerDurationMs = 0;
179
370
  try {
180
- const result = await handlePayload(message.payload);
371
+ const result = await scheduler.run(action, async (meta = {}) => {
372
+ scheduleMeta = { ...scheduleMeta, ...meta };
373
+ const handlerStartedAt = Date.now();
374
+ try {
375
+ return await handlePayload(message.payload);
376
+ } finally {
377
+ handlerDurationMs = Math.max(0, Date.now() - handlerStartedAt);
378
+ }
379
+ });
181
380
  recentCalls.push({
182
381
  at: new Date().toISOString(),
183
382
  action,
184
383
  durationMs: Date.now() - startedAt,
384
+ queueWaitMs: scheduleMeta.queueWaitMs,
385
+ handlerDurationMs,
386
+ lane: scheduleMeta.lane,
185
387
  ok: !(result && typeof result === "object" && result.ok === false),
186
388
  });
187
389
  if (recentCalls.length > 100) recentCalls.shift();
188
- socket.send(serializeResponse(message.requestId, result));
390
+ sendSocketResponse(socket, message.requestId, result, {
391
+ agentQueueWaitMs: scheduleMeta.queueWaitMs,
392
+ agentHandlerMs: handlerDurationMs,
393
+ lane: scheduleMeta.lane,
394
+ });
189
395
  } catch (error) {
190
396
  recentCalls.push({
191
397
  at: new Date().toISOString(),
192
398
  action,
193
399
  durationMs: Date.now() - startedAt,
400
+ queueWaitMs: scheduleMeta.queueWaitMs,
401
+ handlerDurationMs,
402
+ lane: scheduleMeta.lane,
194
403
  ok: false,
195
404
  error: error instanceof Error ? error.message : "agent_error",
196
405
  });
197
406
  if (recentCalls.length > 100) recentCalls.shift();
198
- socket.send(serializeResponse(message.requestId, {
407
+ sendSocketResponse(socket, message.requestId, {
199
408
  ok: false,
200
409
  error: error instanceof Error ? error.message : "agent_error",
201
- }));
410
+ });
202
411
  }
203
412
  });
204
413
 
205
414
  socket.on("unexpected-response", (_request, response) => {
206
415
  if (response.statusCode === 401 || response.statusCode === 403) {
207
416
  response.resume();
208
- requireReauthorization();
209
- socket.terminate();
417
+ requireReauthorization("credential_rejected");
210
418
  }
211
419
  });
212
420
 
213
421
  socket.on("close", (code, reason) => {
422
+ if (socket !== activeSocket) return;
423
+ activeSocket = null;
424
+ stopHeartbeat();
214
425
  const reasonText = reason.toString();
215
426
  console.log("Agent disconnected (" + code + ") " + reasonText);
216
427
  if (code === 4001 || reasonText === "credential_revoked") {
217
- requireReauthorization();
428
+ requireReauthorization("credential_revoked");
218
429
  return;
219
430
  }
220
- if (!reauthorizationRequired) setTimeout(connect, reconnectMs);
431
+ if (code === 4002 || reasonText === "protocol_incompatible") {
432
+ requireProtocolUpdate({ receivedProtocolVersion: agentHello.protocolVersion });
433
+ return;
434
+ }
435
+ scheduleReconnect(code, reasonText || "socket_closed");
221
436
  });
222
437
 
223
438
  socket.on("error", (error) => {
224
- if (!reauthorizationRequired) console.error("WebSocket error:", error.message);
439
+ if (socket === activeSocket && !stopping && !reauthorizationRequired) {
440
+ console.error("WebSocket error:", error.message);
441
+ }
225
442
  });
226
443
  }
227
444
 
445
+ function restartAgentProcess() {
446
+ if (stopping) return;
447
+ stopping = true;
448
+ connectionState.markStopping("restart_requested");
449
+ clearReconnectTimer();
450
+ stopHeartbeat();
451
+ const socket = activeSocket;
452
+ activeSocket = null;
453
+ try { socket?.close(1012, "restart_requested"); } catch {}
454
+ desktop.close();
455
+ setTimeout(() => process.exit(AGENT_RESTART_EXIT_CODE), 50);
456
+ }
457
+
458
+ function shutdown(reason) {
459
+ if (stopping) return;
460
+ stopping = true;
461
+ connectionState.markStopping(reason);
462
+ clearReconnectTimer();
463
+ stopHeartbeat();
464
+ const socket = activeSocket;
465
+ activeSocket = null;
466
+ try { socket?.close(1000, "client_shutdown"); } catch {}
467
+ desktop.close();
468
+ setTimeout(() => process.exit(0), 50);
469
+ }
470
+
471
+ process.once("SIGINT", () => shutdown("SIGINT"));
472
+ process.once("SIGTERM", () => shutdown("SIGTERM"));
473
+ process.once("exit", () => desktop.close());
474
+
228
475
  connect();
@@ -0,0 +1,67 @@
1
+ import fs from "node:fs";
2
+
3
+ export const AGENT_PROTOCOL_VERSION = 1;
4
+
5
+ let cachedVersion = null;
6
+
7
+ export function getAgentVersion() {
8
+ if (cachedVersion) return cachedVersion;
9
+ try {
10
+ const packageUrl = new URL("../package.json", import.meta.url);
11
+ const parsed = JSON.parse(fs.readFileSync(packageUrl, "utf8"));
12
+ cachedVersion = typeof parsed.version === "string" && parsed.version ? parsed.version : "0.0.0";
13
+ } catch {
14
+ cachedVersion = "0.0.0";
15
+ }
16
+ return cachedVersion;
17
+ }
18
+
19
+ export function getAgentCapabilities(options = {}) {
20
+ const terminalEnabled = options.terminalEnabled === true;
21
+ const desktopEnabled = options.desktopEnabled === true;
22
+ const platform = String(options.platform || process.platform);
23
+
24
+ const capabilities = [
25
+ "transport.heartbeat",
26
+ "filesystem.stat",
27
+ "filesystem.list",
28
+ "filesystem.read",
29
+ "filesystem.readMany",
30
+ "filesystem.batch",
31
+ "filesystem.artifact",
32
+ "filesystem.write",
33
+ "filesystem.edit",
34
+ "filesystem.mkdir",
35
+ "filesystem.move",
36
+ "filesystem.delete",
37
+ "filesystem.search",
38
+ "process.list",
39
+ "process.kill",
40
+ ];
41
+
42
+ if (terminalEnabled) {
43
+ capabilities.push("terminal.exec", "terminal.sessions", "terminal.batch");
44
+ }
45
+
46
+ if (desktopEnabled && platform === "win32") {
47
+ capabilities.push("desktop.screenshot", "desktop.control", "desktop.step");
48
+ }
49
+
50
+ return capabilities;
51
+ }
52
+
53
+ export function buildAgentHello(options = {}) {
54
+ const platform = String(options.platform || process.platform);
55
+ return {
56
+ control: "agent_hello",
57
+ protocolVersion: Number(options.protocolVersion ?? AGENT_PROTOCOL_VERSION),
58
+ agentVersion: String(options.agentVersion || getAgentVersion()),
59
+ platform,
60
+ arch: String(options.arch || process.arch),
61
+ capabilities: getAgentCapabilities({
62
+ terminalEnabled: options.terminalEnabled === true,
63
+ desktopEnabled: options.desktopEnabled === true,
64
+ platform,
65
+ }),
66
+ };
67
+ }
@@ -119,22 +119,24 @@ export class TerminalManager {
119
119
  });
120
120
  }
121
121
 
122
- start(command, cwd) {
122
+ start(command, cwd, observability) {
123
123
  validateCommand(command);
124
124
  return this.#spawnSession({
125
125
  type: "command",
126
126
  command,
127
127
  cwd: safeCwd(cwd),
128
128
  args: ["-NoLogo", "-NoProfile", "-Command", command],
129
+ observability,
129
130
  });
130
131
  }
131
132
 
132
- startShell(cwd) {
133
+ startShell(cwd, observability) {
133
134
  return this.#spawnSession({
134
135
  type: "shell",
135
136
  command: "powershell",
136
137
  cwd: safeCwd(cwd),
137
138
  args: ["-NoLogo", "-NoProfile", "-NoExit", "-Command", "-"],
139
+ observability,
138
140
  });
139
141
  }
140
142
 
@@ -143,6 +145,45 @@ export class TerminalManager {
143
145
  return [...this.sessions.values()].map((session) => this.#summary(session));
144
146
  }
145
147
 
148
+ observability() {
149
+ this.#cleanup();
150
+ return {
151
+ ok: true,
152
+ sessions: [...this.sessions.values()].map((session) => ({
153
+ sessionId: session.id,
154
+ type: session.type,
155
+ status: session.status,
156
+ exitCode: session.exitCode,
157
+ startedAt: session.startedAt,
158
+ endedAt: session.endedAt,
159
+ ...(session.observability?.userId ? { userId: session.observability.userId } : {}),
160
+ ...(session.observability?.activityId ? { activityId: session.observability.activityId } : {}),
161
+ ...(session.observability?.toolCallId ? { toolCallId: session.observability.toolCallId } : {}),
162
+ })),
163
+ batches: [...this.batches.values()].map((batch) => ({
164
+ batchId: batch.batchId,
165
+ createdAt: batch.createdAt,
166
+ endedAt: batch.endedAt,
167
+ cancelled: batch.cancelled,
168
+ ...(batch.observability?.userId ? { userId: batch.observability.userId } : {}),
169
+ ...(batch.observability?.activityId ? { activityId: batch.observability.activityId } : {}),
170
+ ...(batch.observability?.toolCallId ? { toolCallId: batch.observability.toolCallId } : {}),
171
+ counts: this.#batchSummary(batch).counts,
172
+ jobs: batch.jobs.map((job) => ({
173
+ jobId: job.jobId,
174
+ status: job.status,
175
+ queuedAt: job.queuedAt,
176
+ startedAt: job.startedAt,
177
+ endedAt: job.endedAt,
178
+ durationMs: job.durationMs,
179
+ exitCode: job.exitCode,
180
+ })),
181
+ })),
182
+ activeExecJobs: this.activeBatchJobs,
183
+ queuedJobs: this.batchQueue.length,
184
+ };
185
+ }
186
+
146
187
  read(sessionId, afterSeq = 0, maxChars = MAX_READ_CHARS) {
147
188
  this.#cleanup();
148
189
  const session = this.#get(sessionId);
@@ -216,6 +257,7 @@ export class TerminalManager {
216
257
  endedAt: null,
217
258
  requestedConcurrency,
218
259
  cancelled: false,
260
+ observability: options.observability ?? null,
219
261
  jobs: jobs.map((input, index) => {
220
262
  const item = typeof input === "string" ? { command: input } : input;
221
263
  validateCommand(item?.command);
@@ -311,7 +353,7 @@ export class TerminalManager {
311
353
  };
312
354
  }
313
355
 
314
- #spawnSession({ type, command, cwd, args }) {
356
+ #spawnSession({ type, command, cwd, args, observability }) {
315
357
  this.#cleanup();
316
358
  const running = [...this.sessions.values()].filter((session) => session.status === "running").length;
317
359
  if (running >= MAX_SESSIONS) throw new Error("too_many_sessions");
@@ -337,6 +379,7 @@ export class TerminalManager {
337
379
  chunks: [],
338
380
  nextSeq: 1,
339
381
  totalChars: 0,
382
+ observability: observability ?? null,
340
383
  };
341
384
 
342
385
  this.sessions.set(session.id, session);