@gajae-code/coding-agent 0.18.0 → 0.18.5

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 (86) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/dist/types/cli/stats-cli.d.ts +0 -5
  3. package/dist/types/config/autorouting-tier-map.d.ts +29 -2
  4. package/dist/types/config/model-registry.d.ts +16 -1
  5. package/dist/types/config/settings-schema.d.ts +2 -2
  6. package/dist/types/exa/mcp-client.d.ts +1 -5
  7. package/dist/types/exec/bash-shell-guardian.d.ts +26 -0
  8. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +1 -0
  9. package/dist/types/hashline/parser.d.ts +28 -5
  10. package/dist/types/modes/acp/acp-agent.d.ts +6 -1
  11. package/dist/types/sdk/acp/adapter.d.ts +3 -1
  12. package/dist/types/sdk/broker/lifecycle.d.ts +6 -1
  13. package/dist/types/sdk/broker/recovery-backoff.d.ts +62 -0
  14. package/dist/types/sdk/broker/runtime.d.ts +20 -0
  15. package/dist/types/sdk/broker/startup-budget.d.ts +5 -1
  16. package/dist/types/sdk/bus/index.d.ts +1 -0
  17. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  18. package/dist/types/sdk/client/client.d.ts +5 -1
  19. package/dist/types/sdk/host/session-runtime.d.ts +7 -0
  20. package/dist/types/sdk/prompt-watchdog.d.ts +1 -1
  21. package/dist/types/session/agent-session.d.ts +2 -0
  22. package/dist/types/task/index.d.ts +0 -1
  23. package/dist/types/tools/bash.d.ts +1 -0
  24. package/dist/types/tools/descriptors.d.ts +5 -0
  25. package/dist/types/tools/eval-backends.d.ts +37 -0
  26. package/dist/types/tools/eval.d.ts +1 -6
  27. package/dist/types/tools/index.d.ts +1 -36
  28. package/dist/types/tools/session-descriptions.d.ts +22 -0
  29. package/package.json +7 -7
  30. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  31. package/src/cli/doctor/managed-link.ts +4 -4
  32. package/src/cli/doctor/repairs.ts +9 -1
  33. package/src/cli/stats-cli.ts +4 -36
  34. package/src/cli-main.ts +4 -8
  35. package/src/cli.ts +4 -8
  36. package/src/commands/commit.ts +2 -2
  37. package/src/commands/sdk.ts +1 -2
  38. package/src/config/autorouting-tier-map.ts +20 -2
  39. package/src/config/model-preset-registry.ts +17 -2
  40. package/src/config/model-profile-activation.ts +3 -1
  41. package/src/config/model-profiles.ts +11 -3
  42. package/src/config/model-registry.ts +45 -17
  43. package/src/config/settings-schema.ts +8 -2
  44. package/src/exa/mcp-client.ts +41 -8
  45. package/src/exec/bash-shell-guardian.ts +214 -48
  46. package/src/gjc-runtime/session-state-sidecar.ts +88 -58
  47. package/src/hashline/execute.ts +51 -3
  48. package/src/hashline/parser.ts +63 -16
  49. package/src/internal-urls/docs-index.generated.ts +25 -25
  50. package/src/modes/acp/acp-agent.ts +136 -20
  51. package/src/modes/controllers/ssh-command-controller.ts +3 -1
  52. package/src/modes/interactive-mode.ts +10 -0
  53. package/src/modes/utils/context-usage.ts +2 -6
  54. package/src/prompts/tools/task.md +8 -27
  55. package/src/sdk/acp/adapter.ts +38 -3
  56. package/src/sdk/broker/doctor-restart.ts +38 -3
  57. package/src/sdk/broker/lifecycle.ts +408 -218
  58. package/src/sdk/broker/recovery-backoff.ts +135 -0
  59. package/src/sdk/broker/runtime.ts +83 -0
  60. package/src/sdk/broker/startup-budget.ts +6 -1
  61. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  62. package/src/sdk/bus/index.ts +308 -22
  63. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  64. package/src/sdk/client/client.ts +53 -35
  65. package/src/sdk/host/session-runtime.ts +99 -3
  66. package/src/sdk/prompt-watchdog.ts +17 -4
  67. package/src/sdk/protocol/operation-inventory.generated.json +11 -0
  68. package/src/session/agent-session.ts +79 -17
  69. package/src/session/internal/managed-session-storage.ts +23 -0
  70. package/src/task/index.ts +21 -66
  71. package/src/tools/bash.ts +39 -0
  72. package/src/tools/descriptors.ts +27 -1
  73. package/src/tools/eval-backends.ts +95 -0
  74. package/src/tools/eval.ts +3 -15
  75. package/src/tools/index.ts +9 -88
  76. package/src/tools/search.ts +2 -7
  77. package/src/tools/session-descriptions.ts +135 -0
  78. package/src/tools/tool-catalog.generated.ts +1 -1
  79. package/dist/types/cli/grep-cli.d.ts +0 -17
  80. package/dist/types/cli/shell-cli.d.ts +0 -8
  81. package/dist/types/commands/grep.d.ts +0 -42
  82. package/dist/types/commands/shell.d.ts +0 -21
  83. package/src/cli/grep-cli.ts +0 -160
  84. package/src/cli/shell-cli.ts +0 -176
  85. package/src/commands/grep.ts +0 -48
  86. package/src/commands/shell.ts +0 -29
@@ -187,7 +187,16 @@ export function formatSearchResults(data: ExaSearchResponse): string {
187
187
  * Format a non-search MCP response as human-readable text.
188
188
  * Handles objects, arrays, primitives, and common MCP response shapes.
189
189
  */
190
- export function formatGenericResponse(data: unknown): string {
190
+ // Nesting depth is bounded. Each object level re-indents the entire formatted
191
+ // subtree, so cost grows super-linearly with depth: 500/1000/2000/4000/8000
192
+ // levels cost 60ms/227ms/730ms/5.9s/94s. A tool-call payload is whatever the
193
+ // remote MCP server returned, and `MCP_MAX_CONTENT_BYTES` does not bound depth
194
+ // — 8000 levels of `{"a":` is under 47 KB. The limit matches the one the
195
+ // session-import walker already applies (`sanitizeImportedValue`, depth > 64).
196
+ const MAX_GENERIC_RESPONSE_DEPTH = 64;
197
+
198
+ export function formatGenericResponse(data: unknown, depth = 0): string {
199
+ if (depth > MAX_GENERIC_RESPONSE_DEPTH) return "[depth-limit]";
191
200
  if (data === null || data === undefined) return "No result.";
192
201
  if (typeof data === "string") return data;
193
202
  if (typeof data === "number" || typeof data === "boolean") return String(data);
@@ -199,14 +208,22 @@ export function formatGenericResponse(data: unknown): string {
199
208
  const item = data[i];
200
209
  if (typeof item === "object" && item !== null) {
201
210
  const record = item as Record<string, unknown>;
202
- const title = (record.title ?? record.name ?? record.id ?? `Item ${i + 1}`) as string;
211
+ const rawTitle = record.title ?? record.name ?? record.id;
212
+ // A remote title is unvalidated JSON: stringify non-strings through the
213
+ // depth-bounded path instead of template coercion, which recurses unbounded.
214
+ const title =
215
+ rawTitle === undefined || rawTitle === null
216
+ ? `Item ${i + 1}`
217
+ : typeof rawTitle === "string"
218
+ ? rawTitle
219
+ : formatValue(rawTitle, depth + 2);
203
220
  parts.push(`\n### ${title}`);
204
221
  for (const [k, v] of Object.entries(record)) {
205
222
  if (["title", "name", "id"].includes(k)) continue;
206
- parts.push(`- **${k}:** ${formatValue(v)}`);
223
+ parts.push(`- **${k}:** ${formatValue(v, depth + 2)}`);
207
224
  }
208
225
  } else {
209
- parts.push(`- ${formatValue(item)}`);
226
+ parts.push(`- ${formatValue(item, depth + 1)}`);
210
227
  }
211
228
  }
212
229
  return parts.join("\n");
@@ -231,10 +248,10 @@ export function formatGenericResponse(data: unknown): string {
231
248
  if (k === "content") continue; // handled above
232
249
  if (v === null || v === undefined) continue;
233
250
  if (typeof v === "object") {
234
- const formatted = formatGenericResponse(v);
251
+ const formatted = formatGenericResponse(v, depth + 1);
235
252
  if (formatted) lines.push(`- **${k}:**\n${indent(formatted, 2)}`);
236
253
  } else {
237
- lines.push(`- **${k}:** ${formatValue(v)}`);
254
+ lines.push(`- **${k}:** ${formatValue(v, depth + 1)}`);
238
255
  }
239
256
  }
240
257
  return lines.join("\n") || "(empty)";
@@ -243,12 +260,28 @@ export function formatGenericResponse(data: unknown): string {
243
260
  return String(data);
244
261
  }
245
262
 
246
- function formatValue(v: unknown): string {
263
+ function formatValue(v: unknown, depth: number): string {
247
264
  if (v === null || v === undefined) return "—";
248
- if (typeof v === "object") return JSON.stringify(v);
265
+ if (typeof v === "object") return JSON.stringify(truncateDepth(v, depth));
249
266
  return String(v);
250
267
  }
251
268
 
269
+ /**
270
+ * Copies a value, replacing anything nested past MAX_GENERIC_RESPONSE_DEPTH with
271
+ * "[depth-limit]", so JSON.stringify never recurses through a remote payload's
272
+ * unbounded structure.
273
+ */
274
+ function truncateDepth(value: unknown, depth: number): unknown {
275
+ if (value === null || typeof value !== "object") return value;
276
+ if (depth > MAX_GENERIC_RESPONSE_DEPTH) return "[depth-limit]";
277
+ if (Array.isArray(value)) return value.map(item => truncateDepth(item, depth + 1));
278
+ // Null prototype: an own "__proto__" key from a parsed payload must stay an
279
+ // own data property instead of hitting the Object.prototype setter.
280
+ const copy: Record<string, unknown> = Object.create(null);
281
+ for (const [key, child] of Object.entries(value)) copy[key] = truncateDepth(child, depth + 1);
282
+ return copy;
283
+ }
284
+
252
285
  function indent(text: string, spaces: number): string {
253
286
  const pad = " ".repeat(spaces);
254
287
  return text
@@ -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", {
@@ -175,18 +194,113 @@ function createDarwinAncestryTracker(owned: Map<string, NativeProcess>): DarwinA
175
194
  },
176
195
  });
177
196
  const knownUniqueIds = new Set<bigint>();
197
+ // Ancestry evidence (knownUniqueIds) and successful retention are tracked
198
+ // separately: a descendant whose process validation fails stays known, so its
199
+ // own children still chain, but it is retried on every poll until retained.
200
+ const retainedUniqueIds = new Set<bigint>();
178
201
  const { Process } = require("@gajae-code/natives") as NativeProcessBindings;
179
- const uniqueIdentity = (pid: number): { uniqueId: bigint; parentUniqueId: bigint } | undefined => {
180
- const info = new Uint8Array(56);
181
- if (proc.symbols.proc_pidinfo(pid, 17, 0, ptr(info), info.byteLength) !== info.byteLength) return undefined;
182
- const view = new DataView(info.buffer);
183
- return { uniqueId: view.getBigUint64(16, true), parentUniqueId: view.getBigUint64(24, true) };
202
+ const fromPid = deps.fromPid ?? ((pid: number) => Process.fromPid(pid));
203
+ const observe = deps.observe ?? ((pid: number) => Process.observe(pid));
204
+ // Reuse buffer across poll iterations to avoid allocations inside the loop.
205
+ const identityBuffer = new Uint8Array(56);
206
+ const identityView = new DataView(identityBuffer.buffer);
207
+ const defaultUniqueIdentity = (pid: number): { uniqueId: bigint; parentUniqueId: bigint } | undefined => {
208
+ const bytes = proc.symbols.proc_pidinfo(pid, 17, 0, ptr(identityBuffer), identityBuffer.byteLength);
209
+ if (bytes !== identityBuffer.byteLength) return undefined;
210
+ return {
211
+ uniqueId: identityView.getBigUint64(16, true),
212
+ parentUniqueId: identityView.getBigUint64(24, true),
213
+ };
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
+ }
184
256
  };
185
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 => {
186
298
  const identity = signedUniqueId === undefined ? uniqueIdentity(processRef.pid) : undefined;
187
299
  const uniqueId = signedUniqueId ?? identity?.uniqueId;
300
+ // Guardian registration is strict: must have a unique id
188
301
  if (uniqueId === undefined) return false;
189
302
  knownUniqueIds.add(uniqueId);
303
+ retainedUniqueIds.add(uniqueId);
190
304
  retainOwnedProcess(owned, processRef);
191
305
  return true;
192
306
  };
@@ -195,28 +309,46 @@ function createDarwinAncestryTracker(owned: Map<string, NativeProcess>): DarwinA
195
309
  knownUniqueIds.add(uniqueId);
196
310
  },
197
311
  track,
312
+ trackGuardian,
198
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
+ }
199
325
  const capacity = proc.symbols.proc_listallpids(null, 0);
200
326
  if (capacity <= 0) return false;
201
327
  const pids = new Int32Array(capacity + 64);
202
328
  const count = proc.symbols.proc_listallpids(ptr(pids), pids.byteLength);
203
329
  if (count <= 0) return false;
204
- const candidates = new Map<
205
- number,
206
- { uniqueId: bigint; parentUniqueId: bigint; processRef: NativeProcess }
207
- >();
330
+ // First pass: collect identities for all pids, avoiding Process.fromPid calls.
331
+ const identities = new Map<number, { uniqueId: bigint; parentUniqueId: bigint }>();
208
332
  for (let index = 0; index < count; index++) {
209
333
  const pid = pids[index]!;
210
- const processRef = Process.fromPid(pid);
211
- if (!processRef) continue;
212
334
  const identity = uniqueIdentity(pid);
213
- const after = Process.fromPid(pid);
214
- if (identity && after?.incarnation === processRef.incarnation) {
215
- candidates.set(pid, { ...identity, processRef });
216
- }
335
+ if (identity) identities.set(pid, identity);
217
336
  }
218
- for (const pid of extendOwnedDarwinAncestry(knownUniqueIds, candidates)) {
219
- retainOwnedProcess(owned, candidates.get(pid)!.processRef);
337
+ // Extend ancestry evidence, then validate every listed pid that is known but
338
+ // not yet retained: new descendants plus earlier ones whose Process.fromPid
339
+ // check failed (it can return null for a live pid it could not query). A pid
340
+ // whose identity changed between the listing and the check was reused; skip it.
341
+ extendOwnedDarwinAncestry(knownUniqueIds, identities);
342
+ for (const [pid, listed] of identities) {
343
+ if (!knownUniqueIds.has(listed.uniqueId) || retainedUniqueIds.has(listed.uniqueId)) continue;
344
+ const before = fromPid(pid);
345
+ if (!before) continue;
346
+ const identity = uniqueIdentity(pid);
347
+ const after = fromPid(pid);
348
+ if (identity?.uniqueId === listed.uniqueId && after?.incarnation === before.incarnation) {
349
+ retainOwnedProcess(owned, after);
350
+ retainedUniqueIds.add(listed.uniqueId);
351
+ }
220
352
  }
221
353
  return true;
222
354
  },
@@ -259,7 +391,7 @@ export async function runBashShellGuardian(): Promise<void> {
259
391
  if (darwinTracker) {
260
392
  const { Process } = require("@gajae-code/natives") as NativeProcessBindings;
261
393
  const guardian = Process.fromPid(process.pid);
262
- if (!guardian || !darwinTracker.track(guardian)) {
394
+ if (!guardian || !darwinTracker.trackGuardian(guardian)) {
263
395
  darwinTracker.close();
264
396
  await ledger.close();
265
397
  await fs.rm(ownershipFilePath, { force: true });
@@ -290,6 +422,8 @@ export async function runBashShellGuardian(): Promise<void> {
290
422
  input.once("close", () => supervisor.stdin.end());
291
423
  let cleaning: Promise<void> | undefined;
292
424
  let ledgerBuffer = "";
425
+ let pendingLedgerLines: string[] = [];
426
+ const pendingAttempts = new Map<string, number>();
293
427
  let ownershipScan = Promise.resolve(true);
294
428
  const scanOwnership = (): Promise<boolean> => {
295
429
  ownershipScan = ownershipScan.then(async previousOk => {
@@ -301,11 +435,23 @@ export async function runBashShellGuardian(): Promise<void> {
301
435
  return false;
302
436
  }
303
437
  ledgerBuffer += content;
304
- const lines = ledgerBuffer.split("\n");
305
- ledgerBuffer = lines.pop() ?? "";
438
+ const fresh = ledgerBuffer.split("\n");
439
+ ledgerBuffer = fresh.pop() ?? "";
440
+ const lines = [...pendingLedgerLines, ...fresh];
441
+ pendingLedgerLines = [];
306
442
  for (const line of lines) {
307
443
  if (!line) continue;
308
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
+ }
309
455
  if (owned?.darwinUniqueId && darwinTracker) darwinTracker.seed(owned.darwinUniqueId);
310
456
  if (owned?.processRef && darwinTracker && !darwinTracker.track(owned.processRef, owned.darwinUniqueId))
311
457
  return false;
@@ -319,28 +465,48 @@ export async function runBashShellGuardian(): Promise<void> {
319
465
  return ownershipScan;
320
466
  };
321
467
  let periodicScanActive = false;
322
- const ownershipScanTimer = darwinTracker
323
- ? setInterval(() => {
324
- if (periodicScanActive) return;
325
- periodicScanActive = true;
326
- void scanOwnership()
327
- .then(ok => {
328
- if (ok || supervisor.exitCode !== null || supervisor.signalCode !== null) return;
329
- if (supervisor.pid) {
330
- try {
331
- process.kill(-supervisor.pid, "SIGKILL");
332
- } catch {}
333
- }
334
- supervisor.kill("SIGKILL");
335
- })
336
- .finally(() => {
337
- periodicScanActive = false;
338
- });
339
- }, 100)
340
- : 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
+ }
341
506
  const cleanup = (): Promise<void> => {
342
507
  cleaning ??= (async () => {
343
508
  if (ownershipScanTimer) clearInterval(ownershipScanTimer);
509
+ ledgerWatcher?.close();
344
510
  let trackingOk = await scanOwnership();
345
511
  if (supervisor.exitCode === null && supervisor.signalCode === null) {
346
512
  if (process.platform !== "win32" && supervisor.pid) {
@@ -12,6 +12,7 @@ import {
12
12
  import { normalizePathForComparison, postmortem } from "@gajae-code/utils";
13
13
  import { withFileLock } from "../config/file-lock";
14
14
  import {
15
+ type CoordinatorAtomicWriteOptions,
15
16
  ensureCoordinatorDirectory,
16
17
  syncCoordinatorDirectory,
17
18
  syncCoordinatorFile,
@@ -177,8 +178,25 @@ export const __sessionStateSidecarTestHooks: {
177
178
  beforeRescopeJournalWrite?: (cwd: string) => void | Promise<void>;
178
179
  beforePersistFromEvent?: (eventType: string, cwd: string) => void | Promise<void>;
179
180
  beforeRescopePublish?: () => void | Promise<void>;
181
+ disableCoordinatorFileSyncForTests?: boolean;
180
182
  } = {};
181
183
 
184
+ /** Create coordinator atomic write options with test hook support for disabling fsync. */
185
+ function coordinatorAtomicWriteOptionsForTests<T extends CoordinatorAtomicWriteOptions>(
186
+ overrides?: T,
187
+ ): CoordinatorAtomicWriteOptions {
188
+ const options = overrides ?? ({} as T);
189
+ if (!__sessionStateSidecarTestHooks.disableCoordinatorFileSyncForTests) {
190
+ return options;
191
+ }
192
+ return {
193
+ ...options,
194
+ syncFile: async () => {
195
+ /* skip fsync in tests */
196
+ },
197
+ };
198
+ }
199
+
182
200
  interface RuntimeStateEvent {
183
201
  type: string;
184
202
  messages?: unknown[];
@@ -1806,35 +1824,39 @@ async function writeStateFileSyncConditional(
1806
1824
  expectedDestination: NativeExactFileIdentity | null,
1807
1825
  ): Promise<void> {
1808
1826
  const contents = `${JSON.stringify(finalizeSidecarPayload(payload, keyId))}\n`;
1809
- await writeCoordinatorAtomic(stateFile, contents, {
1810
- rename: async (source, destination) => {
1811
- if (!expectedDestination) {
1812
- const published = renameNoReplacePath(source, destination);
1813
- if (!published.ok) throw new PreviousRuntimeStateReadError();
1814
- return;
1815
- }
1816
- const stat = fsSync.lstatSync(source, { bigint: true });
1817
- const parent = fsSync.lstatSync(path.dirname(source), { bigint: true });
1818
- if (!stat.isFile() || stat.isSymbolicLink() || !parent.isDirectory() || parent.isSymbolicLink())
1819
- throw new PreviousRuntimeStateReadError();
1820
- const replaced = exactReplacePath(
1821
- source,
1822
- destination,
1823
- {
1824
- dev: stat.dev,
1825
- ino: stat.ino,
1826
- nlink: stat.nlink,
1827
- parentDev: parent.dev,
1828
- parentIno: parent.ino,
1829
- size: stat.size,
1830
- mtimeNs: stat.mtimeNs,
1831
- sha256: createHash("sha256").update(contents).digest("hex"),
1832
- },
1833
- expectedDestination,
1834
- );
1835
- if (!replaced.ok) throw new PreviousRuntimeStateReadError();
1836
- },
1837
- });
1827
+ await writeCoordinatorAtomic(
1828
+ stateFile,
1829
+ contents,
1830
+ coordinatorAtomicWriteOptionsForTests({
1831
+ rename: async (source: string, destination: string) => {
1832
+ if (!expectedDestination) {
1833
+ const published = renameNoReplacePath(source, destination);
1834
+ if (!published.ok) throw new PreviousRuntimeStateReadError();
1835
+ return;
1836
+ }
1837
+ const stat = fsSync.lstatSync(source, { bigint: true });
1838
+ const parent = fsSync.lstatSync(path.dirname(source), { bigint: true });
1839
+ if (!stat.isFile() || stat.isSymbolicLink() || !parent.isDirectory() || parent.isSymbolicLink())
1840
+ throw new PreviousRuntimeStateReadError();
1841
+ const replaced = exactReplacePath(
1842
+ source,
1843
+ destination,
1844
+ {
1845
+ dev: stat.dev,
1846
+ ino: stat.ino,
1847
+ nlink: stat.nlink,
1848
+ parentDev: parent.dev,
1849
+ parentIno: parent.ino,
1850
+ size: stat.size,
1851
+ mtimeNs: stat.mtimeNs,
1852
+ sha256: createHash("sha256").update(contents).digest("hex"),
1853
+ },
1854
+ expectedDestination,
1855
+ );
1856
+ if (!replaced.ok) throw new PreviousRuntimeStateReadError();
1857
+ },
1858
+ }),
1859
+ );
1838
1860
  }
1839
1861
 
1840
1862
  /**
@@ -1967,7 +1989,7 @@ async function withStateFileLocks<T>(stateFiles: readonly string[], operation: (
1967
1989
  }
1968
1990
 
1969
1991
  async function writeStateFile(stateFile: string, payload: Record<string, unknown>): Promise<void> {
1970
- await writeCoordinatorAtomic(stateFile, `${JSON.stringify(payload)}\n`);
1992
+ await writeCoordinatorAtomic(stateFile, `${JSON.stringify(payload)}\n`, coordinatorAtomicWriteOptionsForTests());
1971
1993
  }
1972
1994
 
1973
1995
  function contextWithManagedOwnerGeneration(context: RuntimeStateContext): RuntimeStateContext {
@@ -2471,12 +2493,16 @@ export async function prepareCoordinatorRuntimeStateRescope(input: {
2471
2493
  ).toString("base64");
2472
2494
  }
2473
2495
  const writeJournal = async (journalFile: string): Promise<void> => {
2474
- await writeCoordinatorAtomic(journalFile, `${JSON.stringify(journal)}\n`, {
2475
- rename: async (source, destination) => {
2476
- const published = renameNoReplacePath(source, destination);
2477
- if (!published.ok) throw new PreviousRuntimeStateReadError();
2478
- },
2479
- });
2496
+ await writeCoordinatorAtomic(
2497
+ journalFile,
2498
+ `${JSON.stringify(journal)}\n`,
2499
+ coordinatorAtomicWriteOptionsForTests({
2500
+ rename: async (source: string, destination: string) => {
2501
+ const published = renameNoReplacePath(source, destination);
2502
+ if (!published.ok) throw new PreviousRuntimeStateReadError();
2503
+ },
2504
+ }),
2505
+ );
2480
2506
  };
2481
2507
  await __sessionStateSidecarTestHooks.beforeRescopeJournalWrite?.(previousCwd);
2482
2508
  assertPinnedDirectoryIdentity(input.previousCwd, previousCwdIdentity);
@@ -2532,28 +2558,32 @@ export async function markCoordinatorRuntimeStateRescopePublishing(
2532
2558
  ).toString("base64");
2533
2559
  }
2534
2560
  const contents = `${JSON.stringify(journal)}\n`;
2535
- await writeCoordinatorAtomic(journalFile, contents, {
2536
- rename: async (source, destination) => {
2537
- const sourceStat = fsSync.lstatSync(source, { bigint: true });
2538
- const sourceParent = fsSync.lstatSync(path.dirname(source), { bigint: true });
2539
- const replaced = exactReplacePath(
2540
- source,
2541
- destination,
2542
- {
2543
- dev: sourceStat.dev,
2544
- ino: sourceStat.ino,
2545
- nlink: sourceStat.nlink,
2546
- parentDev: sourceParent.dev,
2547
- parentIno: sourceParent.ino,
2548
- size: sourceStat.size,
2549
- mtimeNs: sourceStat.mtimeNs,
2550
- sha256: createHash("sha256").update(contents).digest("hex"),
2551
- },
2552
- observed.identity,
2553
- );
2554
- if (!replaced.ok) throw new PreviousRuntimeStateReadError();
2555
- },
2556
- });
2561
+ await writeCoordinatorAtomic(
2562
+ journalFile,
2563
+ contents,
2564
+ coordinatorAtomicWriteOptionsForTests({
2565
+ rename: async (source: string, destination: string) => {
2566
+ const sourceStat = fsSync.lstatSync(source, { bigint: true });
2567
+ const sourceParent = fsSync.lstatSync(path.dirname(source), { bigint: true });
2568
+ const replaced = exactReplacePath(
2569
+ source,
2570
+ destination,
2571
+ {
2572
+ dev: sourceStat.dev,
2573
+ ino: sourceStat.ino,
2574
+ nlink: sourceStat.nlink,
2575
+ parentDev: sourceParent.dev,
2576
+ parentIno: sourceParent.ino,
2577
+ size: sourceStat.size,
2578
+ mtimeNs: sourceStat.mtimeNs,
2579
+ sha256: createHash("sha256").update(contents).digest("hex"),
2580
+ },
2581
+ observed.identity,
2582
+ );
2583
+ if (!replaced.ok) throw new PreviousRuntimeStateReadError();
2584
+ },
2585
+ }),
2586
+ );
2557
2587
  }
2558
2588
  }
2559
2589