@lucascouts/claude-agent-acp-plus 0.25.3 → 0.27.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/dist/task-feed.js CHANGED
@@ -27,9 +27,22 @@
27
27
  *
28
28
  * Neither class ever copies a command line (`SDKTaskStartedMessage.prompt`)
29
29
  * into an entry or a log line.
30
+ *
31
+ * Version 2 (story 041) adds `hasOutput` and `tail`. A shell task's output
32
+ * path — named by the Bash result while it runs, by `task_notification` when
33
+ * it ends — is kept beside the table, never on the wire. Once an ended shell
34
+ * task leaves the table, its path moves to a bounded registry so
35
+ * `_claude/tasks/output` can still serve it.
30
36
  */
37
+ import { stat } from "node:fs/promises";
38
+ import { readTail } from "./task-output.js";
31
39
  /** The `session_info_update._meta` key the snapshot is published under. */
32
40
  export const TASKS_META_KEY = "_claude/tasks";
41
+ /** The `_claude/tasks` contract version advertised at `initialize`. Version 2
42
+ * adds `hasOutput`, `tail` and `_claude/tasks/output`. */
43
+ export const TASKS_CONTRACT_VERSION = 2;
44
+ /** Ended shell outputs still servable per session, most recent kept. */
45
+ export const ENDED_OUTPUTS_MAX = 256;
33
46
  /** How long a foreground shell task is held back before it is published. */
34
47
  export const FOREGROUND_SHELL_HOLD_MS = 5000;
35
48
  /** How long a running background task may be missing from the level before
@@ -104,6 +117,10 @@ export class TaskFeed {
104
117
  held = new Map();
105
118
  /** Shell task ids labelled by their tool call: a patch never relabels them. */
106
119
  toolDescribed = new Set();
120
+ /** Shell task id → its output file, while the task is in the table. */
121
+ outputs = new Map();
122
+ /** Ended shell outputs that left the table, oldest first; see `ENDED_OUTPUTS_MAX`. */
123
+ endedOutputs = new Map();
107
124
  constructor(clock, log = {}, sessionId = "") {
108
125
  this.clock = clock;
109
126
  this.log = log;
@@ -141,6 +158,8 @@ export class TaskFeed {
141
158
  usage: null,
142
159
  lastTool: null,
143
160
  summary: null,
161
+ hasOutput: false,
162
+ tail: null,
144
163
  };
145
164
  if (shell && msg.is_backgrounded !== true) {
146
165
  this.held.set(msg.task_id, entry);
@@ -194,6 +213,7 @@ export class TaskFeed {
194
213
  else {
195
214
  if (normaliseStatus(msg.patch.status)) {
196
215
  this.held.delete(msg.task_id);
216
+ this.outputs.delete(msg.task_id);
197
217
  }
198
218
  else {
199
219
  this.patch(heldEntry, msg.patch);
@@ -214,14 +234,19 @@ export class TaskFeed {
214
234
  }
215
235
  onNotification(msg) {
216
236
  // A held shell task that ends was never shown: its ending is not news.
217
- if (this.held.delete(msg.task_id))
237
+ if (this.held.delete(msg.task_id)) {
238
+ this.outputs.delete(msg.task_id);
218
239
  return "none";
240
+ }
219
241
  const task = this.running(msg.task_id, "task_notification");
220
242
  if (!task)
221
243
  return "none";
222
244
  const ending = normaliseStatus(msg.status, msg.reason) ?? "completed";
223
245
  if (msg.summary)
224
246
  task.summary = msg.summary;
247
+ if (task.type === "shell" && typeof msg.output_file === "string" && msg.output_file !== "") {
248
+ this.outputs.set(task.id, msg.output_file);
249
+ }
225
250
  const usage = usageOf(msg.usage);
226
251
  if (usage)
227
252
  task.usage = usage;
@@ -273,6 +298,8 @@ export class TaskFeed {
273
298
  /** The CLI process restarted: whatever was running did not survive it. A
274
299
  * held shell task was never shown, so it is dropped rather than ended. */
275
300
  onProcessRestart() {
301
+ for (const id of this.held.keys())
302
+ this.outputs.delete(id);
276
303
  this.held.clear();
277
304
  let change = "none";
278
305
  for (const task of this.tasks.values()) {
@@ -283,6 +310,69 @@ export class TaskFeed {
283
310
  }
284
311
  return change;
285
312
  }
313
+ /**
314
+ * Records the output file a Bash result named for a known shell task. Any
315
+ * other task, or an unknown id, is ignored: only a shell writes an output
316
+ * file, and only one the adapter recorded is ever read (R2.5).
317
+ */
318
+ onOutputPath(taskId, path) {
319
+ const task = this.tasks.get(taskId) ?? this.held.get(taskId);
320
+ if (!task || task.type !== "shell" || task.status !== "running") {
321
+ this.log.debug?.(`[tasks/feed] sessionId=${this.sessionId} taskId=${taskId} ignored=output_path`);
322
+ return "none";
323
+ }
324
+ this.outputs.set(taskId, path);
325
+ return "progress";
326
+ }
327
+ /**
328
+ * Sets a shell task's tail and whether its output reads. A running task
329
+ * keeps `lines`; an ended one keeps none, so this only confirms its output.
330
+ * Returns `"none"` when nothing changed, so an idle file publishes nothing.
331
+ */
332
+ setTail(taskId, lines, readable) {
333
+ const task = this.tasks.get(taskId);
334
+ if (!task || task.type !== "shell")
335
+ return "none";
336
+ // An ended task's output is decided by the ending's check alone: a poller
337
+ // tick that started before the ending and failed after it must not undo it.
338
+ if (task.status !== "running" && !readable)
339
+ return "none";
340
+ const tail = task.status === "running" && readable ? lines : null;
341
+ if (task.hasOutput === readable && sameLines(task.tail, tail))
342
+ return "none";
343
+ task.hasOutput = readable;
344
+ task.tail = tail === null ? null : [...tail];
345
+ return "progress";
346
+ }
347
+ /**
348
+ * The file the ending's existence check reads (R1.7), or null when there is
349
+ * nothing to check: `taskId` must be a running shell in the table. A non-empty
350
+ * `output_file` from the ending (the CLI's own) wins over the path the Bash result named.
351
+ * The caller checks BEFORE folding the ending in, so the snapshot that
352
+ * carries the ending already says whether the output can be served.
353
+ */
354
+ endingOutputPath(taskId, outputFile) {
355
+ const task = this.tasks.get(taskId);
356
+ if (!task || task.type !== "shell" || task.status !== "running")
357
+ return null;
358
+ if (typeof outputFile === "string" && outputFile !== "")
359
+ return outputFile;
360
+ return this.outputs.get(taskId) ?? null;
361
+ }
362
+ /** Running shell tasks with a recorded output file: the tail poller's work. */
363
+ runningOutputs() {
364
+ const work = [];
365
+ for (const [taskId, path] of this.outputs) {
366
+ const task = this.tasks.get(taskId);
367
+ if (task?.status === "running" && task.type === "shell")
368
+ work.push({ taskId, path });
369
+ }
370
+ return work;
371
+ }
372
+ /** The output file `_claude/tasks/output` may read for `taskId`, or null. */
373
+ outputPathFor(taskId) {
374
+ return this.outputs.get(taskId) ?? this.endedOutputs.get(taskId) ?? null;
375
+ }
286
376
  /** True when the feed holds `taskId` as running — the stop request's gate. */
287
377
  isRunning(taskId) {
288
378
  return this.tasks.get(taskId)?.status === "running";
@@ -293,7 +383,11 @@ export class TaskFeed {
293
383
  const tasks = [...this.tasks.values()]
294
384
  .filter((t) => t.status === "running" || !this.published.has(t.id))
295
385
  .sort((a, b) => a.startedAt - b.startedAt)
296
- .map((t) => ({ ...t, usage: t.usage ? { ...t.usage } : null }));
386
+ .map((t) => ({
387
+ ...t,
388
+ usage: t.usage ? { ...t.usage } : null,
389
+ tail: t.tail ? [...t.tail] : null,
390
+ }));
297
391
  const endedIds = tasks.filter((t) => t.status !== "running").map((t) => t.id);
298
392
  for (const id of endedIds)
299
393
  this.snapshotLives.set(id, this.lives.get(id) ?? 0);
@@ -312,6 +406,7 @@ export class TaskFeed {
312
406
  this.snapshotLives.delete(id);
313
407
  this.published.add(id);
314
408
  this.tasks.delete(id);
409
+ this.retireOutput(id);
315
410
  }
316
411
  }
317
412
  running(taskId, kind) {
@@ -329,6 +424,12 @@ export class TaskFeed {
329
424
  * state supersedes an ending the client has not seen yet, and a published
330
425
  * id must be shown again. */
331
426
  admit(entry) {
427
+ // A new life writes a new file: the previous life's is not this one's. A
428
+ // held shell being promoted is the same life, and keeps the path it has.
429
+ if (this.tasks.has(entry.id) || this.published.has(entry.id)) {
430
+ this.outputs.delete(entry.id);
431
+ this.endedOutputs.delete(entry.id);
432
+ }
332
433
  this.published.delete(entry.id);
333
434
  this.lives.set(entry.id, (this.lives.get(entry.id) ?? 0) + 1);
334
435
  this.tasks.set(entry.id, entry);
@@ -345,8 +446,32 @@ export class TaskFeed {
345
446
  end(task, status, at = this.clock()) {
346
447
  task.status = status;
347
448
  task.endedAt = at;
449
+ // An ended task has no tail, and its output is claimed again only once the
450
+ // file was checked (R1.7).
451
+ task.tail = null;
452
+ task.hasOutput = false;
348
453
  this.deadlines.delete(task.id);
349
454
  }
455
+ /** Moves a task that left the table to the ended registry, newest last. */
456
+ retireOutput(taskId) {
457
+ const path = this.outputs.get(taskId);
458
+ this.outputs.delete(taskId);
459
+ if (path === undefined)
460
+ return;
461
+ this.endedOutputs.delete(taskId);
462
+ this.endedOutputs.set(taskId, path);
463
+ while (this.endedOutputs.size > ENDED_OUTPUTS_MAX) {
464
+ const oldest = this.endedOutputs.keys().next().value;
465
+ if (oldest === undefined)
466
+ break;
467
+ this.endedOutputs.delete(oldest);
468
+ }
469
+ }
470
+ }
471
+ function sameLines(a, b) {
472
+ if (a === null || b === null)
473
+ return a === b;
474
+ return a.length === b.length && a.every((line, i) => line === b[i]);
350
475
  }
351
476
  const realTimers = {
352
477
  now: () => Date.now(),
@@ -528,3 +653,136 @@ export class TaskFeedPublisher {
528
653
  }, Math.max(0, at - this.timers.now()));
529
654
  }
530
655
  }
656
+ /** The longest the ending's existence check may hold the SDK message loop. */
657
+ export const ENDING_CHECK_TIMEOUT_MS = 2_000;
658
+ /** How often the tail poller looks at running shells' output files (R1.4). */
659
+ export const TAIL_POLL_INTERVAL_MS = 1_000;
660
+ const realPollerTimers = {
661
+ setInterval: (fn, ms) => setInterval(fn, ms),
662
+ clearInterval: (handle) => clearInterval(handle),
663
+ };
664
+ /** A log field value with no whitespace or `=` to forge a second field. */
665
+ function field(value) {
666
+ return value.replace(/[\s=]+/g, "_");
667
+ }
668
+ function codeOf(error) {
669
+ return typeof error === "object" &&
670
+ error !== null &&
671
+ "code" in error &&
672
+ typeof error.code === "string"
673
+ ? error.code
674
+ : "unknown";
675
+ }
676
+ /**
677
+ * Publishes a running shell task's tail (story 041, R1.4, R1.5). One interval
678
+ * while any shell output is known; each tick `stat`s every file and reads the
679
+ * tail only when its size changed, shrinking included. A read error publishes
680
+ * `tail: null, hasOutput: false` and logs once per task until the file reads
681
+ * again. A tick never overlaps the previous one, and no rejection escapes it:
682
+ * the interval runs outside every caller that could handle one, and Node exits
683
+ * on an unhandled rejection.
684
+ */
685
+ export class TailPoller {
686
+ feed;
687
+ publisher;
688
+ timers;
689
+ readTailFn;
690
+ log;
691
+ sessionId;
692
+ interval = null;
693
+ busy = false;
694
+ closed = false;
695
+ /** task id → the file size the last published tail was read at. */
696
+ sizes = new Map();
697
+ /** Task ids whose file failed to read: logged once, until it reads again. */
698
+ failing = new Set();
699
+ constructor(feed, publisher, timers = realPollerTimers, readTailFn = readTail, log = () => { }, sessionId = "") {
700
+ this.feed = feed;
701
+ this.publisher = publisher;
702
+ this.timers = timers;
703
+ this.readTailFn = readTailFn;
704
+ this.log = log;
705
+ this.sessionId = sessionId;
706
+ }
707
+ /** Arms the interval when there is output to watch. Called after a path is recorded. */
708
+ kick() {
709
+ if (this.closed || this.interval !== null)
710
+ return;
711
+ if (this.feed.runningOutputs().length === 0)
712
+ return;
713
+ this.interval = this.timers.setInterval(() => void this.tick(), TAIL_POLL_INTERVAL_MS);
714
+ }
715
+ /**
716
+ * The ending's existence check (R1.7): true when `path` reads as a regular
717
+ * file within `ENDING_CHECK_TIMEOUT_MS`. Never rejects.
718
+ */
719
+ async checkOutput(path) {
720
+ let timer;
721
+ const timeout = new Promise((resolve) => {
722
+ timer = setTimeout(() => resolve(false), ENDING_CHECK_TIMEOUT_MS);
723
+ });
724
+ try {
725
+ return await Promise.race([
726
+ this.readTailFn(path).then(() => true, () => false),
727
+ timeout,
728
+ ]);
729
+ }
730
+ finally {
731
+ clearTimeout(timer);
732
+ }
733
+ }
734
+ dispose() {
735
+ this.closed = true;
736
+ this.stop();
737
+ }
738
+ stop() {
739
+ if (this.interval !== null)
740
+ this.timers.clearInterval(this.interval);
741
+ this.interval = null;
742
+ }
743
+ async tick() {
744
+ if (this.busy || this.closed)
745
+ return;
746
+ this.busy = true;
747
+ try {
748
+ const work = this.feed.runningOutputs();
749
+ if (work.length === 0) {
750
+ this.stop();
751
+ this.sizes.clear();
752
+ this.failing.clear();
753
+ return;
754
+ }
755
+ for (const { taskId, path } of work) {
756
+ if (this.closed)
757
+ return;
758
+ let lines = null;
759
+ let readable = false;
760
+ try {
761
+ const size = (await stat(path)).size;
762
+ if (this.sizes.get(taskId) === size)
763
+ continue;
764
+ lines = await this.readTailFn(path);
765
+ this.sizes.set(taskId, size);
766
+ this.failing.delete(taskId);
767
+ readable = true;
768
+ }
769
+ catch (error) {
770
+ this.sizes.delete(taskId);
771
+ if (!this.failing.has(taskId)) {
772
+ this.failing.add(taskId);
773
+ this.log(`[tasks/tail] level=warn sessionId=${field(this.sessionId)} taskId=${field(taskId)} code=${field(codeOf(error))}`);
774
+ }
775
+ }
776
+ if (this.closed)
777
+ return;
778
+ this.publisher.notify(this.feed.setTail(taskId, lines, readable));
779
+ }
780
+ }
781
+ catch (error) {
782
+ this.log(`[tasks/tail] level=warn sessionId=${field(this.sessionId)} outcome=tick_failed code=${field(codeOf(error))}`);
783
+ }
784
+ finally {
785
+ this.busy = false;
786
+ }
787
+ }
788
+ }
@@ -0,0 +1,32 @@
1
+ /** The most `_claude/tasks/output` ever serves, whatever the client asks for (R2.3). */
2
+ export declare const OUTPUT_MAX_BYTES = 1048576;
3
+ /** Lines kept in a running task's `tail` (R1.2). */
4
+ export declare const TAIL_MAX_LINES = 10;
5
+ /** Code points kept per tail line; a longer line ends in `…` (R1.3). */
6
+ export declare const TAIL_MAX_CHARS = 240;
7
+ /** Bytes read from the end of the file to build a tail. */
8
+ export declare const TAIL_READ_BYTES = 16384;
9
+ /** A file that could not be read. `code` is the fs code, or `not_regular`. */
10
+ export declare class OutputUnavailable extends Error {
11
+ readonly code: string;
12
+ constructor(code: string);
13
+ }
14
+ /** The end of `file`, at most `min(maxBytes, OUTPUT_MAX_BYTES)` bytes (R2.1, R2.3). */
15
+ export declare function readOutput(file: string, maxBytes: number): Promise<{
16
+ content: string;
17
+ totalBytes: number;
18
+ truncated: boolean;
19
+ }>;
20
+ /** The last `TAIL_MAX_LINES` lines of `file`, sanitized (R1.2, R1.3). */
21
+ export declare function readTail(file: string): Promise<string[]>;
22
+ /** One tail line as a terminal would leave it, without escapes or controls (R1.3). */
23
+ export declare function sanitizeLine(line: string): string;
24
+ /**
25
+ * The output path a Bash result names for `taskId`, or null. Only an absolute, normalized path
26
+ * whose file name is exactly `<taskId>.output` is accepted (R1.6): anything else could be
27
+ * another task's file, or no task's.
28
+ */
29
+ export declare function outputPathFromBashResult(text: string, taskId: string): string | null;
30
+ /** True for an absolute, normalized path whose file name is exactly `<taskId>.output`. */
31
+ export declare function isOutputPathFor(candidate: string, taskId: string): boolean;
32
+ //# sourceMappingURL=task-output.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"task-output.d.ts","sourceRoot":"","sources":["../src/task-output.ts"],"names":[],"mappings":"AAgBA,wFAAwF;AACxF,eAAO,MAAM,gBAAgB,UAAY,CAAC;AAC1C,oDAAoD;AACpD,eAAO,MAAM,cAAc,KAAK,CAAC;AACjC,wEAAwE;AACxE,eAAO,MAAM,cAAc,MAAM,CAAC;AAClC,2DAA2D;AAC3D,eAAO,MAAM,eAAe,QAAS,CAAC;AAEtC,8EAA8E;AAC9E,qBAAa,iBAAkB,SAAQ,KAAK;IAC1C,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;gBAEV,IAAI,EAAE,MAAM;CAKzB;AAwDD,uFAAuF;AACvF,wBAAsB,UAAU,CAC9B,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,OAAO,CAAA;CAAE,CAAC,CAMtE;AAED,yEAAyE;AACzE,wBAAsB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAK9D;AASD,sFAAsF;AACtF,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAUjD;AAMD;;;;GAIG;AACH,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAGpF;AAED,0FAA0F;AAC1F,wBAAgB,eAAe,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAM1E"}
@@ -0,0 +1,135 @@
1
+ // Reading a shell task's output file — story 041.
2
+ //
3
+ // The CLI writes a background shell's output to `<task id>.output` and names that path in the
4
+ // Bash result. This module reads the end of such a file safely, for two consumers: the tail the
5
+ // task feed publishes while the task runs, and `_claude/tasks/output` after it ends.
6
+ //
7
+ // Safety rules, each one a test in `tests/task-output.test.ts`:
8
+ // - only a regular file is read, opened with `O_NOFOLLOW` so a symlink planted at the recorded
9
+ // path is refused (Node's flag covers only the last component, hence the `isFile()` check on
10
+ // the handle as well);
11
+ // - a cut lands on a line boundary, never inside a line or a UTF-8 sequence;
12
+ // - an error carries the fs code and never the file's content.
13
+ import { constants, open } from "node:fs/promises";
14
+ import path from "node:path";
15
+ /** The most `_claude/tasks/output` ever serves, whatever the client asks for (R2.3). */
16
+ export const OUTPUT_MAX_BYTES = 1_048_576;
17
+ /** Lines kept in a running task's `tail` (R1.2). */
18
+ export const TAIL_MAX_LINES = 10;
19
+ /** Code points kept per tail line; a longer line ends in `…` (R1.3). */
20
+ export const TAIL_MAX_CHARS = 240;
21
+ /** Bytes read from the end of the file to build a tail. */
22
+ export const TAIL_READ_BYTES = 16_384;
23
+ /** A file that could not be read. `code` is the fs code, or `not_regular`. */
24
+ export class OutputUnavailable extends Error {
25
+ code;
26
+ constructor(code) {
27
+ super(`task output unavailable (${code})`);
28
+ this.name = "OutputUnavailable";
29
+ this.code = code;
30
+ }
31
+ }
32
+ function unavailable(error) {
33
+ if (error instanceof OutputUnavailable)
34
+ return error;
35
+ const code = typeof error === "object" && error !== null && "code" in error && typeof error.code === "string"
36
+ ? error.code
37
+ : "unknown";
38
+ return new OutputUnavailable(code);
39
+ }
40
+ /**
41
+ * Reads the last `maxBytes` of `file`, cut on a line boundary. When the file is longer, one byte
42
+ * before the window is read too: everything up to and including the first `\n` is dropped, so a
43
+ * window that already starts on a line keeps that line. A window holding no `\n` is kept whole.
44
+ */
45
+ async function readWindow(file, maxBytes) {
46
+ let handle;
47
+ try {
48
+ // O_NONBLOCK: opening a FIFO for reading would otherwise wait for a writer
49
+ // forever; with it the open returns, and `isFile()` below refuses the FIFO.
50
+ handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
51
+ }
52
+ catch (error) {
53
+ throw unavailable(error);
54
+ }
55
+ try {
56
+ const stats = await handle.stat();
57
+ if (!stats.isFile())
58
+ throw new OutputUnavailable("not_regular");
59
+ const totalBytes = stats.size;
60
+ const truncated = totalBytes > maxBytes;
61
+ const start = truncated ? totalBytes - maxBytes - 1 : 0;
62
+ const length = totalBytes - start;
63
+ const buffer = Buffer.alloc(length);
64
+ let read = 0;
65
+ while (read < length) {
66
+ const { bytesRead } = await handle.read(buffer, read, length - read, start + read);
67
+ if (bytesRead === 0)
68
+ break; // the file shrank between fstat and read
69
+ read += bytesRead;
70
+ }
71
+ let bytes = buffer.subarray(0, read);
72
+ if (truncated) {
73
+ const newline = bytes.indexOf(0x0a);
74
+ bytes = newline === -1 ? bytes.subarray(1) : bytes.subarray(newline + 1);
75
+ }
76
+ return { bytes, totalBytes, truncated };
77
+ }
78
+ catch (error) {
79
+ throw unavailable(error);
80
+ }
81
+ finally {
82
+ await handle.close().catch(() => { });
83
+ }
84
+ }
85
+ const decode = (bytes) => new TextDecoder("utf-8", { fatal: false }).decode(bytes);
86
+ /** The end of `file`, at most `min(maxBytes, OUTPUT_MAX_BYTES)` bytes (R2.1, R2.3). */
87
+ export async function readOutput(file, maxBytes) {
88
+ const { bytes, totalBytes, truncated } = await readWindow(file, Math.min(maxBytes, OUTPUT_MAX_BYTES));
89
+ return { content: decode(bytes), totalBytes, truncated };
90
+ }
91
+ /** The last `TAIL_MAX_LINES` lines of `file`, sanitized (R1.2, R1.3). */
92
+ export async function readTail(file) {
93
+ const { bytes } = await readWindow(file, TAIL_READ_BYTES);
94
+ const lines = decode(bytes).split("\n");
95
+ if (lines.at(-1) === "")
96
+ lines.pop();
97
+ return lines.slice(-TAIL_MAX_LINES).map(sanitizeLine);
98
+ }
99
+ /* eslint-disable no-control-regex -- these patterns exist to match terminal control bytes */
100
+ const CSI = /\x1b\[[0-?]*[ -/]*[@-~]/g;
101
+ const OSC = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
102
+ const OTHER_ESCAPE = /\x1b[\s\S]?/g;
103
+ const CONTROL = /[\x00-\x08\x0b-\x1f\x7f]/g;
104
+ /* eslint-enable no-control-regex */
105
+ /** One tail line as a terminal would leave it, without escapes or controls (R1.3). */
106
+ export function sanitizeLine(line) {
107
+ const redrawn = line.slice(line.lastIndexOf("\r") + 1);
108
+ const clean = redrawn
109
+ .replace(OSC, "")
110
+ .replace(CSI, "")
111
+ .replace(OTHER_ESCAPE, "")
112
+ .replace(CONTROL, "");
113
+ const chars = [...clean];
114
+ if (chars.length <= TAIL_MAX_CHARS)
115
+ return clean;
116
+ return chars.slice(0, TAIL_MAX_CHARS - 1).join("") + "…";
117
+ }
118
+ // The CLI ends the sentence with a period (2.1.285: "…/tasks/<id>.output."), so
119
+ // one trailing punctuation mark is not part of the path.
120
+ const OUTPUT_NOTICE = /Output is being written to: (\S+?)[.,;:]?(?:\s|$)/;
121
+ /**
122
+ * The output path a Bash result names for `taskId`, or null. Only an absolute, normalized path
123
+ * whose file name is exactly `<taskId>.output` is accepted (R1.6): anything else could be
124
+ * another task's file, or no task's.
125
+ */
126
+ export function outputPathFromBashResult(text, taskId) {
127
+ const candidate = text.match(OUTPUT_NOTICE)?.[1];
128
+ return candidate !== undefined && isOutputPathFor(candidate, taskId) ? candidate : null;
129
+ }
130
+ /** True for an absolute, normalized path whose file name is exactly `<taskId>.output`. */
131
+ export function isOutputPathFor(candidate, taskId) {
132
+ return (path.isAbsolute(candidate) &&
133
+ path.normalize(candidate) === candidate &&
134
+ path.basename(candidate) === `${taskId}.output`);
135
+ }
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.25.3",
6
+ "version": "0.27.0",
7
7
  "description": "Claude Code in Zed and other ACP clients with the VS Code extension's UX — multi-select questions, refusal consent, live quota windows, background tasks, Ultracode effort and session notices. Fork of claude-agent-acp.",
8
8
  "main": "dist/lib.js",
9
9
  "types": "dist/lib.d.ts",