@gajae-code/coding-agent 0.18.0 → 0.18.1

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 (40) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/types/config/settings-schema.d.ts +2 -2
  3. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +1 -0
  4. package/dist/types/sdk/acp/adapter.d.ts +3 -1
  5. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  6. package/dist/types/sdk/client/client.d.ts +5 -1
  7. package/dist/types/session/agent-session.d.ts +2 -0
  8. package/dist/types/task/index.d.ts +0 -1
  9. package/dist/types/tools/descriptors.d.ts +5 -0
  10. package/dist/types/tools/eval-backends.d.ts +37 -0
  11. package/dist/types/tools/eval.d.ts +1 -6
  12. package/dist/types/tools/index.d.ts +1 -36
  13. package/dist/types/tools/session-descriptions.d.ts +22 -0
  14. package/package.json +7 -7
  15. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  16. package/src/cli/doctor/managed-link.ts +4 -4
  17. package/src/cli/doctor/repairs.ts +9 -1
  18. package/src/config/settings-schema.ts +8 -2
  19. package/src/exec/bash-shell-guardian.ts +31 -15
  20. package/src/gjc-runtime/session-state-sidecar.ts +88 -58
  21. package/src/internal-urls/docs-index.generated.ts +4 -4
  22. package/src/modes/acp/acp-agent.ts +97 -16
  23. package/src/modes/interactive-mode.ts +10 -0
  24. package/src/modes/utils/context-usage.ts +2 -6
  25. package/src/prompts/tools/task.md +8 -27
  26. package/src/sdk/acp/adapter.ts +38 -3
  27. package/src/sdk/broker/doctor-restart.ts +38 -3
  28. package/src/sdk/bus/index.ts +3 -3
  29. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  30. package/src/sdk/client/client.ts +53 -35
  31. package/src/sdk/protocol/operation-inventory.generated.json +11 -0
  32. package/src/session/agent-session.ts +65 -15
  33. package/src/task/index.ts +21 -66
  34. package/src/tools/descriptors.ts +27 -1
  35. package/src/tools/eval-backends.ts +95 -0
  36. package/src/tools/eval.ts +3 -15
  37. package/src/tools/index.ts +9 -88
  38. package/src/tools/search.ts +2 -7
  39. package/src/tools/session-descriptions.ts +135 -0
  40. package/src/tools/tool-catalog.generated.ts +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,41 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.18.1] - 2026-09-29
6
+
7
+ ### Fixed
8
+
9
+ - `./install.sh --dev` and `gjc doctor` no longer treat this checkout's own `gjc` and `가재씨` links as foreign after a macOS reboot. The ownership receipt still binds the link by inode, but no longer compares the recorded `st_dev`, which APFS changes across boots. A receipt left behind after its link was removed by hand is recognized as this checkout's and replaced, while a receipt that names another checkout is refused before either link is touched.
10
+
11
+ - Loading `search` or `eval` for the first time no longer changes its advertised tool description, so the provider-visible `tools` block and the prompt-cache prefix stay stable. Before the implementation loads, the description is now rendered from the session (hashline or line-number display for `search`, the allowed Python/JavaScript backends for `eval`), instead of taking the stub-session text from the generated catalog.
12
+
13
+ - Keep the provider-visible `task` description stable before and after the tool loads by rendering it from session settings without listing configured agent names; unknown-agent errors continue to list all visible agents. IRC guidance is now independent of whether the IRC tool becomes available mid-session.
14
+
15
+ - Windows binary update via exactReplaceRetained now succeeds with owner-only ACL enforcement. The retained update path required READ_CONTROL on both the staged source and current destination handles to query ACL ownership, but the handles were opened without READ_CONTROL, causing every real Windows update to fail with acl_unavailable. (#6096)
16
+
17
+ - Reduced macOS idle CPU from the bash shell guardian: the Darwin ancestry poll now reads kernel process identities first and validates only new descendants with `Process.fromPid`, instead of querying every process on each scan. A descendant whose validation fails transiently is retried on the next poll rather than dropped from cleanup.
18
+
19
+ - User-cancelled turns (ACP session/cancel, SDK turn.abort) settle as `cancelled` again instead of a failed terminal (regression from 0.18.0).
20
+
21
+ - ACP cancels received before SDK prompt admission now stay attached to the waiter: the prompt is not dispatched, or a real terminal abort is retried after admission, rather than letting the turn continue.
22
+
23
+ - ACP MCP launch failures now retain the broker lifecycle cause and a bounded, credential-free diagnostic, while SDK transport failures keep their original retry code.
24
+
25
+ - `gjc doctor --fix --repair service.restart-owned` on the SDK broker now reports a broker refusal, such as `restart_busy` while any session is live, as `blocked` with reason `prepare_refused:<code>` and no side effect. It was previously reported as `uncertain` / `repair_execution_unverified` with `sideEffectStarted: true`. A refused commit now also cancels its prepared reservation instead of leaving new work refused until the lease expires.
26
+
27
+ - Treat cooperative SDK prompt pauses as successful terminal stops instead of unreported failures.
28
+
29
+ - A displayed extension message sent from `session_start` (such as a handoff summary) now appears once at startup instead of twice, and startup notices shown before the first paint are no longer wiped. Transcript rebuilds requested before the initial transcript paint are skipped; the first paint renders the full session.
30
+
31
+ ### Documentation
32
+
33
+ - Updated: Clarified that human reviewers only need to approve the current head on GitHub; body verdict lines are required only for agent reviewers and owner self-approval.
34
+
35
+ ### Bug fixes
36
+
37
+ - Fixed: Human approval now works when a PR has no verdict line in its body. GJC checks GitHub reviews for human approval on the current head instead.
38
+ - Fixed: A valid PR contract with no approval yet now shows a "Waiting for approval" notice instead of an error.
39
+
5
40
  ## [0.18.0] - 2026-09-27
6
41
 
7
42
  ### Added
@@ -2183,11 +2183,11 @@ export declare const SETTINGS_SCHEMA: {
2183
2183
  readonly ui: {
2184
2184
  readonly tab: "context";
2185
2185
  readonly label: "Compaction Threshold";
2186
- readonly description: "Percent threshold for context maintenance; set to Default to use legacy reserve-based behavior";
2186
+ readonly description: "Percent threshold for context maintenance; the 300,000-token default cap applies only with adaptive mode disabled (except during context promotion)";
2187
2187
  readonly options: readonly [{
2188
2188
  readonly value: "default";
2189
2189
  readonly label: "Default";
2190
- readonly description: "Legacy reserve-based threshold";
2190
+ readonly description: "300,000-token default cap applies only with adaptive mode disabled (except during context promotion)";
2191
2191
  }, {
2192
2192
  readonly value: "10";
2193
2193
  readonly label: "10%";
@@ -39,6 +39,7 @@ export declare const __sessionStateSidecarTestHooks: {
39
39
  beforeRescopeJournalWrite?: (cwd: string) => void | Promise<void>;
40
40
  beforePersistFromEvent?: (eventType: string, cwd: string) => void | Promise<void>;
41
41
  beforeRescopePublish?: () => void | Promise<void>;
42
+ disableCoordinatorFileSyncForTests?: boolean;
42
43
  };
43
44
  interface RuntimeStateEvent {
44
45
  type: string;
@@ -61,7 +61,9 @@ export declare class AcpSdkAdapterError extends Error {
61
61
  }
62
62
  /**
63
63
  * The error an ACP session launch must throw once a lifecycle request that carried MCP
64
- * servers has failed.
64
+ * servers has failed. Broker transport failures from the SDK client are preserved verbatim;
65
+ * other failures keep the adapter's `unavailable` code while carrying a bounded, credential-free
66
+ * copy of the original code and message as their cause diagnostic.
65
67
  */
66
68
  export declare function acpMcpLaunchFailure(error: unknown, mcpServers: SessionLifecycleMcpServer[]): unknown;
67
69
  export type AcpReconnectFailedHandler = (error: SdkClientError) => void;
@@ -292,7 +292,7 @@ export declare const SDK_LIFECYCLE_ROUTER_PROTOCOL_VERSION = 1;
292
292
  * Generation 193 gates streamed content on negotiated observer capabilities,
293
293
  * so existing owners are replaced before exposing the new observer path.
294
294
  */
295
- export declare const DAEMON_GENERATION = 196;
295
+ export declare const DAEMON_GENERATION = 197;
296
296
  /**
297
297
  * Serving-compatibility boundary for daemon lifecycle requests. Epoch 7
298
298
  * requires durable publication receipts and acknowledged attachment dispatch,
@@ -2,6 +2,8 @@ export type SdkErrorCode = "invalid_input" | "unknown_operation" | "not_found" |
2
2
  export declare class SdkClientError extends Error {
3
3
  readonly code: SdkErrorCode;
4
4
  readonly details: unknown;
5
+ /** True only when the SDK client itself reported a transport failure, never for a server error frame. */
6
+ readonly transport: boolean;
5
7
  /**
6
8
  * Reconnect-cycle diagnostics, separate from `details` because `details` is an
7
9
  * established contract: callers read the terminating transport error straight off
@@ -10,7 +12,9 @@ export declare class SdkClientError extends Error {
10
12
  * break every such reader, so the new attribution rides alongside it instead.
11
13
  */
12
14
  readonly reconnect?: SdkReconnectExhaustedDetails;
13
- constructor(code: SdkErrorCode, message: string, details?: unknown, reconnect?: SdkReconnectExhaustedDetails);
15
+ constructor(code: SdkErrorCode, message: string, details?: unknown, reconnect?: SdkReconnectExhaustedDetails, options?: {
16
+ transport?: boolean;
17
+ });
14
18
  }
15
19
  /** A prepared transport operation failed before any wire handoff. */
16
20
  export declare class SdkPreparedDispatchError extends SdkClientError {
@@ -1940,6 +1940,8 @@ export declare class AgentSession {
1940
1940
  * Get session statistics.
1941
1941
  */
1942
1942
  getSessionStats(): SessionStats;
1943
+ /** Current auto-compaction threshold, including active model-promotion policy. */
1944
+ getAutoCompactionThresholdTokens(contextTokens?: number): number;
1943
1945
  /**
1944
1946
  * Get current context usage statistics.
1945
1947
  * Uses the last assistant message's usage data when available,
@@ -51,7 +51,6 @@ export declare class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskT
51
51
  */
52
52
  get parameters(): TaskToolSchemaInstance;
53
53
  renderCall(args: unknown, options: Parameters<typeof renderTaskCall>[1], theme: Theme): import("@gajae-code/tui").Component;
54
- /** Dynamic description that reflects current disabled-agent settings */
55
54
  get description(): string;
56
55
  private constructor();
57
56
  /**
@@ -17,6 +17,11 @@ export interface ToolDescriptorMetadata {
17
17
  readonly intent?: AgentTool<any, any, any>["intent"];
18
18
  readonly parametersForSession?: (session?: ToolSession) => TSchema;
19
19
  readonly parameters?: TSchema;
20
+ /**
21
+ * Renders the description the loaded tool will report for this session. Tools whose prompt text
22
+ * depends on session settings need it, or the advertised text changes on first load (#5992).
23
+ */
24
+ readonly descriptionForSession?: (session: ToolSession) => string;
20
25
  readonly description?: string;
21
26
  readonly label?: string;
22
27
  readonly customWireName?: string;
@@ -0,0 +1,37 @@
1
+ import type { ToolSession } from ".";
2
+ export interface EvalBackendsAllowance {
3
+ python: boolean;
4
+ js: boolean;
5
+ }
6
+ /**
7
+ * Parse the `GJC_PY` multi-value token into per-backend booleans.
8
+ *
9
+ * Tokens (case-insensitive):
10
+ * - `0` / `bash` → JavaScript only (`{ py: false, js: true }`)
11
+ * - `1` / `py` → Python only (`{ py: true, js: false }`)
12
+ * - `js` → JavaScript only (`{ py: false, js: true }`)
13
+ * - `mix` / `both` → both backends (`{ py: true, js: true }`)
14
+ *
15
+ * Returns `null` when `GJC_PY` is unset, empty, or holds an unrecognized
16
+ * token, so the caller can fall back to the legacy `PI_PY` / `PI_JS` flags or
17
+ * per-key settings. This matches the documented contract that invalid values
18
+ * are ignored.
19
+ */
20
+ export declare function parseGjcPy(env: Record<string, string | undefined>): {
21
+ py: boolean;
22
+ js: boolean;
23
+ } | null;
24
+ /**
25
+ * Resolve eval-backend allowance from environment only. `GJC_PY` wins when set
26
+ * to a recognized token; otherwise the legacy `PI_PY` / `PI_JS` flags apply.
27
+ * Returns `null` when no env override is set so the caller can defer to settings.
28
+ */
29
+ export declare function resolveEvalBackendsFromEnv(env: Record<string, string | undefined>): EvalBackendsAllowance | null;
30
+ /** Read per-backend allowance from settings (defaults true). */
31
+ export declare function readEvalBackendsAllowance(session: ToolSession): EvalBackendsAllowance;
32
+ /**
33
+ * Materialize the active eval backend allowance. `GJC_PY` takes precedence
34
+ * over the legacy `PI_PY` / `PI_JS` env flags, which in turn override the
35
+ * per-key settings (defaults true). When no env override is set, settings win.
36
+ */
37
+ export declare function resolveEvalBackends(session: ToolSession): EvalBackendsAllowance;
@@ -42,11 +42,7 @@ export type EvalToolResult = {
42
42
  details: EvalToolDetails | undefined;
43
43
  };
44
44
  export type EvalProxyExecutor = (params: EvalToolParams, signal?: AbortSignal) => Promise<EvalToolResult>;
45
- export interface EvalToolDescriptionOptions {
46
- py?: boolean;
47
- js?: boolean;
48
- }
49
- export declare function getEvalToolDescription(options?: EvalToolDescriptionOptions): string;
45
+ export { type EvalToolDescriptionOptions, getEvalToolDescription } from "./session-descriptions";
50
46
  export interface EvalToolOptions {
51
47
  proxyExecutor?: EvalProxyExecutor;
52
48
  }
@@ -105,4 +101,3 @@ export declare const evalToolRenderer: {
105
101
  mergeCallAndResult: boolean;
106
102
  inline: boolean;
107
103
  };
108
- export {};
@@ -405,42 +405,7 @@ export interface BuiltinCapabilityCatalogEntry {
405
405
  }
406
406
  export declare const BUILTIN_CAPABILITY_CATALOG: readonly BuiltinCapabilityCatalogEntry[];
407
407
  export type ToolName = keyof typeof BUILTIN_TOOLS;
408
- export interface EvalBackendsAllowance {
409
- python: boolean;
410
- js: boolean;
411
- }
412
- /**
413
- * Parse the `GJC_PY` multi-value token into per-backend booleans.
414
- *
415
- * Tokens (case-insensitive):
416
- * - `0` / `bash` → JavaScript only (`{ py: false, js: true }`)
417
- * - `1` / `py` → Python only (`{ py: true, js: false }`)
418
- * - `js` → JavaScript only (`{ py: false, js: true }`)
419
- * - `mix` / `both` → both backends (`{ py: true, js: true }`)
420
- *
421
- * Returns `null` when `GJC_PY` is unset, empty, or holds an unrecognized
422
- * token, so the caller can fall back to the legacy `PI_PY` / `PI_JS` flags or
423
- * per-key settings. This matches the documented contract that invalid values
424
- * are ignored.
425
- */
426
- export declare function parseGjcPy(env: Record<string, string | undefined>): {
427
- py: boolean;
428
- js: boolean;
429
- } | null;
430
- /**
431
- * Resolve eval-backend allowance from environment only. `GJC_PY` wins when set
432
- * to a recognized token; otherwise the legacy `PI_PY` / `PI_JS` flags apply.
433
- * Returns `null` when no env override is set so the caller can defer to settings.
434
- */
435
- export declare function resolveEvalBackendsFromEnv(env: Record<string, string | undefined>): EvalBackendsAllowance | null;
436
- /** Read per-backend allowance from settings (defaults true). */
437
- export declare function readEvalBackendsAllowance(session: ToolSession): EvalBackendsAllowance;
438
- /**
439
- * Materialize the active eval backend allowance. `GJC_PY` takes precedence
440
- * over the legacy `PI_PY` / `PI_JS` env flags, which in turn override the
441
- * per-key settings (defaults true). When no env override is set, settings win.
442
- */
443
- export declare function resolveEvalBackends(session: ToolSession): EvalBackendsAllowance;
408
+ export { type EvalBackendsAllowance, parseGjcPy, readEvalBackendsAllowance, resolveEvalBackends, resolveEvalBackendsFromEnv, } from "./eval-backends";
444
409
  /**
445
410
  * Create tools from the descriptor registry.
446
411
  */
@@ -0,0 +1,22 @@
1
+ import { type TaskToolSchemaInstance } from "../task/types";
2
+ interface DescriptionSettings {
3
+ get(key: "task.maxConcurrency" | "task.isolation.mode" | "task.simple" | "task.disabledAgents" | "eval.py" | "eval.js" | "readLineNumbers" | "readHashLines" | "edit.mode"): unknown;
4
+ getEffectiveAutorouting(): {
5
+ active: boolean;
6
+ };
7
+ }
8
+ interface DescriptionSession {
9
+ settings: DescriptionSettings;
10
+ getSessionSpawns?: () => string | null;
11
+ hasEditTool?: boolean;
12
+ }
13
+ export interface EvalToolDescriptionOptions {
14
+ py?: boolean;
15
+ js?: boolean;
16
+ }
17
+ export declare function getEvalToolDescription(options?: EvalToolDescriptionOptions): string;
18
+ export declare function evalToolDescriptionForSession(session: DescriptionSession | null | undefined): string;
19
+ export declare function searchToolDescriptionForSession(session: DescriptionSession): string;
20
+ export declare function renderTaskDescription(session: DescriptionSession): string;
21
+ export declare function taskParametersForSession(session?: DescriptionSession): TaskToolSchemaInstance;
22
+ export {};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/coding-agent",
4
- "version": "0.18.0",
4
+ "version": "0.18.1",
5
5
  "description": "Gajae Code CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://gajae-code.com",
7
7
  "author": "Yeachan-Heo and Gajae Code Contributors",
@@ -57,12 +57,12 @@
57
57
  "@agentclientprotocol/sdk": "1.3.0",
58
58
  "@babel/parser": "^7.29.3",
59
59
  "@mozilla/readability": "^0.6.0",
60
- "@gajae-code/stats": "0.18.0",
61
- "@gajae-code/agent-core": "0.18.0",
62
- "@gajae-code/ai": "0.18.0",
63
- "@gajae-code/natives": "0.18.0",
64
- "@gajae-code/tui": "0.18.0",
65
- "@gajae-code/utils": "0.18.0",
60
+ "@gajae-code/stats": "0.18.1",
61
+ "@gajae-code/agent-core": "0.18.1",
62
+ "@gajae-code/ai": "0.18.1",
63
+ "@gajae-code/natives": "0.18.1",
64
+ "@gajae-code/tui": "0.18.1",
65
+ "@gajae-code/utils": "0.18.1",
66
66
  "@puppeteer/browsers": "^2.13.0",
67
67
  "@types/turndown": "5.0.6",
68
68
  "@xterm/headless": "^6.0.0",
@@ -203,6 +203,8 @@ const LOCKED_EXCLUSIONS: Readonly<Record<string, string>> = {
203
203
  "agent_session:queueDeferredMessage": "internal accessor/plumbing, not a user-facing control seam",
204
204
  "agent_session:queueDeferredMessageForTests": "internal accessor/plumbing, not a user-facing control seam",
205
205
  "agent_session:getContextUsageObservabilityForTests": "test-only observability, not a user-facing control seam",
206
+ "agent_session:getAutoCompactionThresholdTokens":
207
+ "internal compaction-threshold accessor feeding the /context breakdown; exposed through context.get, not an independent SDK operation",
206
208
  "agent_session:purgeQueuedCustomMessages": "internal accessor/plumbing, not a user-facing control seam",
207
209
  "agent_session:clearQueue": "internal accessor/plumbing, not a user-facing control seam",
208
210
  "agent_session:popLastQueuedMessage": "internal accessor/plumbing, not a user-facing control seam",
@@ -217,11 +217,11 @@ export async function describeManagedLink(
217
217
  const parent = await fs.lstat(parentPath, { bigint: true }).catch(() => undefined);
218
218
  const trustedReceipt =
219
219
  !!body && ownedReceipt(receipt) && publicCandidates.some(candidate => candidate.path === body.source);
220
+ // The recorded st_dev is not compared: macOS APFS gives the same volume a new st_dev after a reboot,
221
+ // which made every pre-reboot receipt look foreign (#5990). The inode binds the link, and the volume
222
+ // is re-derived from the parent directory observed in the same snapshot.
220
223
  const identityMatches =
221
- !!body &&
222
- !!link &&
223
- body.identity.dev === link.stat.dev.toString() &&
224
- body.identity.ino === link.stat.ino.toString();
224
+ !!body && !!link && body.identity.ino === link.stat.ino.toString() && link.stat.dev === link.parent.dev;
225
225
  const owned = trustedReceipt && (link ? identityMatches : occupant === undefined);
226
226
  const recordedCandidate = body && publicCandidates.find(candidate => candidate.path === body.source);
227
227
  let status: ManagedLinkDescriptor["status"] = "unknown";
@@ -892,7 +892,15 @@ export async function executeSelectedDoctorRepair(
892
892
  outcome =
893
893
  result.kind === "restarted"
894
894
  ? { kind: result.kind, successorIncarnation: result.successor.incarnation }
895
- : { kind: result.kind, ...("reason" in result ? { reason: result.reason } : {}) };
895
+ : {
896
+ kind: result.kind,
897
+ // A broker refusal carries the broker's own code (e.g. restart_busy).
898
+ ...("reason" in result
899
+ ? { reason: result.reason }
900
+ : "code" in result
901
+ ? { reason: result.code }
902
+ : {}),
903
+ };
896
904
  } else {
897
905
  settings = await Settings.loadForScope({ cwd: context.cwd, agentDir: context.agentRoot.locator });
898
906
  const result = await restartDaemonForDoctor({
@@ -2050,9 +2050,15 @@ export const SETTINGS_SCHEMA = {
2050
2050
  ui: {
2051
2051
  tab: "context",
2052
2052
  label: "Compaction Threshold",
2053
- description: "Percent threshold for context maintenance; set to Default to use legacy reserve-based behavior",
2053
+ description:
2054
+ "Percent threshold for context maintenance; the 300,000-token default cap applies only with adaptive mode disabled (except during context promotion)",
2054
2055
  options: [
2055
- { value: "default", label: "Default", description: "Legacy reserve-based threshold" },
2056
+ {
2057
+ value: "default",
2058
+ label: "Default",
2059
+ description:
2060
+ "300,000-token default cap applies only with adaptive mode disabled (except during context promotion)",
2061
+ },
2056
2062
  { value: "10", label: "10%", description: "Extremely early maintenance" },
2057
2063
  { value: "20", label: "20%", description: "Very early maintenance" },
2058
2064
  { value: "30", label: "30%", description: "Early maintenance" },
@@ -175,18 +175,28 @@ function createDarwinAncestryTracker(owned: Map<string, NativeProcess>): DarwinA
175
175
  },
176
176
  });
177
177
  const knownUniqueIds = new Set<bigint>();
178
+ // Ancestry evidence (knownUniqueIds) and successful retention are tracked
179
+ // separately: a descendant whose process validation fails stays known, so its
180
+ // own children still chain, but it is retried on every poll until retained.
181
+ const retainedUniqueIds = new Set<bigint>();
178
182
  const { Process } = require("@gajae-code/natives") as NativeProcessBindings;
183
+ // Reuse buffer across poll iterations to avoid allocations inside the loop.
184
+ const identityBuffer = new Uint8Array(56);
185
+ const identityView = new DataView(identityBuffer.buffer);
179
186
  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) };
187
+ const bytes = proc.symbols.proc_pidinfo(pid, 17, 0, ptr(identityBuffer), identityBuffer.byteLength);
188
+ if (bytes !== identityBuffer.byteLength) return undefined;
189
+ return {
190
+ uniqueId: identityView.getBigUint64(16, true),
191
+ parentUniqueId: identityView.getBigUint64(24, true),
192
+ };
184
193
  };
185
194
  const track = (processRef: NativeProcess, signedUniqueId?: bigint): boolean => {
186
195
  const identity = signedUniqueId === undefined ? uniqueIdentity(processRef.pid) : undefined;
187
196
  const uniqueId = signedUniqueId ?? identity?.uniqueId;
188
197
  if (uniqueId === undefined) return false;
189
198
  knownUniqueIds.add(uniqueId);
199
+ retainedUniqueIds.add(uniqueId);
190
200
  retainOwnedProcess(owned, processRef);
191
201
  return true;
192
202
  };
@@ -201,23 +211,29 @@ function createDarwinAncestryTracker(owned: Map<string, NativeProcess>): DarwinA
201
211
  const pids = new Int32Array(capacity + 64);
202
212
  const count = proc.symbols.proc_listallpids(ptr(pids), pids.byteLength);
203
213
  if (count <= 0) return false;
204
- const candidates = new Map<
205
- number,
206
- { uniqueId: bigint; parentUniqueId: bigint; processRef: NativeProcess }
207
- >();
214
+ // First pass: collect identities for all pids, avoiding Process.fromPid calls.
215
+ const identities = new Map<number, { uniqueId: bigint; parentUniqueId: bigint }>();
208
216
  for (let index = 0; index < count; index++) {
209
217
  const pid = pids[index]!;
210
- const processRef = Process.fromPid(pid);
211
- if (!processRef) continue;
218
+ const identity = uniqueIdentity(pid);
219
+ if (identity) identities.set(pid, identity);
220
+ }
221
+ // Extend ancestry evidence, then validate every listed pid that is known but
222
+ // not yet retained: new descendants plus earlier ones whose Process.fromPid
223
+ // check failed (it can return null for a live pid it could not query). A pid
224
+ // whose identity changed between the listing and the check was reused; skip it.
225
+ extendOwnedDarwinAncestry(knownUniqueIds, identities);
226
+ for (const [pid, listed] of identities) {
227
+ if (!knownUniqueIds.has(listed.uniqueId) || retainedUniqueIds.has(listed.uniqueId)) continue;
228
+ const before = Process.fromPid(pid);
229
+ if (!before) continue;
212
230
  const identity = uniqueIdentity(pid);
213
231
  const after = Process.fromPid(pid);
214
- if (identity && after?.incarnation === processRef.incarnation) {
215
- candidates.set(pid, { ...identity, processRef });
232
+ if (identity?.uniqueId === listed.uniqueId && after?.incarnation === before.incarnation) {
233
+ retainOwnedProcess(owned, after);
234
+ retainedUniqueIds.add(listed.uniqueId);
216
235
  }
217
236
  }
218
- for (const pid of extendOwnedDarwinAncestry(knownUniqueIds, candidates)) {
219
- retainOwnedProcess(owned, candidates.get(pid)!.processRef);
220
- }
221
237
  return true;
222
238
  },
223
239
  close() {
@@ -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