clawspec 1.0.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.
Files changed (71) hide show
  1. package/README.md +908 -0
  2. package/README.zh-CN.md +914 -0
  3. package/index.ts +3 -0
  4. package/openclaw.plugin.json +129 -0
  5. package/package.json +52 -0
  6. package/skills/openspec-apply-change.md +146 -0
  7. package/skills/openspec-explore.md +75 -0
  8. package/skills/openspec-propose.md +102 -0
  9. package/src/acp/client.ts +693 -0
  10. package/src/config.ts +220 -0
  11. package/src/control/keywords.ts +72 -0
  12. package/src/dependencies/acpx.ts +221 -0
  13. package/src/dependencies/openspec.ts +148 -0
  14. package/src/execution/session.ts +56 -0
  15. package/src/execution/state.ts +125 -0
  16. package/src/index.ts +179 -0
  17. package/src/memory/store.ts +118 -0
  18. package/src/openspec/cli.ts +279 -0
  19. package/src/openspec/tasks.ts +40 -0
  20. package/src/orchestrator/helpers.ts +312 -0
  21. package/src/orchestrator/service.ts +2971 -0
  22. package/src/planning/journal.ts +118 -0
  23. package/src/rollback/store.ts +173 -0
  24. package/src/state/locks.ts +133 -0
  25. package/src/state/store.ts +527 -0
  26. package/src/types.ts +301 -0
  27. package/src/utils/args.ts +88 -0
  28. package/src/utils/channel-key.ts +66 -0
  29. package/src/utils/env-path.ts +31 -0
  30. package/src/utils/fs.ts +218 -0
  31. package/src/utils/markdown.ts +136 -0
  32. package/src/utils/messages.ts +5 -0
  33. package/src/utils/paths.ts +127 -0
  34. package/src/utils/shell-command.ts +227 -0
  35. package/src/utils/slug.ts +50 -0
  36. package/src/watchers/manager.ts +3042 -0
  37. package/src/watchers/notifier.ts +69 -0
  38. package/src/worker/prompts.ts +484 -0
  39. package/src/worker/skills.ts +52 -0
  40. package/src/workspace/store.ts +140 -0
  41. package/test/acp-client.test.ts +234 -0
  42. package/test/acpx-dependency.test.ts +112 -0
  43. package/test/assistant-journal.test.ts +136 -0
  44. package/test/command-surface.test.ts +23 -0
  45. package/test/config.test.ts +77 -0
  46. package/test/detach-attach.test.ts +98 -0
  47. package/test/file-lock.test.ts +78 -0
  48. package/test/fs-utils.test.ts +22 -0
  49. package/test/helpers/harness.ts +241 -0
  50. package/test/helpers.test.ts +108 -0
  51. package/test/keywords.test.ts +80 -0
  52. package/test/notifier.test.ts +29 -0
  53. package/test/openspec-dependency.test.ts +67 -0
  54. package/test/pause-cancel.test.ts +55 -0
  55. package/test/planning-journal.test.ts +69 -0
  56. package/test/plugin-registration.test.ts +35 -0
  57. package/test/project-memory.test.ts +42 -0
  58. package/test/proposal.test.ts +24 -0
  59. package/test/queue-planning.test.ts +247 -0
  60. package/test/queue-work.test.ts +110 -0
  61. package/test/recovery.test.ts +576 -0
  62. package/test/service-archive.test.ts +82 -0
  63. package/test/shell-command.test.ts +48 -0
  64. package/test/state-store.test.ts +74 -0
  65. package/test/tasks-and-checkpoint.test.ts +60 -0
  66. package/test/use-project.test.ts +19 -0
  67. package/test/watcher-planning.test.ts +504 -0
  68. package/test/watcher-work.test.ts +1741 -0
  69. package/test/worker-command.test.ts +66 -0
  70. package/test/worker-skills.test.ts +12 -0
  71. package/tsconfig.json +25 -0
@@ -0,0 +1,693 @@
1
+ import { createInterface } from "node:readline";
2
+ import type { ChildProcessWithoutNullStreams } from "node:child_process";
3
+ import type { PluginLogger } from "openclaw/plugin-sdk";
4
+ import { runShellCommand, spawnShellCommand, terminateChildProcess } from "../utils/shell-command.ts";
5
+
6
+ export type AcpWorkerEvent =
7
+ | {
8
+ type: "text_delta";
9
+ text: string;
10
+ tag?: string;
11
+ stream?: string;
12
+ }
13
+ | {
14
+ type: "tool_call";
15
+ text: string;
16
+ title: string;
17
+ tag?: string;
18
+ status?: string;
19
+ toolCallId?: string;
20
+ }
21
+ | {
22
+ type: "done";
23
+ }
24
+ | {
25
+ type: "error";
26
+ message: string;
27
+ code?: string;
28
+ retryable?: boolean;
29
+ };
30
+
31
+ export type AcpWorkerHandle = {
32
+ sessionKey: string;
33
+ backend: "acpx";
34
+ runtimeSessionName: string;
35
+ cwd: string;
36
+ agentId: string;
37
+ acpxRecordId?: string;
38
+ backendSessionId?: string;
39
+ agentSessionId?: string;
40
+ };
41
+
42
+ export type AcpWorkerStatus = {
43
+ summary: string;
44
+ acpxRecordId?: string;
45
+ backendSessionId?: string;
46
+ agentSessionId?: string;
47
+ details?: Record<string, unknown>;
48
+ };
49
+
50
+ type AcpWorkerClientOptions = {
51
+ agentId: string;
52
+ logger: PluginLogger;
53
+ command: string;
54
+ env?: NodeJS.ProcessEnv;
55
+ permissionMode?: "approve-all" | "approve-reads" | "deny-all";
56
+ queueOwnerTtlSeconds?: number;
57
+ };
58
+
59
+ type EnsureSessionParams = {
60
+ sessionKey: string;
61
+ cwd: string;
62
+ agentId?: string;
63
+ };
64
+
65
+ type RunTurnParams = EnsureSessionParams & {
66
+ text: string;
67
+ signal?: AbortSignal;
68
+ onReady?: (params: {
69
+ backendId: string;
70
+ handle: AcpWorkerHandle;
71
+ }) => Promise<void> | void;
72
+ onEvent?: (event: AcpWorkerEvent) => Promise<void> | void;
73
+ };
74
+
75
+ type SessionDescriptor = {
76
+ sessionKey: string;
77
+ cwd: string;
78
+ agentId: string;
79
+ };
80
+
81
+ type ActiveSessionProcess = {
82
+ sessionKey: string;
83
+ child: ChildProcessWithoutNullStreams;
84
+ cwd: string;
85
+ agentId: string;
86
+ startedAt: string;
87
+ };
88
+
89
+ type SessionExitState = {
90
+ summary: string;
91
+ details: Record<string, unknown>;
92
+ };
93
+
94
+ const DEFAULT_QUEUE_OWNER_TTL_SECONDS = 30;
95
+ const DEFAULT_PERMISSION_MODE = "approve-all";
96
+
97
+ export class AcpWorkerClient {
98
+ readonly agentId: string;
99
+ readonly logger: PluginLogger;
100
+ readonly command: string;
101
+ readonly env?: NodeJS.ProcessEnv;
102
+ readonly permissionMode: "approve-all" | "approve-reads" | "deny-all";
103
+ readonly queueOwnerTtlSeconds: number;
104
+ readonly handles = new Map<string, AcpWorkerHandle>();
105
+ readonly sessionDescriptors = new Map<string, SessionDescriptor>();
106
+ readonly activeProcesses = new Map<string, ActiveSessionProcess>();
107
+ readonly lastExitStates = new Map<string, AcpWorkerStatus>();
108
+
109
+ constructor(options: AcpWorkerClientOptions) {
110
+ this.agentId = options.agentId;
111
+ this.logger = options.logger;
112
+ this.command = options.command;
113
+ this.env = options.env;
114
+ this.permissionMode = options.permissionMode ?? DEFAULT_PERMISSION_MODE;
115
+ this.queueOwnerTtlSeconds = options.queueOwnerTtlSeconds ?? DEFAULT_QUEUE_OWNER_TTL_SECONDS;
116
+ }
117
+
118
+ async ensureSession(params: EnsureSessionParams): Promise<{
119
+ backendId: string;
120
+ handle: AcpWorkerHandle;
121
+ }> {
122
+ const descriptor = {
123
+ sessionKey: params.sessionKey,
124
+ cwd: params.cwd,
125
+ agentId: params.agentId ?? this.agentId,
126
+ };
127
+ this.sessionDescriptors.set(params.sessionKey, descriptor);
128
+
129
+ let events = await this.runControlCommand({
130
+ agentId: descriptor.agentId,
131
+ cwd: descriptor.cwd,
132
+ command: ["sessions", "ensure", "--name", descriptor.sessionKey],
133
+ allowErrorCodes: ["NO_SESSION"],
134
+ });
135
+
136
+ if (events.some((event) => toAcpxErrorEvent(event)?.code === "NO_SESSION")) {
137
+ events = await this.runControlCommand({
138
+ agentId: descriptor.agentId,
139
+ cwd: descriptor.cwd,
140
+ command: ["sessions", "new", "--name", descriptor.sessionKey],
141
+ });
142
+ }
143
+
144
+ const identifiers = extractSessionIdentifiers(events);
145
+ const handle: AcpWorkerHandle = {
146
+ sessionKey: descriptor.sessionKey,
147
+ backend: "acpx",
148
+ runtimeSessionName: descriptor.sessionKey,
149
+ cwd: descriptor.cwd,
150
+ agentId: descriptor.agentId,
151
+ ...identifiers,
152
+ };
153
+ this.handles.set(params.sessionKey, handle);
154
+ return {
155
+ backendId: "acpx",
156
+ handle,
157
+ };
158
+ }
159
+
160
+ async runTurn(params: RunTurnParams): Promise<{
161
+ backendId: string;
162
+ handle: AcpWorkerHandle;
163
+ }> {
164
+ const ensured = await this.ensureSession(params);
165
+ const descriptor = this.sessionDescriptors.get(params.sessionKey) ?? {
166
+ sessionKey: params.sessionKey,
167
+ cwd: params.cwd,
168
+ agentId: params.agentId ?? this.agentId,
169
+ };
170
+
171
+ const child = spawnShellCommand({
172
+ command: this.command,
173
+ args: this.buildPromptArgs({
174
+ agentId: descriptor.agentId,
175
+ cwd: descriptor.cwd,
176
+ sessionKey: descriptor.sessionKey,
177
+ }),
178
+ cwd: descriptor.cwd,
179
+ env: this.env,
180
+ });
181
+ child.stdout.setEncoding("utf8");
182
+ child.stderr.setEncoding("utf8");
183
+ child.stdin.end(params.text);
184
+
185
+ const startedAt = new Date().toISOString();
186
+ this.activeProcesses.set(params.sessionKey, {
187
+ sessionKey: params.sessionKey,
188
+ child,
189
+ cwd: descriptor.cwd,
190
+ agentId: descriptor.agentId,
191
+ startedAt,
192
+ });
193
+ this.lastExitStates.delete(params.sessionKey);
194
+ this.logger.debug?.(
195
+ `[clawspec] acpx worker spawned: session=${params.sessionKey} agent=${descriptor.agentId} pid=${child.pid ?? "unknown"}`,
196
+ );
197
+
198
+ await params.onReady?.(ensured);
199
+
200
+ let stderr = "";
201
+ let sawDone = false;
202
+ let sawError = false;
203
+ let abortCleanupDone = false;
204
+ const lines = createInterface({ input: child.stdout });
205
+ child.stderr.on("data", (chunk) => {
206
+ stderr += String(chunk);
207
+ });
208
+
209
+ const abortRun = async () => {
210
+ if (abortCleanupDone) {
211
+ return;
212
+ }
213
+ abortCleanupDone = true;
214
+ await this.cancelSession(params.sessionKey, "abort-signal").catch(() => undefined);
215
+ safeKill(child);
216
+ };
217
+ const onAbort = () => {
218
+ void abortRun();
219
+ };
220
+ if (params.signal?.aborted) {
221
+ await abortRun();
222
+ } else if (params.signal) {
223
+ params.signal.addEventListener("abort", onAbort, { once: true });
224
+ }
225
+
226
+ try {
227
+ for await (const line of lines) {
228
+ const event = parsePromptEventLine(line);
229
+ if (!event) {
230
+ continue;
231
+ }
232
+ await params.onEvent?.(event);
233
+ if (event.type === "done") {
234
+ sawDone = true;
235
+ } else if (event.type === "error") {
236
+ sawError = true;
237
+ throw new Error(event.code ? `${event.code}: ${event.message}` : event.message);
238
+ }
239
+ }
240
+
241
+ const exit = await waitForExit(child);
242
+ this.recordSessionExit(params.sessionKey, descriptor, child.pid, exit.code, exit.signal, stderr);
243
+ if (exit.error) {
244
+ throw exit.error;
245
+ }
246
+ if ((exit.code ?? 0) !== 0 && !sawError) {
247
+ throw new Error(formatAcpxExitMessage(stderr, exit.code));
248
+ }
249
+ if (!sawDone && !sawError) {
250
+ await params.onEvent?.({ type: "done" });
251
+ }
252
+ return ensured;
253
+ } finally {
254
+ this.activeProcesses.delete(params.sessionKey);
255
+ lines.close();
256
+ if (params.signal) {
257
+ params.signal.removeEventListener("abort", onAbort);
258
+ }
259
+ safeKill(child);
260
+ }
261
+ }
262
+
263
+ async getSessionStatus(
264
+ session:
265
+ | string
266
+ | {
267
+ sessionKey: string;
268
+ cwd?: string;
269
+ agentId?: string;
270
+ },
271
+ ): Promise<AcpWorkerStatus | undefined> {
272
+ const sessionKey = typeof session === "string" ? session : session.sessionKey;
273
+ const descriptor = this.resolveDescriptor(session);
274
+ const active = this.activeProcesses.get(sessionKey);
275
+ if (active && !active.child.killed) {
276
+ return {
277
+ summary: `status=alive pid=${active.child.pid ?? "unknown"}`,
278
+ details: {
279
+ status: "alive",
280
+ pid: active.child.pid ?? null,
281
+ startedAt: active.startedAt,
282
+ cwd: active.cwd,
283
+ agentId: active.agentId,
284
+ source: "clawspec-child",
285
+ },
286
+ };
287
+ }
288
+
289
+ if (!descriptor?.cwd) {
290
+ return this.lastExitStates.get(sessionKey);
291
+ }
292
+
293
+ try {
294
+ const events = await this.runControlCommand({
295
+ agentId: descriptor.agentId,
296
+ cwd: descriptor.cwd,
297
+ command: ["status", "--session", descriptor.sessionKey],
298
+ allowErrorCodes: ["NO_SESSION"],
299
+ });
300
+ const noSession = events.map((event) => toAcpxErrorEvent(event)).find((event) => event?.code === "NO_SESSION");
301
+ if (noSession) {
302
+ return this.lastExitStates.get(sessionKey) ?? {
303
+ summary: "status=dead no-session",
304
+ details: {
305
+ status: "dead",
306
+ summary: noSession.message,
307
+ },
308
+ };
309
+ }
310
+
311
+ const detail = events.find((event) => !toAcpxErrorEvent(event)) ?? events[0];
312
+ if (!detail) {
313
+ return this.lastExitStates.get(sessionKey) ?? {
314
+ summary: "acpx status unavailable",
315
+ details: { status: "unknown" },
316
+ };
317
+ }
318
+
319
+ const status = asTrimmedString(detail.status) || "unknown";
320
+ const acpxRecordId = asOptionalString(detail.acpxRecordId);
321
+ const acpxSessionId = asOptionalString(detail.acpxSessionId);
322
+ const agentSessionId = asOptionalString(detail.agentSessionId);
323
+ const pid = typeof detail.pid === "number" && Number.isFinite(detail.pid) ? detail.pid : null;
324
+ return {
325
+ summary: [
326
+ `status=${status}`,
327
+ acpxRecordId ? `acpxRecordId=${acpxRecordId}` : null,
328
+ acpxSessionId ? `acpxSessionId=${acpxSessionId}` : null,
329
+ pid != null ? `pid=${pid}` : null,
330
+ ].filter(Boolean).join(" "),
331
+ ...acpxRecordId ? { acpxRecordId } : {},
332
+ ...acpxSessionId ? { backendSessionId: acpxSessionId } : {},
333
+ ...agentSessionId ? { agentSessionId } : {},
334
+ details: detail,
335
+ };
336
+ } catch (error) {
337
+ this.logger.warn(
338
+ `[clawspec] ACP status probe failed for session ${sessionKey}: ${error instanceof Error ? error.message : String(error)}`,
339
+ );
340
+ return this.lastExitStates.get(sessionKey);
341
+ }
342
+ }
343
+
344
+ async cancelSession(sessionKey: string, reason = "cancelled by ClawSpec"): Promise<void> {
345
+ const descriptor = this.sessionDescriptors.get(sessionKey);
346
+ if (descriptor) {
347
+ try {
348
+ await this.runControlCommand({
349
+ agentId: descriptor.agentId,
350
+ cwd: descriptor.cwd,
351
+ command: ["cancel", "--session", sessionKey],
352
+ allowErrorCodes: ["NO_SESSION"],
353
+ });
354
+ } catch (error) {
355
+ this.logger.warn(
356
+ `[clawspec] ACP cancel failed for session ${sessionKey}: ${error instanceof Error ? error.message : String(error)}`,
357
+ );
358
+ }
359
+ }
360
+
361
+ const active = this.activeProcesses.get(sessionKey);
362
+ if (active) {
363
+ safeKill(active.child);
364
+ this.activeProcesses.delete(sessionKey);
365
+ this.recordSessionExit(sessionKey, active, active.child.pid, null, "SIGTERM", reason);
366
+ }
367
+ }
368
+
369
+ async closeSession(sessionKey: string, reason = "closed by ClawSpec"): Promise<void> {
370
+ const descriptor = this.sessionDescriptors.get(sessionKey);
371
+ if (descriptor) {
372
+ try {
373
+ await this.runControlCommand({
374
+ agentId: descriptor.agentId,
375
+ cwd: descriptor.cwd,
376
+ command: ["sessions", "close", sessionKey],
377
+ allowErrorCodes: ["NO_SESSION"],
378
+ });
379
+ } catch (error) {
380
+ this.logger.warn(
381
+ `[clawspec] ACP close failed for session ${sessionKey}: ${error instanceof Error ? error.message : String(error)}`,
382
+ );
383
+ }
384
+ }
385
+
386
+ const active = this.activeProcesses.get(sessionKey);
387
+ if (active) {
388
+ safeKill(active.child);
389
+ this.activeProcesses.delete(sessionKey);
390
+ this.recordSessionExit(sessionKey, active, active.child.pid, null, "SIGTERM", reason);
391
+ }
392
+
393
+ this.handles.delete(sessionKey);
394
+ this.sessionDescriptors.delete(sessionKey);
395
+ }
396
+
397
+ private buildControlArgs(params: {
398
+ agentId: string;
399
+ cwd: string;
400
+ command: string[];
401
+ }): string[] {
402
+ return [
403
+ "--format",
404
+ "json",
405
+ "--json-strict",
406
+ "--cwd",
407
+ params.cwd,
408
+ params.agentId,
409
+ ...params.command,
410
+ ];
411
+ }
412
+
413
+ private buildPromptArgs(params: {
414
+ agentId: string;
415
+ cwd: string;
416
+ sessionKey: string;
417
+ }): string[] {
418
+ return [
419
+ "--format",
420
+ "json",
421
+ "--json-strict",
422
+ "--cwd",
423
+ params.cwd,
424
+ ...buildPermissionArgs(this.permissionMode),
425
+ "--ttl",
426
+ String(this.queueOwnerTtlSeconds),
427
+ params.agentId,
428
+ "prompt",
429
+ "--session",
430
+ params.sessionKey,
431
+ "--file",
432
+ "-",
433
+ ];
434
+ }
435
+
436
+ private async runControlCommand(params: {
437
+ agentId: string;
438
+ cwd: string;
439
+ command: string[];
440
+ allowErrorCodes?: string[];
441
+ }): Promise<Array<Record<string, unknown>>> {
442
+ const result = await runShellCommand({
443
+ command: this.command,
444
+ args: this.buildControlArgs(params),
445
+ cwd: params.cwd,
446
+ env: this.env,
447
+ });
448
+
449
+ if (result.error) {
450
+ throw result.error;
451
+ }
452
+
453
+ const events = parseJsonLines(result.stdout);
454
+ const errorEvent = events.map((event) => toAcpxErrorEvent(event)).find(Boolean) ?? null;
455
+ if (errorEvent && !(params.allowErrorCodes ?? []).includes(errorEvent.code ?? "")) {
456
+ throw new Error(errorEvent.code ? `${errorEvent.code}: ${errorEvent.message}` : errorEvent.message);
457
+ }
458
+ if ((result.code ?? 0) !== 0 && !errorEvent) {
459
+ throw new Error(formatAcpxExitMessage(result.stderr, result.code));
460
+ }
461
+ return events;
462
+ }
463
+
464
+ private resolveDescriptor(
465
+ session:
466
+ | string
467
+ | {
468
+ sessionKey: string;
469
+ cwd?: string;
470
+ agentId?: string;
471
+ },
472
+ ): SessionDescriptor | undefined {
473
+ if (typeof session !== "string" && session.cwd) {
474
+ return {
475
+ sessionKey: session.sessionKey,
476
+ cwd: session.cwd,
477
+ agentId: session.agentId ?? this.agentId,
478
+ };
479
+ }
480
+ return this.sessionDescriptors.get(typeof session === "string" ? session : session.sessionKey);
481
+ }
482
+
483
+ private recordSessionExit(
484
+ sessionKey: string,
485
+ descriptor: { cwd: string; agentId: string },
486
+ pid: number | undefined,
487
+ code: number | null | undefined,
488
+ signal: NodeJS.Signals | null | undefined,
489
+ stderr: string,
490
+ ): void {
491
+ const status = "dead";
492
+ const summary = [
493
+ `status=${status}`,
494
+ pid != null ? `pid=${pid}` : null,
495
+ code != null ? `code=${code}` : null,
496
+ signal ? `signal=${signal}` : null,
497
+ stderr.trim() ? `summary=${stderr.trim().replace(/\s+/g, " ").slice(0, 160)}` : null,
498
+ ].filter(Boolean).join(" ");
499
+ this.lastExitStates.set(sessionKey, {
500
+ summary,
501
+ details: {
502
+ status,
503
+ pid: pid ?? null,
504
+ code: code ?? null,
505
+ signal: signal ?? null,
506
+ cwd: descriptor.cwd,
507
+ agentId: descriptor.agentId,
508
+ summary: stderr.trim() || undefined,
509
+ timestamp: new Date().toISOString(),
510
+ },
511
+ });
512
+ this.logger.debug?.(
513
+ `[clawspec] acpx worker exited: session=${sessionKey} pid=${pid ?? "unknown"} code=${code ?? "null"} signal=${signal ?? "null"}`,
514
+ );
515
+ }
516
+ }
517
+
518
+ function parseJsonLines(value: string): Array<Record<string, unknown>> {
519
+ const events: Array<Record<string, unknown>> = [];
520
+ for (const line of value.split(/\r?\n/)) {
521
+ const trimmed = line.trim();
522
+ if (!trimmed) {
523
+ continue;
524
+ }
525
+ try {
526
+ const parsed = JSON.parse(trimmed);
527
+ if (isRecord(parsed)) {
528
+ events.push(parsed);
529
+ }
530
+ } catch {
531
+ continue;
532
+ }
533
+ }
534
+ return events;
535
+ }
536
+
537
+ function parsePromptEventLine(line: string): AcpWorkerEvent | null {
538
+ const trimmed = line.trim();
539
+ if (!trimmed) {
540
+ return null;
541
+ }
542
+
543
+ let parsed: Record<string, unknown>;
544
+ try {
545
+ const raw = JSON.parse(trimmed);
546
+ if (!isRecord(raw)) {
547
+ return null;
548
+ }
549
+ parsed = raw;
550
+ } catch {
551
+ return { type: "text_delta", text: trimmed };
552
+ }
553
+
554
+ const error = toAcpxErrorEvent(parsed);
555
+ if (error) {
556
+ return error;
557
+ }
558
+
559
+ const type = asTrimmedString(parsed.type);
560
+ if (type === "done") {
561
+ return { type: "done" };
562
+ }
563
+
564
+ const toolTitle = asTrimmedString(parsed.title);
565
+ const toolStatus = asOptionalString(parsed.status);
566
+ const toolCallId = asOptionalString(parsed.toolCallId);
567
+ if (toolTitle || toolCallId) {
568
+ return {
569
+ type: "tool_call",
570
+ text: toolStatus ? `${toolTitle || "tool call"} (${toolStatus})` : (toolTitle || "tool call"),
571
+ title: toolTitle || "tool call",
572
+ ...toolStatus ? { status: toolStatus } : {},
573
+ ...toolCallId ? { toolCallId } : {},
574
+ };
575
+ }
576
+
577
+ const text = extractDisplayText(parsed);
578
+ if (text) {
579
+ return { type: "text_delta", text };
580
+ }
581
+ return null;
582
+ }
583
+
584
+ function toAcpxErrorEvent(value: Record<string, unknown>): AcpWorkerEvent | null {
585
+ if (asTrimmedString(value.type) !== "error") {
586
+ return null;
587
+ }
588
+ return {
589
+ type: "error",
590
+ message: asTrimmedString(value.message) || "acpx reported an error",
591
+ code: asOptionalString(value.code),
592
+ retryable: typeof value.retryable === "boolean" ? value.retryable : undefined,
593
+ };
594
+ }
595
+
596
+ function extractSessionIdentifiers(events: Array<Record<string, unknown>>): {
597
+ acpxRecordId?: string;
598
+ backendSessionId?: string;
599
+ agentSessionId?: string;
600
+ } {
601
+ const event = events.find((entry) =>
602
+ asOptionalString(entry.acpxRecordId)
603
+ || asOptionalString(entry.acpxSessionId)
604
+ || asOptionalString(entry.agentSessionId)
605
+ );
606
+ if (!event) {
607
+ return {};
608
+ }
609
+
610
+ return {
611
+ ...asOptionalString(event.acpxRecordId) ? { acpxRecordId: asOptionalString(event.acpxRecordId) } : {},
612
+ ...asOptionalString(event.acpxSessionId) ? { backendSessionId: asOptionalString(event.acpxSessionId) } : {},
613
+ ...asOptionalString(event.agentSessionId) ? { agentSessionId: asOptionalString(event.agentSessionId) } : {},
614
+ };
615
+ }
616
+
617
+ function extractDisplayText(parsed: Record<string, unknown>): string | undefined {
618
+ const directText = asOptionalString(parsed.text);
619
+ if (directText) {
620
+ return directText;
621
+ }
622
+
623
+ if (isRecord(parsed.content)) {
624
+ const contentText = asOptionalString(parsed.content.text);
625
+ if (contentText) {
626
+ return contentText;
627
+ }
628
+ }
629
+
630
+ const summary = asOptionalString(parsed.summary) ?? asOptionalString(parsed.message);
631
+ if (summary) {
632
+ return summary;
633
+ }
634
+
635
+ if (asTrimmedString(parsed.method) === "session/update" && isRecord(parsed.params) && isRecord(parsed.params.update)) {
636
+ return asOptionalString(parsed.params.update.summary)
637
+ ?? asOptionalString(parsed.params.update.message)
638
+ ?? asOptionalString(parsed.params.update.sessionUpdate);
639
+ }
640
+
641
+ return asOptionalString(parsed.sessionUpdate);
642
+ }
643
+
644
+ async function waitForExit(child: ChildProcessWithoutNullStreams): Promise<{
645
+ code: number | null;
646
+ signal: NodeJS.Signals | null;
647
+ error?: Error;
648
+ }> {
649
+ return await new Promise((resolve) => {
650
+ let settled = false;
651
+ const finish = (value: { code: number | null; signal: NodeJS.Signals | null; error?: Error }) => {
652
+ if (settled) {
653
+ return;
654
+ }
655
+ settled = true;
656
+ resolve(value);
657
+ };
658
+ child.once("error", (error) => finish({ code: null, signal: null, error }));
659
+ child.once("close", (code, signal) => finish({ code, signal }));
660
+ });
661
+ }
662
+
663
+ function safeKill(child: ChildProcessWithoutNullStreams): void {
664
+ terminateChildProcess(child);
665
+ }
666
+
667
+ function buildPermissionArgs(mode: "approve-all" | "approve-reads" | "deny-all"): string[] {
668
+ if (mode === "deny-all") {
669
+ return ["--deny-all"];
670
+ }
671
+ if (mode === "approve-reads") {
672
+ return ["--approve-reads"];
673
+ }
674
+ return ["--approve-all"];
675
+ }
676
+
677
+ function formatAcpxExitMessage(stderr: string, exitCode: number | null | undefined): string {
678
+ const detail = stderr.trim();
679
+ return detail || `acpx exited with code ${exitCode ?? "unknown"}`;
680
+ }
681
+
682
+ function asTrimmedString(value: unknown): string {
683
+ return typeof value === "string" ? value.trim() : "";
684
+ }
685
+
686
+ function asOptionalString(value: unknown): string | undefined {
687
+ const trimmed = asTrimmedString(value);
688
+ return trimmed || undefined;
689
+ }
690
+
691
+ function isRecord(value: unknown): value is Record<string, unknown> {
692
+ return typeof value === "object" && value !== null && !Array.isArray(value);
693
+ }