@gajae-code/coding-agent 0.18.1 → 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 (55) hide show
  1. package/CHANGELOG.md +60 -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/exa/mcp-client.d.ts +1 -5
  6. package/dist/types/exec/bash-shell-guardian.d.ts +26 -0
  7. package/dist/types/hashline/parser.d.ts +28 -5
  8. package/dist/types/modes/acp/acp-agent.d.ts +6 -1
  9. package/dist/types/sdk/broker/lifecycle.d.ts +6 -1
  10. package/dist/types/sdk/broker/recovery-backoff.d.ts +62 -0
  11. package/dist/types/sdk/broker/runtime.d.ts +20 -0
  12. package/dist/types/sdk/broker/startup-budget.d.ts +5 -1
  13. package/dist/types/sdk/bus/index.d.ts +1 -0
  14. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  15. package/dist/types/sdk/host/session-runtime.d.ts +7 -0
  16. package/dist/types/sdk/prompt-watchdog.d.ts +1 -1
  17. package/dist/types/tools/bash.d.ts +1 -0
  18. package/package.json +7 -7
  19. package/src/cli/stats-cli.ts +4 -36
  20. package/src/cli-main.ts +4 -8
  21. package/src/cli.ts +4 -8
  22. package/src/commands/commit.ts +2 -2
  23. package/src/commands/sdk.ts +1 -2
  24. package/src/config/autorouting-tier-map.ts +20 -2
  25. package/src/config/model-preset-registry.ts +17 -2
  26. package/src/config/model-profile-activation.ts +3 -1
  27. package/src/config/model-profiles.ts +11 -3
  28. package/src/config/model-registry.ts +45 -17
  29. package/src/exa/mcp-client.ts +41 -8
  30. package/src/exec/bash-shell-guardian.ts +184 -34
  31. package/src/hashline/execute.ts +51 -3
  32. package/src/hashline/parser.ts +63 -16
  33. package/src/internal-urls/docs-index.generated.ts +22 -22
  34. package/src/modes/acp/acp-agent.ts +39 -4
  35. package/src/modes/controllers/ssh-command-controller.ts +3 -1
  36. package/src/sdk/broker/lifecycle.ts +408 -218
  37. package/src/sdk/broker/recovery-backoff.ts +135 -0
  38. package/src/sdk/broker/runtime.ts +83 -0
  39. package/src/sdk/broker/startup-budget.ts +6 -1
  40. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  41. package/src/sdk/bus/index.ts +305 -19
  42. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  43. package/src/sdk/host/session-runtime.ts +99 -3
  44. package/src/sdk/prompt-watchdog.ts +17 -4
  45. package/src/session/agent-session.ts +14 -2
  46. package/src/session/internal/managed-session-storage.ts +23 -0
  47. package/src/tools/bash.ts +39 -0
  48. package/dist/types/cli/grep-cli.d.ts +0 -17
  49. package/dist/types/cli/shell-cli.d.ts +0 -8
  50. package/dist/types/commands/grep.d.ts +0 -42
  51. package/dist/types/commands/shell.d.ts +0 -21
  52. package/src/cli/grep-cli.ts +0 -160
  53. package/src/cli/shell-cli.ts +0 -176
  54. package/src/commands/grep.ts +0 -48
  55. package/src/commands/shell.ts +0 -29
@@ -45,8 +45,9 @@ export const TIER_MAP_VERSION = 1;
45
45
 
46
46
  export const CURATED_TIER_LABELS = {
47
47
  "anthropic/claude-haiku-4-5": [{ tier: "fast", rank: 1 }],
48
- "anthropic/claude-sonnet-5": [{ tier: "balanced", rank: 1 }],
49
- "anthropic/claude-sonnet-4-6": [{ tier: "balanced", rank: 2 }],
48
+ "anthropic/claude-sonnet-5-5": [{ tier: "balanced", rank: 1 }],
49
+ "anthropic/claude-sonnet-5": [{ tier: "balanced", rank: 2 }],
50
+ "anthropic/claude-sonnet-4-6": [{ tier: "balanced", rank: 3 }],
50
51
  "anthropic/claude-opus-5-5": [{ tier: "strong", effort: "high", rank: 1 }],
51
52
  "anthropic/claude-opus-5": [{ tier: "strong", effort: "high", rank: 2 }],
52
53
  "anthropic/claude-opus-4-8": [{ tier: "strong", effort: "high", rank: 3 }],
@@ -55,6 +56,7 @@ export const CURATED_TIER_LABELS = {
55
56
  { tier: "balanced", effort: "medium", rank: 1 },
56
57
  ],
57
58
  "openai-codex/gpt-6-sol": [{ tier: "strong", effort: "high", rank: 1 }],
59
+ "openai-codex/gpt-6.1-sol": [{ tier: "strong", effort: "high", rank: 2 }],
58
60
  "google/gemini-3.5-flash-lite": [{ tier: "fast", rank: 1 }],
59
61
  "google/gemini-2.5-flash-lite": [{ tier: "fast", rank: 2 }],
60
62
  "google/gemini-3.5-flash": [{ tier: "balanced", rank: 1 }],
@@ -75,6 +77,22 @@ export const CURATED_TIER_LABELS = {
75
77
 
76
78
  /** Generated from packages/ai/src/models.json at feature land: 3936 in-scope baseline skips. */
77
79
  export const TIER_MAP_SKIP_LIST = {
80
+ "amazon-bedrock/anthropic.claude-sonnet-5-5": { rationale: "Sonnet 5.5 catalog addition (#6111); not yet curated" },
81
+ "amazon-bedrock/au.anthropic.claude-sonnet-5-5": {
82
+ rationale: "Sonnet 5.5 catalog addition (#6111); not yet curated",
83
+ },
84
+ "amazon-bedrock/eu.anthropic.claude-sonnet-5-5": {
85
+ rationale: "Sonnet 5.5 catalog addition (#6111); not yet curated",
86
+ },
87
+ "amazon-bedrock/global.anthropic.claude-sonnet-5-5": {
88
+ rationale: "Sonnet 5.5 catalog addition (#6111); not yet curated",
89
+ },
90
+ "amazon-bedrock/jp.anthropic.claude-sonnet-5-5": {
91
+ rationale: "Sonnet 5.5 catalog addition (#6111); not yet curated",
92
+ },
93
+ "amazon-bedrock/us.anthropic.claude-sonnet-5-5": {
94
+ rationale: "Sonnet 5.5 catalog addition (#6111); not yet curated",
95
+ },
78
96
  "kiro/claude-opus-5-5": { rationale: "post-rebase catalog addition from dev; not yet curated" },
79
97
  "kiro/claude-opus-5.5": { rationale: "post-rebase catalog addition from dev; not yet curated" },
80
98
  "alibaba-token-plan/qwen3.8-flash": { rationale: "post-rebase catalog addition from dev; not yet curated" },
@@ -652,6 +652,9 @@ function assertRegistryIdentityPolicy(value: unknown): void {
652
652
  }
653
653
 
654
654
  function assertCanonicalUnicode(value: string): void {
655
+ // Native well-formedness check first; the scan below only runs to name the
656
+ // offending surrogate when the string is malformed.
657
+ if (value.isWellFormed()) return;
655
658
  for (let index = 0; index < value.length; index++) {
656
659
  const code = value.charCodeAt(index);
657
660
  if (code >= 0xd800 && code <= 0xdbff) {
@@ -665,6 +668,18 @@ function assertCanonicalUnicode(value: string): void {
665
668
  }
666
669
  }
667
670
 
671
+ /** Characters JSON.stringify escapes in strings (lone surrogates are rejected earlier). */
672
+ const JSON_STRING_ESCAPES = /["\\\u0000-\u001f]/;
673
+
674
+ /**
675
+ * Quote a string exactly as JSON.stringify does. Registry documents are almost
676
+ * entirely plain ASCII identifiers, so skipping the per-call JSON.stringify for
677
+ * strings with nothing to escape removes most of the serializer's cost.
678
+ */
679
+ function quoteCanonicalString(value: string): string {
680
+ return JSON_STRING_ESCAPES.test(value) ? JSON.stringify(value) : `"${value}"`;
681
+ }
682
+
668
683
  function serializeCanonicalJson(value: unknown, stack: Set<object>): string {
669
684
  if (value === null) return "null";
670
685
  if (typeof value === "boolean") return value ? "true" : "false";
@@ -674,7 +689,7 @@ function serializeCanonicalJson(value: unknown, stack: Set<object>): string {
674
689
  }
675
690
  if (typeof value === "string") {
676
691
  assertCanonicalUnicode(value);
677
- return JSON.stringify(value);
692
+ return quoteCanonicalString(value);
678
693
  }
679
694
  if (typeof value !== "object") throw new TypeError(`Canonical JSON rejects ${typeof value}.`);
680
695
  if (stack.has(value)) throw new TypeError("Canonical JSON rejects cycles.");
@@ -694,7 +709,7 @@ function serializeCanonicalJson(value: unknown, stack: Set<object>): string {
694
709
  const entries: string[] = [];
695
710
  for (const key of Object.keys(record).sort()) {
696
711
  assertCanonicalUnicode(key);
697
- entries.push(`${JSON.stringify(key)}:${serializeCanonicalJson(record[key], stack)}`);
712
+ entries.push(`${quoteCanonicalString(key)}:${serializeCanonicalJson(record[key], stack)}`);
698
713
  }
699
714
  return `{${entries.join(",")}}`;
700
715
  } finally {
@@ -17,6 +17,7 @@ import {
17
17
  export { resolveModelProfileName } from "./model-profile-contract";
18
18
 
19
19
  import {
20
+ createRegistrySelectorIndex,
20
21
  GJC_MODEL_ASSIGNMENT_TARGETS,
21
22
  type GjcModelAssignmentTargetId,
22
23
  isAuthenticated,
@@ -675,6 +676,7 @@ export async function resolveMissingSessionModelRecovery(options: {
675
676
  options.skips.every(skip => skip.reason === "unknown_model");
676
677
  if (!allSelectorsUnknown) return undefined;
677
678
  const fullCatalog = options.modelRegistry.getAll();
679
+ const catalogIndex = createRegistrySelectorIndex(fullCatalog);
678
680
  const savedSelectorsMissingFromCatalog = resolveConfiguredModelPatterns(
679
681
  options.defaultEntries,
680
682
  options.settings,
@@ -688,7 +690,7 @@ export async function resolveMissingSessionModelRecovery(options: {
688
690
  }).model
689
691
  )
690
692
  return false;
691
- return !registrySelectorResolvesToModel(selector, fullCatalog);
693
+ return !registrySelectorResolvesToModel(selector, catalogIndex);
692
694
  });
693
695
  const savedConcreteDefault = options.savedDefault ? parseModelString(options.savedDefault) : undefined;
694
696
  const savedConcreteDefaultMissingFromCatalog =
@@ -93,6 +93,13 @@ export const BUILTIN_MODEL_PROFILES: readonly ModelProfileDefinition[] = [
93
93
  critic: "openai-codex/gpt-6-sol:max",
94
94
  architect: "openai-codex/gpt-6-sol:xhigh",
95
95
  }),
96
+ profile("codex-sol61", ["openai-codex"], {
97
+ default: "openai-codex/gpt-6.1-sol:medium",
98
+ executor: "openai-codex/gpt-6.1-sol:medium",
99
+ planner: "openai-codex/gpt-6.1-sol:high",
100
+ critic: "openai-codex/gpt-6.1-sol:xhigh",
101
+ architect: "openai-codex/gpt-6-astra:xhigh",
102
+ }),
96
103
  profile("lunamaxxing", ["openai-codex"], {
97
104
  default: "openai-codex/gpt-6-luna:medium",
98
105
  executor: "openai-codex/gpt-6-luna:xhigh",
@@ -256,14 +263,14 @@ export const BUILTIN_MODEL_PROFILES: readonly ModelProfileDefinition[] = [
256
263
  }),
257
264
  profile("claude-opus", ["anthropic"], {
258
265
  default: ["anthropic/claude-opus-5-5:medium", "anthropic/claude-opus-4-6:xhigh"],
259
- executor: "anthropic/claude-sonnet-5",
266
+ executor: "anthropic/claude-sonnet-5-5",
260
267
  planner: ["anthropic/claude-opus-5-5:medium", "anthropic/claude-opus-4-6:low"],
261
268
  critic: ["anthropic/claude-opus-5-5:high", "anthropic/claude-opus-4-6:high"],
262
269
  architect: ["anthropic/claude-opus-5-5:high", "anthropic/claude-opus-4-6:xhigh"],
263
270
  }),
264
271
  profile("claude-fable", ["anthropic"], {
265
272
  default: "anthropic/claude-fable-5-1:xhigh",
266
- executor: "anthropic/claude-sonnet-5",
273
+ executor: "anthropic/claude-sonnet-5-5",
267
274
  planner: "anthropic/claude-fable-5-1:low",
268
275
  critic: "anthropic/claude-fable-5-1:high",
269
276
  architect: "anthropic/claude-fable-5-1:xhigh",
@@ -491,7 +498,7 @@ export const BUILTIN_MODEL_PROFILES: readonly ModelProfileDefinition[] = [
491
498
  profile("opus-codex", ["anthropic", "openai-codex"], {
492
499
  default: "anthropic/claude-opus-5-5:medium",
493
500
  executor: "openai-codex/gpt-5.6-terra:low",
494
- planner: "anthropic/claude-sonnet-5",
501
+ planner: "anthropic/claude-sonnet-5-5",
495
502
  critic: "openai-codex/gpt-6-sol:xhigh",
496
503
  architect: "openai-codex/gpt-6-sol:high",
497
504
  }),
@@ -555,6 +562,7 @@ const PROFILE_PRESENTATION: Record<string, ModelProfilePresentation> = {
555
562
  "codex-eco": { displayName: "Codex Eco", providerGroup: "CODEX" },
556
563
  "codex-medium": { displayName: "Codex Medium", providerGroup: "CODEX" },
557
564
  "codex-pro": { displayName: "Codex Pro", providerGroup: "CODEX" },
565
+ "codex-sol61": { displayName: "Codex Sol 6.1", providerGroup: "CODEX" },
558
566
  lunamaxxing: { displayName: "LunaMaxxing", providerGroup: "CODEX" },
559
567
  "astra-lite": { displayName: "ASTRA-Lite", providerGroup: "CODEX" },
560
568
  "astra-default": { displayName: "ASTRA-Default", providerGroup: "CODEX" },
@@ -782,26 +782,53 @@ function registryModelMetadataWithoutApiSpecificFields(model: Model<Api>): Parti
782
782
  return metadata;
783
783
  }
784
784
 
785
- export function registrySelectorResolvesToModel(selector: string, models: readonly Model<Api>[]): boolean {
785
+ /**
786
+ * Lowercased lookup keys for a model catalog, so selector resolution is a set
787
+ * lookup instead of a scan that re-lowercases every model id per selector.
788
+ */
789
+ export interface RegistrySelectorIndex {
790
+ /** `id` and `provider/id`. */
791
+ exact: ReadonlySet<string>;
792
+ /** `id` alone. */
793
+ ids: ReadonlySet<string>;
794
+ /** Model ids per provider, for parsed `provider/id` selectors (nested, so no delimiter can collide). */
795
+ providerIds: ReadonlyMap<string, ReadonlySet<string>>;
796
+ /** Every `/`-delimited suffix of each id (`a/b/c` → `b/c`, `c`). */
797
+ idSuffixes: ReadonlySet<string>;
798
+ }
799
+
800
+ export function createRegistrySelectorIndex(models: readonly Model<Api>[]): RegistrySelectorIndex {
801
+ const exact = new Set<string>();
802
+ const ids = new Set<string>();
803
+ const providerIds = new Map<string, Set<string>>();
804
+ const idSuffixes = new Set<string>();
805
+ for (const model of models) {
806
+ const id = model.id.toLowerCase();
807
+ const provider = model.provider.toLowerCase();
808
+ ids.add(id);
809
+ exact.add(id);
810
+ exact.add(`${provider}/${id}`);
811
+ let providerModelIds = providerIds.get(provider);
812
+ if (!providerModelIds) {
813
+ providerModelIds = new Set<string>();
814
+ providerIds.set(provider, providerModelIds);
815
+ }
816
+ providerModelIds.add(id);
817
+ for (let slash = id.indexOf("/"); slash !== -1; slash = id.indexOf("/", slash + 1)) {
818
+ idSuffixes.add(id.slice(slash + 1));
819
+ }
820
+ }
821
+ return { exact, ids, providerIds, idSuffixes };
822
+ }
823
+
824
+ export function registrySelectorResolvesToModel(selector: string, index: RegistrySelectorIndex): boolean {
786
825
  const normalizedSelector = selector.trim().toLowerCase();
787
- if (
788
- models.some(
789
- model =>
790
- model.id.toLowerCase() === normalizedSelector ||
791
- `${model.provider}/${model.id}`.toLowerCase() === normalizedSelector,
792
- )
793
- )
794
- return true;
826
+ if (index.exact.has(normalizedSelector)) return true;
795
827
  const suffix = splitSelectorThinkingSuffix(normalizedSelector);
796
828
  const baseSelector = suffix.thinkingLevel === undefined ? normalizedSelector : suffix.selector;
797
829
  const parsed = parseModelString(baseSelector);
798
- if (parsed)
799
- return models.some(
800
- model => model.provider.toLowerCase() === parsed.provider && model.id.toLowerCase() === parsed.id,
801
- );
802
- return models.some(
803
- model => model.id.toLowerCase() === baseSelector || model.id.toLowerCase().endsWith(`/${baseSelector}`),
804
- );
830
+ if (parsed) return index.providerIds.get(parsed.provider)?.has(parsed.id) ?? false;
831
+ return index.ids.has(baseSelector) || index.idSuffixes.has(baseSelector);
805
832
  }
806
833
 
807
834
  function filterMaterializedRegistryProfiles(
@@ -810,6 +837,7 @@ function filterMaterializedRegistryProfiles(
810
837
  dynamicProviders: ReadonlySet<string>,
811
838
  ): Map<string, ModelProfileDefinition> {
812
839
  const filtered = new Map<string, ModelProfileDefinition>();
840
+ const index = createRegistrySelectorIndex(models);
813
841
  for (const [name, profile] of profiles) {
814
842
  if (
815
843
  profile.source === "registry" &&
@@ -818,7 +846,7 @@ function filterMaterializedRegistryProfiles(
818
846
  return (
819
847
  selectors.length > 0 &&
820
848
  !selectors.some(selector => {
821
- if (registrySelectorResolvesToModel(selector, models)) return true;
849
+ if (registrySelectorResolvesToModel(selector, index)) return true;
822
850
  const suffix = splitSelectorThinkingSuffix(selector);
823
851
  const parsed = parseModelString(suffix.thinkingLevel ? suffix.selector : selector);
824
852
  return parsed !== undefined && dynamicProviders.has(parsed.provider);
@@ -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", {
@@ -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) {