@gajae-code/coding-agent 0.18.1 → 0.18.6

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 (128) hide show
  1. package/CHANGELOG.md +161 -0
  2. package/dist/types/cli/stats-cli.d.ts +0 -5
  3. package/dist/types/cli-commands.d.ts +10 -0
  4. package/dist/types/cli-ordinary.d.ts +13 -0
  5. package/dist/types/cli.d.ts +12 -3
  6. package/dist/types/commands/contribution-prep.d.ts +0 -3
  7. package/dist/types/commands/sdk.d.ts +3 -0
  8. package/dist/types/commands/update.d.ts +1 -0
  9. package/dist/types/config/autorouting-tier-map.d.ts +29 -2
  10. package/dist/types/config/model-profile-activation.d.ts +19 -0
  11. package/dist/types/config/model-registry.d.ts +16 -1
  12. package/dist/types/coordinator-mcp/session-reaper.d.ts +5 -0
  13. package/dist/types/edit/modes/replace.d.ts +3 -3
  14. package/dist/types/edit/streaming.d.ts +2 -2
  15. package/dist/types/exa/mcp-client.d.ts +1 -5
  16. package/dist/types/exec/bash-shell-guardian.d.ts +26 -0
  17. package/dist/types/exec/bash-shell-supervisor.d.ts +12 -0
  18. package/dist/types/hashline/parser.d.ts +28 -5
  19. package/dist/types/modes/acp/acp-agent.d.ts +6 -1
  20. package/dist/types/sdk/broker/discovery.d.ts +8 -0
  21. package/dist/types/sdk/broker/ensure.d.ts +1 -0
  22. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +4 -0
  23. package/dist/types/sdk/broker/lifecycle.d.ts +6 -1
  24. package/dist/types/sdk/broker/recovery-backoff.d.ts +62 -0
  25. package/dist/types/sdk/broker/runtime.d.ts +21 -1
  26. package/dist/types/sdk/broker/startup-budget.d.ts +5 -1
  27. package/dist/types/sdk/bus/index.d.ts +1 -0
  28. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  29. package/dist/types/sdk/bus/telegram-daemon.d.ts +1 -0
  30. package/dist/types/sdk/cli/diagnostics-cli.d.ts +18 -0
  31. package/dist/types/sdk/diagnostics/observe-broker.d.ts +185 -0
  32. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  33. package/dist/types/sdk/host/session-runtime.d.ts +7 -0
  34. package/dist/types/sdk/index.d.ts +1 -0
  35. package/dist/types/sdk/prompt-watchdog.d.ts +1 -1
  36. package/dist/types/sdk/router/session-router.d.ts +2 -0
  37. package/dist/types/sdk/session.d.ts +13 -0
  38. package/dist/types/session/agent-session.d.ts +5 -1
  39. package/dist/types/tools/ask-contract.d.ts +75 -254
  40. package/dist/types/tools/bash.d.ts +1 -0
  41. package/package.json +7 -7
  42. package/scripts/build-sdk-package-smoke.ts +5 -0
  43. package/src/cli/public-command-entry.ts +5 -0
  44. package/src/cli/ssh-cli.ts +6 -3
  45. package/src/cli/stats-cli.ts +4 -36
  46. package/src/cli/web-search-cli.ts +9 -3
  47. package/src/cli-commands.ts +14 -0
  48. package/src/cli-main.ts +4 -8
  49. package/src/cli-ordinary.ts +197 -0
  50. package/src/cli.ts +25 -186
  51. package/src/commands/commit.ts +2 -2
  52. package/src/commands/contribution-prep.ts +1 -3
  53. package/src/commands/sdk.ts +60 -2
  54. package/src/commands/update.ts +15 -1
  55. package/src/config/autorouting-tier-map.ts +20 -2
  56. package/src/config/file-lock.ts +91 -2
  57. package/src/config/model-preset-registry.ts +17 -2
  58. package/src/config/model-profile-activation.ts +73 -5
  59. package/src/config/model-profile-contract.ts +4 -1
  60. package/src/config/model-profiles.ts +29 -29
  61. package/src/config/model-registry.ts +45 -17
  62. package/src/config/model-resolver.ts +9 -1
  63. package/src/coordinator-mcp/event-webhook.ts +19 -14
  64. package/src/coordinator-mcp/policy.ts +14 -4
  65. package/src/coordinator-mcp/server.ts +142 -7
  66. package/src/coordinator-mcp/session-reaper.ts +9 -1
  67. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  68. package/src/edit/modes/replace.ts +6 -6
  69. package/src/edit/streaming.ts +15 -15
  70. package/src/exa/mcp-client.ts +41 -8
  71. package/src/exec/bash-shell-guardian.ts +184 -34
  72. package/src/exec/bash-shell-supervisor.ts +60 -3
  73. package/src/hashline/execute.ts +51 -3
  74. package/src/hashline/parser.ts +63 -16
  75. package/src/internal-urls/docs-index.generated.ts +29 -28
  76. package/src/memories/index.ts +8 -2
  77. package/src/modes/acp/acp-agent.ts +688 -371
  78. package/src/modes/components/tool-execution.ts +1 -1
  79. package/src/modes/controllers/ssh-command-controller.ts +3 -1
  80. package/src/prompts/tools/ask.md +2 -0
  81. package/src/sdk/broker/broker.ts +46 -0
  82. package/src/sdk/broker/daemon-entry.ts +67 -6
  83. package/src/sdk/broker/discovery.ts +8 -0
  84. package/src/sdk/broker/ensure.ts +156 -20
  85. package/src/sdk/broker/lifecycle-ledger.ts +110 -12
  86. package/src/sdk/broker/lifecycle.ts +108 -34
  87. package/src/sdk/broker/recovery-backoff.ts +135 -0
  88. package/src/sdk/broker/runtime.ts +88 -1
  89. package/src/sdk/broker/startup-budget.ts +6 -1
  90. package/src/sdk/broker/transport.ts +5 -1
  91. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  92. package/src/sdk/bus/index.ts +305 -19
  93. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  94. package/src/sdk/bus/telegram-daemon-control.ts +15 -12
  95. package/src/sdk/bus/telegram-daemon.ts +34 -1
  96. package/src/sdk/cli/diagnostics-cli.ts +247 -0
  97. package/src/sdk/diagnostics/observe-broker.ts +654 -0
  98. package/src/sdk/host/control/dispatch.ts +52 -4
  99. package/src/sdk/host/control/operations.ts +1 -1
  100. package/src/sdk/host/session-runtime.test.ts +40 -2
  101. package/src/sdk/host/session-runtime.ts +112 -8
  102. package/src/sdk/index.ts +1 -0
  103. package/src/sdk/lifecycle-session.ts +1 -0
  104. package/src/sdk/prompt-watchdog.ts +17 -4
  105. package/src/sdk/protocol/operation-registry.ts +1 -1
  106. package/src/sdk/router/session-router.ts +7 -1
  107. package/src/sdk/session.ts +37 -8
  108. package/src/session/agent-session.ts +128 -41
  109. package/src/session/blob-store.ts +78 -33
  110. package/src/session/internal/managed-session-storage.ts +119 -3
  111. package/src/setup/hermes/templates/operator-instructions.v1.md +19 -1
  112. package/src/setup/hermes-setup.ts +4 -1
  113. package/src/slash-commands/builtin-registry.ts +8 -5
  114. package/src/tools/ask-contract.test.ts +76 -1
  115. package/src/tools/ask-contract.ts +57 -6
  116. package/src/tools/ask.ts +16 -7
  117. package/src/tools/bash.ts +77 -29
  118. package/src/tools/descriptors.test.ts +29 -0
  119. package/src/tools/tool-catalog.generated.ts +7 -4
  120. package/src/web/search/index.ts +2 -2
  121. package/dist/types/cli/grep-cli.d.ts +0 -17
  122. package/dist/types/cli/shell-cli.d.ts +0 -8
  123. package/dist/types/commands/grep.d.ts +0 -42
  124. package/dist/types/commands/shell.d.ts +0 -21
  125. package/src/cli/grep-cli.ts +0 -160
  126. package/src/cli/shell-cli.ts +0 -176
  127. package/src/commands/grep.ts +0 -48
  128. package/src/commands/shell.ts +0 -29
@@ -1,6 +1,7 @@
1
1
  import { dlopen, FFIType, ptr } from "bun:ffi";
2
2
  import * as childProcess from "node:child_process";
3
3
  import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
4
+ import * as fsSync from "node:fs";
4
5
  import * as fs from "node:fs/promises";
5
6
  import * as os from "node:os";
6
7
  import * as path from "node:path";
@@ -63,10 +64,10 @@ export function extendOwnedDarwinAncestry<T extends { uniqueId: bigint; parentUn
63
64
  return addedPids;
64
65
  }
65
66
 
66
- function authenticateOwnershipRecord(
67
+ export function authenticateOwnershipRecord(
67
68
  line: string,
68
69
  ledgerToken: string,
69
- ): { processRef?: NativeProcess; darwinUniqueId?: bigint } | undefined {
70
+ ): { processRef?: NativeProcess; darwinUniqueId?: bigint; pending?: true } | undefined {
70
71
  const record = parseOwnershipRecord(line);
71
72
  if (!record) return undefined;
72
73
  const uniqueId = record.darwinUniqueId ?? "";
@@ -80,15 +81,20 @@ function authenticateOwnershipRecord(
80
81
  return undefined;
81
82
  }
82
83
  if (received.byteLength !== expected.byteLength || !timingSafeEqual(received, expected)) return undefined;
83
- if (process.platform === "darwin" && !record.darwinUniqueId) return undefined;
84
84
  const { Process } = require("@gajae-code/natives") as NativeProcessBindings;
85
85
  const owned = Process.fromPid(record.pid);
86
- return {
87
- ...(owned?.incarnation === record.incarnation ? { processRef: owned } : {}),
88
- ...(record.darwinUniqueId ? { darwinUniqueId: BigInt(record.darwinUniqueId) } : {}),
89
- };
86
+ const darwinUniqueId = record.darwinUniqueId ? { darwinUniqueId: BigInt(record.darwinUniqueId) } : {};
87
+ if (owned?.incarnation === record.incarnation) return { processRef: owned, ...darwinUniqueId };
88
+ // fromPid() conflates death with an inconclusive lookup. Only a confirmed
89
+ // absence or a different incarnation settles the record; an unknown result
90
+ // keeps it pending so the caller retries the line on the next scan.
91
+ if (!owned && Process.observe(record.pid).status === "unknown") return { pending: true, ...darwinUniqueId };
92
+ return darwinUniqueId;
90
93
  }
91
94
 
95
+ /** Scans an inconclusive ledger record may stay pending before tracking fails closed. */
96
+ const MAX_PENDING_LEDGER_ATTEMPTS = 50;
97
+
92
98
  function enableLinuxChildSubreaper(): boolean {
93
99
  if (process.platform !== "linux") return true;
94
100
  try {
@@ -157,14 +163,27 @@ function findDarwinLedgerHolders(device: number, inode: bigint): NativeProcess[]
157
163
  }
158
164
  }
159
165
 
160
- type DarwinAncestryTracker = {
166
+ export type DarwinAncestryTracker = {
161
167
  seed(uniqueId: bigint): void;
162
168
  track(processRef: NativeProcess, uniqueId?: bigint): boolean;
169
+ trackGuardian(processRef: NativeProcess, uniqueId?: bigint): boolean;
163
170
  poll(): boolean;
164
171
  close(): void;
165
172
  };
166
173
 
167
- function createDarwinAncestryTracker(owned: Map<string, NativeProcess>): DarwinAncestryTracker | undefined {
174
+ export type DarwinIdentity = { uniqueId: bigint; parentUniqueId: bigint };
175
+
176
+ /** Test seams for the Darwin tracker; production uses libproc and the natives binding. */
177
+ export type DarwinAncestryTrackerDeps = {
178
+ uniqueIdentity?: (pid: number) => DarwinIdentity | undefined;
179
+ fromPid?: (pid: number) => NativeProcess | null | undefined;
180
+ observe?: (pid: number) => { status: "present" | "absent" | "unknown"; incarnation?: string };
181
+ };
182
+
183
+ export function createDarwinAncestryTracker(
184
+ owned: Map<string, NativeProcess>,
185
+ deps: DarwinAncestryTrackerDeps = {},
186
+ ): DarwinAncestryTracker | undefined {
168
187
  if (process.platform !== "darwin") return undefined;
169
188
  try {
170
189
  const proc = dlopen("/usr/lib/libproc.dylib", {
@@ -180,10 +199,12 @@ function createDarwinAncestryTracker(owned: Map<string, NativeProcess>): DarwinA
180
199
  // own children still chain, but it is retried on every poll until retained.
181
200
  const retainedUniqueIds = new Set<bigint>();
182
201
  const { Process } = require("@gajae-code/natives") as NativeProcessBindings;
202
+ const fromPid = deps.fromPid ?? ((pid: number) => Process.fromPid(pid));
203
+ const observe = deps.observe ?? ((pid: number) => Process.observe(pid));
183
204
  // Reuse buffer across poll iterations to avoid allocations inside the loop.
184
205
  const identityBuffer = new Uint8Array(56);
185
206
  const identityView = new DataView(identityBuffer.buffer);
186
- const uniqueIdentity = (pid: number): { uniqueId: bigint; parentUniqueId: bigint } | undefined => {
207
+ const defaultUniqueIdentity = (pid: number): { uniqueId: bigint; parentUniqueId: bigint } | undefined => {
187
208
  const bytes = proc.symbols.proc_pidinfo(pid, 17, 0, ptr(identityBuffer), identityBuffer.byteLength);
188
209
  if (bytes !== identityBuffer.byteLength) return undefined;
189
210
  return {
@@ -191,9 +212,92 @@ function createDarwinAncestryTracker(owned: Map<string, NativeProcess>): DarwinA
191
212
  parentUniqueId: identityView.getBigUint64(24, true),
192
213
  };
193
214
  };
215
+ const uniqueIdentity = deps.uniqueIdentity ?? defaultUniqueIdentity;
216
+ const incarnationOnlyAnchors: NativeProcess[] = [];
217
+ const anchorKeys = new Set<string>();
218
+ const addIncarnationOnlyAnchor = (processRef: NativeProcess): void => {
219
+ const key = `${processRef.pid}:${processRef.incarnation}`;
220
+ if (anchorKeys.has(key)) return;
221
+ anchorKeys.add(key);
222
+ incarnationOnlyAnchors.push(processRef);
223
+ };
224
+ // children() validates its parent and then snapshots the process table; the
225
+ // parent can exit and its pid be reused in between, returning a stranger's
226
+ // children. Only trust a snapshot if the parent still holds its incarnation
227
+ // after it was taken.
228
+ const childrenOf = (parent: NativeProcess): NativeProcess[] => {
229
+ const children = parent.children();
230
+ return fromPid(parent.pid)?.incarnation === parent.incarnation ? children : [];
231
+ };
232
+ const seedAnchorDescendants = (anchor: NativeProcess): void => {
233
+ const pending = [...childrenOf(anchor)];
234
+ while (pending.length > 0) {
235
+ const child = pending.pop()!;
236
+ retainOwnedProcess(owned, child);
237
+ // The unique id is read from a bare pid, so bracket it with incarnation
238
+ // checks against this handle: a pid reused in between would otherwise
239
+ // seed an unrelated process (and its descendants) as owned.
240
+ if (fromPid(child.pid)?.incarnation === child.incarnation) {
241
+ const childId = uniqueIdentity(child.pid)?.uniqueId;
242
+ if (fromPid(child.pid)?.incarnation === child.incarnation) {
243
+ if (childId !== undefined) {
244
+ knownUniqueIds.add(childId);
245
+ retainedUniqueIds.add(childId);
246
+ } else {
247
+ // Its flavor-17 query is denied too: without a unique id the
248
+ // ancestry walk can never reach its subtree, so it must outlive
249
+ // this anchor's pruning as an incarnation-only anchor itself.
250
+ addIncarnationOnlyAnchor(child);
251
+ }
252
+ }
253
+ }
254
+ pending.push(...childrenOf(child));
255
+ }
256
+ };
194
257
  const track = (processRef: NativeProcess, signedUniqueId?: bigint): boolean => {
258
+ // The bare-pid unique-id query is only trusted if the same incarnation
259
+ // holds on both sides of it; otherwise a pid reused after the record was
260
+ // authenticated would seed the replacement's ancestry as owned.
261
+ let identity: { uniqueId: bigint; parentUniqueId: bigint } | undefined;
262
+ if (signedUniqueId === undefined) {
263
+ const queried = uniqueIdentity(processRef.pid);
264
+ const after = observe(processRef.pid);
265
+ if (
266
+ after.status === "absent" ||
267
+ (after.status === "present" && after.incarnation !== processRef.incarnation)
268
+ ) {
269
+ // Confirmed exited or reused since authentication: retain nothing new.
270
+ return true;
271
+ }
272
+ // Present with the same incarnation keeps the queried id. An
273
+ // inconclusive lookup cannot vouch for it, so the record falls through
274
+ // as incarnation-only and is kept as an anchor rather than dropped.
275
+ identity = after.status === "present" ? queried : undefined;
276
+ }
277
+ const uniqueId = signedUniqueId ?? identity?.uniqueId;
278
+ if (uniqueId === undefined) {
279
+ // Incarnation-only record: the child has no unique id to anchor the
280
+ // ancestry graph. Keep it as an anchor whose live descendants poll()
281
+ // seeds by unique id, so their subtrees stay tracked after the child
282
+ // exits and they reparent (PPID discovery alone would lose them).
283
+ // Residual gap (accepted over #6085's exit 70): if the child forks a
284
+ // descendant that leaves the supervisor process group, then exits before
285
+ // this record is scanned, nothing links that descendant back to us. The
286
+ // ledger watcher scans on append to keep that window minimal.
287
+ retainOwnedProcess(owned, processRef);
288
+ addIncarnationOnlyAnchor(processRef);
289
+ seedAnchorDescendants(processRef);
290
+ return true;
291
+ }
292
+ knownUniqueIds.add(uniqueId);
293
+ retainedUniqueIds.add(uniqueId);
294
+ retainOwnedProcess(owned, processRef);
295
+ return true;
296
+ };
297
+ const trackGuardian = (processRef: NativeProcess, signedUniqueId?: bigint): boolean => {
195
298
  const identity = signedUniqueId === undefined ? uniqueIdentity(processRef.pid) : undefined;
196
299
  const uniqueId = signedUniqueId ?? identity?.uniqueId;
300
+ // Guardian registration is strict: must have a unique id
197
301
  if (uniqueId === undefined) return false;
198
302
  knownUniqueIds.add(uniqueId);
199
303
  retainedUniqueIds.add(uniqueId);
@@ -205,7 +309,19 @@ function createDarwinAncestryTracker(owned: Map<string, NativeProcess>): DarwinA
205
309
  knownUniqueIds.add(uniqueId);
206
310
  },
207
311
  track,
312
+ trackGuardian,
208
313
  poll() {
314
+ // Re-seed live anchors, then drop exited ones: their descendants were
315
+ // seeded by unique id while the anchor was alive, and ancestry now
316
+ // carries them, so re-walking a dead anchor only costs time.
317
+ for (let index = incarnationOnlyAnchors.length - 1; index >= 0; index--) {
318
+ const anchor = incarnationOnlyAnchors[index]!;
319
+ seedAnchorDescendants(anchor);
320
+ if (fromPid(anchor.pid)?.incarnation !== anchor.incarnation) {
321
+ incarnationOnlyAnchors.splice(index, 1);
322
+ anchorKeys.delete(`${anchor.pid}:${anchor.incarnation}`);
323
+ }
324
+ }
209
325
  const capacity = proc.symbols.proc_listallpids(null, 0);
210
326
  if (capacity <= 0) return false;
211
327
  const pids = new Int32Array(capacity + 64);
@@ -225,10 +341,10 @@ function createDarwinAncestryTracker(owned: Map<string, NativeProcess>): DarwinA
225
341
  extendOwnedDarwinAncestry(knownUniqueIds, identities);
226
342
  for (const [pid, listed] of identities) {
227
343
  if (!knownUniqueIds.has(listed.uniqueId) || retainedUniqueIds.has(listed.uniqueId)) continue;
228
- const before = Process.fromPid(pid);
344
+ const before = fromPid(pid);
229
345
  if (!before) continue;
230
346
  const identity = uniqueIdentity(pid);
231
- const after = Process.fromPid(pid);
347
+ const after = fromPid(pid);
232
348
  if (identity?.uniqueId === listed.uniqueId && after?.incarnation === before.incarnation) {
233
349
  retainOwnedProcess(owned, after);
234
350
  retainedUniqueIds.add(listed.uniqueId);
@@ -275,7 +391,7 @@ export async function runBashShellGuardian(): Promise<void> {
275
391
  if (darwinTracker) {
276
392
  const { Process } = require("@gajae-code/natives") as NativeProcessBindings;
277
393
  const guardian = Process.fromPid(process.pid);
278
- if (!guardian || !darwinTracker.track(guardian)) {
394
+ if (!guardian || !darwinTracker.trackGuardian(guardian)) {
279
395
  darwinTracker.close();
280
396
  await ledger.close();
281
397
  await fs.rm(ownershipFilePath, { force: true });
@@ -306,6 +422,8 @@ export async function runBashShellGuardian(): Promise<void> {
306
422
  input.once("close", () => supervisor.stdin.end());
307
423
  let cleaning: Promise<void> | undefined;
308
424
  let ledgerBuffer = "";
425
+ let pendingLedgerLines: string[] = [];
426
+ const pendingAttempts = new Map<string, number>();
309
427
  let ownershipScan = Promise.resolve(true);
310
428
  const scanOwnership = (): Promise<boolean> => {
311
429
  ownershipScan = ownershipScan.then(async previousOk => {
@@ -317,11 +435,23 @@ export async function runBashShellGuardian(): Promise<void> {
317
435
  return false;
318
436
  }
319
437
  ledgerBuffer += content;
320
- const lines = ledgerBuffer.split("\n");
321
- ledgerBuffer = lines.pop() ?? "";
438
+ const fresh = ledgerBuffer.split("\n");
439
+ ledgerBuffer = fresh.pop() ?? "";
440
+ const lines = [...pendingLedgerLines, ...fresh];
441
+ pendingLedgerLines = [];
322
442
  for (const line of lines) {
323
443
  if (!line) continue;
324
444
  const owned = authenticateOwnershipRecord(line, ledgerToken);
445
+ if (owned?.pending) {
446
+ // Inconclusive lookup: retry next scan, bounded so a pid stuck in an
447
+ // unknown state cannot grow the queue or starve cleanup forever.
448
+ const attempts = (pendingAttempts.get(line) ?? 0) + 1;
449
+ if (attempts > MAX_PENDING_LEDGER_ATTEMPTS) return false;
450
+ pendingAttempts.set(line, attempts);
451
+ pendingLedgerLines.push(line);
452
+ } else {
453
+ pendingAttempts.delete(line);
454
+ }
325
455
  if (owned?.darwinUniqueId && darwinTracker) darwinTracker.seed(owned.darwinUniqueId);
326
456
  if (owned?.processRef && darwinTracker && !darwinTracker.track(owned.processRef, owned.darwinUniqueId))
327
457
  return false;
@@ -335,28 +465,48 @@ export async function runBashShellGuardian(): Promise<void> {
335
465
  return ownershipScan;
336
466
  };
337
467
  let periodicScanActive = false;
338
- const ownershipScanTimer = darwinTracker
339
- ? setInterval(() => {
340
- if (periodicScanActive) return;
341
- periodicScanActive = true;
342
- void scanOwnership()
343
- .then(ok => {
344
- if (ok || supervisor.exitCode !== null || supervisor.signalCode !== null) return;
345
- if (supervisor.pid) {
346
- try {
347
- process.kill(-supervisor.pid, "SIGKILL");
348
- } catch {}
349
- }
350
- supervisor.kill("SIGKILL");
351
- })
352
- .finally(() => {
353
- periodicScanActive = false;
354
- });
355
- }, 100)
356
- : undefined;
468
+ let periodicScanRequested = false;
469
+ const runPeriodicScan = (): void => {
470
+ if (periodicScanActive) {
471
+ periodicScanRequested = true;
472
+ return;
473
+ }
474
+ periodicScanActive = true;
475
+ void scanOwnership()
476
+ .then(ok => {
477
+ if (ok || supervisor.exitCode !== null || supervisor.signalCode !== null) return;
478
+ if (supervisor.pid) {
479
+ try {
480
+ process.kill(-supervisor.pid, "SIGKILL");
481
+ } catch {}
482
+ }
483
+ supervisor.kill("SIGKILL");
484
+ })
485
+ .finally(() => {
486
+ periodicScanActive = false;
487
+ if (periodicScanRequested && !cleaning) {
488
+ periodicScanRequested = false;
489
+ runPeriodicScan();
490
+ }
491
+ });
492
+ };
493
+ const ownershipScanTimer = darwinTracker ? setInterval(runPeriodicScan, 100) : undefined;
494
+ // Scan as soon as the supervisor appends a ledger record instead of waiting
495
+ // for the next tick. An incarnation-only record's descendants are only
496
+ // reachable while that child is alive, so the anchor must be taken promptly.
497
+ let ledgerWatcher: fsSync.FSWatcher | undefined;
498
+ if (darwinTracker) {
499
+ try {
500
+ ledgerWatcher = fsSync.watch(ownershipFilePath, runPeriodicScan);
501
+ ledgerWatcher.on("error", () => {});
502
+ } catch {
503
+ ledgerWatcher = undefined;
504
+ }
505
+ }
357
506
  const cleanup = (): Promise<void> => {
358
507
  cleaning ??= (async () => {
359
508
  if (ownershipScanTimer) clearInterval(ownershipScanTimer);
509
+ ledgerWatcher?.close();
360
510
  let trackingOk = await scanOwnership();
361
511
  if (supervisor.exitCode === null && supervisor.signalCode === null) {
362
512
  if (process.platform !== "win32" && supervisor.pid) {
@@ -1,6 +1,7 @@
1
1
  import { dlopen, FFIType, ptr } from "bun:ffi";
2
2
  import * as childProcess from "node:child_process";
3
3
  import { createHmac } from "node:crypto";
4
+ import type { EventEmitter } from "node:events";
4
5
  import * as fs from "node:fs/promises";
5
6
  import * as os from "node:os";
6
7
  import * as path from "node:path";
@@ -121,6 +122,63 @@ async function reapLinuxAdoptedZombies(excludedPid: number | undefined): Promise
121
122
  }
122
123
  }
123
124
 
125
+ /**
126
+ * Reap adopted zombies only when the kernel reports a child state change.
127
+ *
128
+ * As the Linux child subreaper, the supervisor inherits orphaned descendants
129
+ * and must reap them, but a full `/proc` sweep is expensive. Polling it on a
130
+ * short interval kept every idle supervisor busy (issue #5972), so sweeps are
131
+ * driven by SIGCHLD instead. At most one sweep runs at a time, and a SIGCHLD that
132
+ * arrives mid-sweep schedules exactly one follow-up so a zombie created after
133
+ * its `/proc` entry was passed is never missed. Returns the unsubscribe hook.
134
+ */
135
+ export function startLinuxAdoptedZombieReaper(
136
+ excludedPid: number | undefined,
137
+ reap: (excludedPid: number | undefined) => Promise<void> = reapLinuxAdoptedZombies,
138
+ signals: Pick<EventEmitter, "on" | "off"> = process,
139
+ ): () => void {
140
+ if (process.platform !== "linux") return () => undefined;
141
+ let running = false;
142
+ let rerun = false;
143
+ let stopped = false;
144
+ let sweepPending = false;
145
+ const sweep = (): void => {
146
+ if (stopped) return;
147
+ if (running) {
148
+ rerun = true;
149
+ return;
150
+ }
151
+ if (sweepPending) return;
152
+ sweepPending = true;
153
+ running = true;
154
+ void reap(excludedPid)
155
+ .catch(() => undefined)
156
+ .finally(() => {
157
+ running = false;
158
+ sweepPending = false;
159
+ if (!rerun || stopped) return;
160
+ rerun = false;
161
+ // Defer recursive call to avoid stack overflow
162
+ setImmediate(() => {
163
+ if (!stopped && !running) sweep();
164
+ });
165
+ });
166
+ };
167
+ try {
168
+ signals.on("SIGCHLD", sweep);
169
+ } catch {
170
+ // Signal handler installation may fail in some environments
171
+ }
172
+ return () => {
173
+ stopped = true;
174
+ try {
175
+ signals.off("SIGCHLD", sweep);
176
+ } catch {
177
+ // Signal handler removal may fail in some environments
178
+ }
179
+ };
180
+ }
181
+
124
182
  function signalFromExit(code: number | null, signal: NodeJS.Signals | null): NodeJS.Signals | null {
125
183
  if (signal) return signal;
126
184
  if (code === null || code <= 128) return null;
@@ -210,8 +268,7 @@ export async function runBashShellSupervisor(): Promise<void> {
210
268
  stdio: ["pipe", "pipe", "pipe"],
211
269
  windowsHide: true,
212
270
  });
213
- const zombieReaper = setInterval(() => void reapLinuxAdoptedZombies(worker.pid), 25);
214
- zombieReaper.unref();
271
+ const stopZombieReaper = startLinuxAdoptedZombieReaper(worker.pid);
215
272
  const pendingRuns: number[] = [];
216
273
  let terminating = false;
217
274
  let protocolToken: string | undefined;
@@ -315,7 +372,7 @@ export async function runBashShellSupervisor(): Promise<void> {
315
372
  );
316
373
  });
317
374
  worker.once("exit", async (code, signal) => {
318
- clearInterval(zombieReaper);
375
+ stopZombieReaper();
319
376
  const resultSignal = signalFromExit(code, signal);
320
377
  if (code === 0 && resultSignal === null) {
321
378
  await reapLinuxSubreaperChildren();
@@ -11,12 +11,12 @@ import { assertEditableFileContent } from "../tools/auto-generated-guard";
11
11
  import { invalidateFsScanAfterWrite } from "../tools/fs-cache-invalidation";
12
12
  import { outputMeta } from "../tools/output-meta";
13
13
  import { enforcePlanModeWrite, resolvePlanPath } from "../tools/plan-mode-guard";
14
- import { HashlineMismatchError } from "./anchors";
14
+ import { formatFullAnchorRequirement, HashlineMismatchError } from "./anchors";
15
15
  import { applyHashlineEdits, type HashlineApplyResult } from "./apply";
16
16
  import { buildCompactHashlineDiffPreview } from "./diff-preview";
17
17
  import { computeLineHash, HL_BODY_SEP } from "./hash";
18
18
  import { type HashlineInputSection, splitHashlineInputs } from "./input";
19
- import { HashlineMissingHashError, parseHashlineWithWarnings } from "./parser";
19
+ import { HashlineMissingHashError, HashlineMissingLineError, parseHashlineWithWarnings } from "./parser";
20
20
  import { tryRecoverHashlineWithCache } from "./recovery";
21
21
  import type {
22
22
  ExecuteHashlineSingleOptions,
@@ -88,6 +88,17 @@ function formatMissingHashAnchors(fileLines: string[], start: number, end: numbe
88
88
  return [...head, "...", format(end)];
89
89
  }
90
90
 
91
+ function formatMissingHashRetryHeader(
92
+ fileLines: string[],
93
+ start: number,
94
+ end: number,
95
+ opSigil: HashlineMissingHashError["opSigil"],
96
+ ): string {
97
+ const format = (line: number): string => `${line}${computeLineHash(line, fileLines[line - 1] ?? "")}`;
98
+ const anchorRange = start === end ? format(start) : `${format(start)}..${format(end)}`;
99
+ return `${opSigil}${anchorRange}`;
100
+ }
101
+
91
102
  /**
92
103
  * Parse an edit section. When an op names lines by number only, answer with
93
104
  * the current full anchors for those lines so the model can retry without
@@ -101,7 +112,43 @@ async function parseHashlineSection(
101
112
  try {
102
113
  return parseHashlineWithWarnings(diff);
103
114
  } catch (err) {
115
+ if (err instanceof HashlineMissingLineError) {
116
+ // When part of a range has a missing hash, suppress the "Did you mean" suggestion
117
+ // and require the full range to be re-supplied.
118
+ if (err.rangeRaw) {
119
+ throw new Error(
120
+ `Anchor "${err.hash}" lacks its line number. The range ${err.rangeRaw.start}..${err.rangeRaw.end} is incomplete. ` +
121
+ `Expected ${formatFullAnchorRequirement(err.hash)} Re-read the target to get the full range. ` +
122
+ `The edit was NOT applied.`,
123
+ );
124
+ }
125
+ const source = await readHashlineFile(absolutePath, pathText);
126
+ const fileLines = source.exists ? normalizeToLF(stripBom(source.rawContent).text).split("\n") : [];
127
+ let matchCount = 0;
128
+ let matchLine: number | undefined;
129
+ for (let index = 0; index < fileLines.length; index++) {
130
+ if (computeLineHash(index + 1, fileLines[index] ?? "") !== err.hash) continue;
131
+ matchCount++;
132
+ matchLine = index + 1;
133
+ }
134
+ if (matchCount === 1 && matchLine !== undefined) {
135
+ const suggestion = new HashlineMissingLineError(err.hash, matchLine, err.opSigil);
136
+ throw new Error(`${suggestion.message} The edit was NOT applied.`);
137
+ }
138
+ throw new Error(
139
+ `${err.message} Expected ${formatFullAnchorRequirement(err.hash)} ` +
140
+ `The hash matches ${matchCount} lines; re-read the target. The edit was NOT applied.`,
141
+ );
142
+ }
104
143
  if (!(err instanceof HashlineMissingHashError)) throw err;
144
+ // When part of a range has a missing hash, suppress the copy-ready suggestion
145
+ // and require re-reading to get the full range.
146
+ if (err.rangeRaw) {
147
+ throw new Error(
148
+ `${err.message} The anchors are missing from the range ${err.rangeRaw.start}..${err.rangeRaw.end}. ` +
149
+ `Re-read the target to get the full range with hashes. The edit was NOT applied.`,
150
+ );
151
+ }
105
152
  const source = await readHashlineFile(absolutePath, pathText);
106
153
  if (!source.exists) throw err;
107
154
  const fileLines = normalizeToLF(stripBom(source.rawContent).text).split("\n");
@@ -112,8 +159,9 @@ async function parseHashlineSection(
112
159
  );
113
160
  }
114
161
  const anchors = formatMissingHashAnchors(fileLines, start, end);
162
+ const retryHeader = formatMissingHashRetryHeader(fileLines, start, end, err.opSigil);
115
163
  throw new Error(
116
- `${err.message}\nThe edit was NOT applied. Current anchors for ${pathText}:\n${anchors.join("\n")}`,
164
+ `${err.message}\nUse ${retryHeader}\nThe edit was NOT applied. Current anchors for ${pathText}:\n${anchors.join("\n")}`,
117
165
  );
118
166
  }
119
167
  }
@@ -20,35 +20,59 @@ import type { Anchor, HashlineCursor, HashlineEdit } from "./types";
20
20
  const LID_CAPTURE_RE = new RegExp(`^\\s*[>+\\-*]*\\s*${HL_HASH_CAPTURE_RE_RAW}(?:\\|.*)?\\s*$`);
21
21
  const regexEscape = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
22
22
 
23
- const BARE_LINE_REF_RE = /^\s*[>+\-*]*\s*([1-9]\d*)(?:\s*(?:-|\.\.)\s*([1-9]\d*))?\s*$/;
23
+ // Dots in copied text are literal unless followed by a possible range endpoint.
24
+ // Ambiguous copied ranges still require a re-read instead of a one-line retry.
25
+ const BARE_LINE_REF_RE =
26
+ /^\s*[>+\-*]*\s*([1-9]\d*)(?:\s*(?:-|\.\.)\s*([1-9]\d*))?(?:\|(?:(?!\.\.\s*[>+\-*]*\s*(?:[1-9]\d*|[a-z]{2}(?:\||\s*$))).)*)?\s*$/;
27
+ const HASH_ONLY_REF_RE = /^\s*[>+\-*]*\s*([a-z]{2})(?:\|.*)?\s*$/;
28
+ type HashlineOpSigil = typeof HL_OP_INSERT_BEFORE | typeof HL_OP_INSERT_AFTER | typeof HL_OP_REPLACE;
24
29
 
25
30
  /**
26
- * An op referenced lines by number alone, without the content hash. The edit
27
- * is never applied on a line number alone; the executor answers with the
28
- * current full anchors for {@link lines} so the model can retry without an
29
- * extra read.
31
+ * An op referenced lines by number alone. The edit is never applied on a line
32
+ * number alone; the executor answers with the current full anchors so the model
33
+ * can retry without another read.
30
34
  */
31
35
  export class HashlineMissingHashError extends Error {
32
36
  constructor(
33
37
  message: string,
34
38
  readonly lines: { start: number; end: number },
39
+ readonly opSigil: HashlineOpSigil,
40
+ readonly rangeRaw?: { start: string; end: string },
35
41
  ) {
36
42
  super(message);
37
43
  this.name = "HashlineMissingHashError";
38
44
  }
39
45
  }
40
46
 
41
- function parseLid(raw: string, lineNum: number): Anchor {
47
+ export class HashlineMissingLineError extends Error {
48
+ constructor(
49
+ readonly hash: string,
50
+ readonly lineNum: number | undefined,
51
+ readonly opSigil: HashlineOpSigil,
52
+ readonly rangeRaw?: { start: string; end: string },
53
+ ) {
54
+ super(
55
+ lineNum === undefined
56
+ ? `Anchor "${hash}" lacks its line number.`
57
+ : `Anchor "${hash}" lacks its line number. Did you mean ${opSigil}${lineNum}${hash}?`,
58
+ );
59
+ this.name = "HashlineMissingLineError";
60
+ }
61
+ }
62
+
63
+ function parseLid(raw: string, lineNum: number, opSigil: HashlineOpSigil): Anchor {
42
64
  const match = LID_CAPTURE_RE.exec(raw);
43
65
  if (!match) {
66
+ const hashOnly = HASH_ONLY_REF_RE.exec(raw);
67
+ if (hashOnly) throw new HashlineMissingLineError(hashOnly[1], undefined, opSigil);
44
68
  const bare = BARE_LINE_REF_RE.exec(raw);
45
69
  if (bare) {
46
70
  const start = Number.parseInt(bare[1], 10);
47
71
  const end = bare[2] === undefined ? start : Number.parseInt(bare[2], 10);
48
72
  throw new HashlineMissingHashError(
49
- `line ${lineNum}: anchor ${JSON.stringify(raw.trim())} is missing its hash; ` +
50
- `use the full anchor such as ${describeAnchorExamples(String(start))}.`,
73
+ `line ${lineNum}: anchor ${JSON.stringify(raw.trim())} is missing its hash.`,
51
74
  { start: Math.min(start, end), end: Math.max(start, end) },
75
+ opSigil,
52
76
  );
53
77
  }
54
78
  throw new Error(
@@ -64,12 +88,12 @@ interface ParsedRange {
64
88
  end: Anchor;
65
89
  }
66
90
 
67
- function parseRange(raw: string, lineNum: number): ParsedRange {
91
+ function parseRange(raw: string, lineNum: number, opSigil: HashlineOpSigil): ParsedRange {
68
92
  // Reject an all-numeric range (`16..18`, `16-18`) as a whole so the error
69
93
  // carries the full span rather than just its first endpoint.
70
- if (BARE_LINE_REF_RE.test(raw)) parseLid(raw, lineNum);
94
+ if (BARE_LINE_REF_RE.test(raw)) parseLid(raw, lineNum, opSigil);
71
95
  if (!raw.includes("..")) {
72
- const start = parseLid(raw, lineNum);
96
+ const start = parseLid(raw, lineNum, opSigil);
73
97
  return { start, end: { ...start } };
74
98
  }
75
99
  const [startRaw, endRaw, extra] = raw.split("..");
@@ -79,8 +103,30 @@ function parseRange(raw: string, lineNum: number): ParsedRange {
79
103
  `For a one-line edit, repeat the same anchor on both sides.`,
80
104
  );
81
105
  }
82
- const start = parseLid(startRaw, lineNum);
83
- const end = parseLid(endRaw, lineNum);
106
+ let start: Anchor;
107
+ try {
108
+ start = parseLid(startRaw, lineNum, opSigil);
109
+ } catch (err) {
110
+ if (err instanceof HashlineMissingHashError) {
111
+ throw new HashlineMissingHashError(err.message, err.lines, err.opSigil, { start: startRaw, end: endRaw });
112
+ }
113
+ if (err instanceof HashlineMissingLineError) {
114
+ throw new HashlineMissingLineError(err.hash, err.lineNum, err.opSigil, { start: startRaw, end: endRaw });
115
+ }
116
+ throw err;
117
+ }
118
+ let end: Anchor;
119
+ try {
120
+ end = parseLid(endRaw, lineNum, opSigil);
121
+ } catch (err) {
122
+ if (err instanceof HashlineMissingHashError) {
123
+ throw new HashlineMissingHashError(err.message, err.lines, err.opSigil, { start: startRaw, end: endRaw });
124
+ }
125
+ if (err instanceof HashlineMissingLineError) {
126
+ throw new HashlineMissingLineError(err.hash, err.lineNum, err.opSigil, { start: startRaw, end: endRaw });
127
+ }
128
+ throw err;
129
+ }
84
130
  if (end.line < start.line) {
85
131
  throw new Error(`line ${lineNum}: range ${startRaw}..${endRaw} ends before it starts.`);
86
132
  }
@@ -104,7 +150,8 @@ function parseInsertTarget(raw: string, lineNum: number, kind: "before" | "after
104
150
  if (raw === "BOF") return { kind: "bof" };
105
151
  if (raw === "EOF") return { kind: "eof" };
106
152
  const cursorKind = kind === "before" ? "before_anchor" : "after_anchor";
107
- return { kind: cursorKind, anchor: parseLid(raw, lineNum) };
153
+ const opSigil = kind === "before" ? HL_OP_INSERT_BEFORE : HL_OP_INSERT_AFTER;
154
+ return { kind: cursorKind, anchor: parseLid(raw, lineNum, opSigil) };
108
155
  }
109
156
 
110
157
  /**
@@ -137,7 +184,7 @@ const INSERT_BEFORE_OP_RE = new RegExp(
137
184
  const INSERT_AFTER_OP_RE = new RegExp(
138
185
  `^${regexEscape(HL_OP_INSERT_AFTER)}\\s*([^|\\s]+)(?:${HL_BODY_SEP_RE_RAW}(.*))?\\s*$`,
139
186
  );
140
- const REPLACE_OP_RE = new RegExp(`^${regexEscape(HL_OP_REPLACE)}\\s*([^\\s+<\\-=]\\S*)\\s*$`);
187
+ const REPLACE_OP_RE = new RegExp(`^${regexEscape(HL_OP_REPLACE)}\\s*([^\\s+<\\-=]\\S*(?:\\|.*)?)\\s*$`);
141
188
 
142
189
  function isEnvelopeOrAbortMarkerLine(line: string): boolean {
143
190
  const trimmed = line.trimEnd();
@@ -240,7 +287,7 @@ export function parseHashlineWithWarnings(diff: string): { edits: HashlineEdit[]
240
287
 
241
288
  const replaceMatch = REPLACE_OP_RE.exec(line);
242
289
  if (replaceMatch) {
243
- const range = parseRange(replaceMatch[1], lineNum);
290
+ const range = parseRange(replaceMatch[1], lineNum, HL_OP_REPLACE);
244
291
  const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, false);
245
292
  if (payload.length > 0) {
246
293
  for (const text of payload) {