@gajae-code/coding-agent 0.11.4 → 0.11.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/types/cli/notify-cli.d.ts +5 -1
  3. package/dist/types/config/settings-schema.d.ts +31 -1
  4. package/dist/types/export/html/template.generated.d.ts +1 -1
  5. package/dist/types/gjc-runtime/deep-interview-state.d.ts +28 -0
  6. package/dist/types/gjc-runtime/launch-tmux.d.ts +3 -0
  7. package/dist/types/gjc-runtime/managed-owner-admission.d.ts +42 -0
  8. package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +41 -0
  9. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +9 -0
  10. package/dist/types/gjc-runtime/ultragoal-owner-loss-recovery.d.ts +94 -0
  11. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
  12. package/dist/types/harness-control-plane/owner.d.ts +3 -1
  13. package/dist/types/internal-urls/local-protocol.d.ts +18 -0
  14. package/dist/types/main.d.ts +2 -2
  15. package/dist/types/modes/components/ansi-display-validator.d.ts +2 -0
  16. package/dist/types/modes/components/notifications-settings-editor.d.ts +4 -0
  17. package/dist/types/modes/components/session-observer-overlay.d.ts +3 -1
  18. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  19. package/dist/types/modes/components/tool-transcript-format.d.ts +58 -0
  20. package/dist/types/modes/components/transcript-viewer-overlay.d.ts +3 -0
  21. package/dist/types/modes/controllers/goal-mode-controller.d.ts +8 -1
  22. package/dist/types/modes/controllers/selector-controller.d.ts +15 -0
  23. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +3 -0
  24. package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +16 -0
  25. package/dist/types/plan-mode/approved-plan.d.ts +1 -0
  26. package/dist/types/runtime-mcp/manager.d.ts +2 -0
  27. package/dist/types/runtime-mcp/oauth-discovery.d.ts +12 -1
  28. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +3 -1
  29. package/dist/types/sdk/bus/chat-daemon-control.d.ts +42 -4
  30. package/dist/types/sdk/bus/config-commands.d.ts +8 -0
  31. package/dist/types/sdk/bus/config.d.ts +22 -0
  32. package/dist/types/sdk/bus/index.d.ts +162 -2
  33. package/dist/types/sdk/bus/notification-service.d.ts +71 -8
  34. package/dist/types/sdk/bus/session-control.d.ts +11 -8
  35. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +5 -0
  36. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +11 -3
  37. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +23 -4
  38. package/dist/types/sdk/bus/telegram-daemon.d.ts +214 -20
  39. package/dist/types/sdk/session.d.ts +16 -1
  40. package/dist/types/secrets/index.d.ts +4 -2
  41. package/dist/types/secrets/obfuscator.d.ts +1 -1
  42. package/dist/types/session/agent-session.d.ts +31 -2
  43. package/dist/types/session/artifacts.d.ts +30 -8
  44. package/dist/types/session/internal/managed-session-scope.d.ts +7 -0
  45. package/dist/types/session/internal/managed-session-storage.d.ts +63 -6
  46. package/dist/types/session/session-manager.d.ts +98 -18
  47. package/dist/types/session/session-storage.d.ts +27 -0
  48. package/dist/types/session/streaming-output.d.ts +4 -0
  49. package/dist/types/session/yield-queue.d.ts +8 -0
  50. package/dist/types/task/executor.d.ts +9 -0
  51. package/dist/types/tool-discovery/tool-index.d.ts +2 -0
  52. package/dist/types/tools/ask.d.ts +48 -0
  53. package/dist/types/tools/bash-skill-urls.d.ts +2 -2
  54. package/dist/types/tools/index.d.ts +4 -0
  55. package/package.json +9 -9
  56. package/scripts/generate-sdk-operation-inventory.ts +3 -0
  57. package/src/cli/notify-cli.ts +40 -18
  58. package/src/commands/sdk.ts +7 -2
  59. package/src/config/settings-schema.ts +31 -1
  60. package/src/daemon/runtime.ts +2 -2
  61. package/src/defaults/gjc/skills/deep-interview/SKILL.md +70 -49
  62. package/src/defaults/gjc/skills/ralplan/SKILL.md +0 -6
  63. package/src/defaults/gjc/skills/team/SKILL.md +7 -9
  64. package/src/defaults/gjc/skills/ultragoal/SKILL.md +0 -2
  65. package/src/eval/py/index.ts +2 -2
  66. package/src/export/html/template.generated.ts +1 -1
  67. package/src/extensibility/plugins/marketplace/manager.ts +94 -10
  68. package/src/gjc-runtime/deep-interview-recorder.ts +7 -0
  69. package/src/gjc-runtime/deep-interview-runtime.ts +10 -0
  70. package/src/gjc-runtime/deep-interview-state.ts +127 -3
  71. package/src/gjc-runtime/launch-tmux.ts +61 -6
  72. package/src/gjc-runtime/managed-owner-admission.ts +352 -0
  73. package/src/gjc-runtime/managed-owner-supervisor.ts +162 -0
  74. package/src/gjc-runtime/state-runtime.ts +38 -12
  75. package/src/gjc-runtime/team-runtime.ts +1 -1
  76. package/src/gjc-runtime/tmux-owner-isolation.ts +117 -0
  77. package/src/gjc-runtime/ultragoal-owner-loss-recovery.ts +328 -0
  78. package/src/gjc-runtime/ultragoal-runtime.ts +14 -0
  79. package/src/harness-control-plane/owner.ts +59 -3
  80. package/src/hindsight/client.ts +65 -2
  81. package/src/internal-urls/agent-protocol.ts +76 -5
  82. package/src/internal-urls/docs-index.generated.ts +8 -7
  83. package/src/internal-urls/local-protocol.ts +416 -18
  84. package/src/lsp/index.ts +4 -88
  85. package/src/main.ts +36 -12
  86. package/src/modes/acp/acp-agent.ts +8 -0
  87. package/src/modes/components/ansi-display-validator.ts +54 -0
  88. package/src/modes/components/notifications-settings-editor.ts +26 -2
  89. package/src/modes/components/session-observer-overlay.ts +40 -24
  90. package/src/modes/components/settings-selector.ts +3 -1
  91. package/src/modes/components/tool-transcript-format.ts +321 -0
  92. package/src/modes/components/transcript-viewer-overlay.ts +39 -17
  93. package/src/modes/controllers/command-controller.ts +15 -0
  94. package/src/modes/controllers/extension-ui-controller.ts +14 -2
  95. package/src/modes/controllers/goal-mode-controller.ts +20 -1
  96. package/src/modes/controllers/plan-mode-controller.ts +2 -0
  97. package/src/modes/controllers/runtime-mcp-command-controller.ts +3 -0
  98. package/src/modes/controllers/selector-controller.ts +70 -8
  99. package/src/modes/interactive-mode.ts +1 -0
  100. package/src/modes/runtime-init.ts +16 -2
  101. package/src/modes/shared/agent-wire/deep-interview-gate.ts +31 -1
  102. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +17 -1
  103. package/src/modes/shared/agent-wire/workflow-gate-schema.ts +4 -2
  104. package/src/modes/shared/agent-wire/workflow-gate-types.ts +120 -3
  105. package/src/plan-mode/approved-plan.ts +3 -1
  106. package/src/prompts/agents/architect.md +1 -0
  107. package/src/prompts/agents/critic.md +1 -0
  108. package/src/prompts/system/custom-system-prompt.md +1 -1
  109. package/src/prompts/system/system-prompt.md +2 -1
  110. package/src/prompts/tools/lsp.md +3 -3
  111. package/src/runtime-mcp/manager.ts +16 -1
  112. package/src/runtime-mcp/oauth-discovery.ts +116 -26
  113. package/src/sdk/broker/session-index.ts +122 -64
  114. package/src/sdk/bus/chat-daemon-cli.ts +72 -10
  115. package/src/sdk/bus/chat-daemon-control.ts +538 -130
  116. package/src/sdk/bus/chat-daemon-runtime.ts +1 -0
  117. package/src/sdk/bus/config-commands.ts +22 -0
  118. package/src/sdk/bus/config.ts +37 -0
  119. package/src/sdk/bus/index.ts +388 -94
  120. package/src/sdk/bus/lifecycle-control-runtime.ts +53 -3
  121. package/src/sdk/bus/notification-service.ts +284 -62
  122. package/src/sdk/bus/recent-activity.ts +14 -5
  123. package/src/sdk/bus/session-control.ts +150 -44
  124. package/src/sdk/bus/telegram-daemon-cli.ts +20 -14
  125. package/src/sdk/bus/telegram-daemon-contract.ts +11 -3
  126. package/src/sdk/bus/telegram-daemon-control.ts +312 -76
  127. package/src/sdk/bus/telegram-daemon.ts +1916 -285
  128. package/src/sdk/host/control/dispatch.ts +14 -5
  129. package/src/sdk/protocol/operation-inventory.generated.json +29 -0
  130. package/src/sdk/session.ts +155 -27
  131. package/src/secrets/index.ts +53 -10
  132. package/src/secrets/obfuscator.ts +21 -18
  133. package/src/secrets/regex.ts +2 -0
  134. package/src/session/agent-session.ts +1447 -852
  135. package/src/session/artifacts.ts +188 -23
  136. package/src/session/context-estimation.ts +7 -2
  137. package/src/session/internal/managed-session-scope.ts +200 -38
  138. package/src/session/internal/managed-session-storage.ts +1152 -46
  139. package/src/session/session-manager.ts +1121 -148
  140. package/src/session/session-storage.ts +109 -11
  141. package/src/session/streaming-output.ts +6 -0
  142. package/src/session/yield-queue.ts +20 -0
  143. package/src/slash-commands/builtin-registry.ts +36 -0
  144. package/src/task/executor.ts +71 -26
  145. package/src/task/index.ts +63 -20
  146. package/src/tool-discovery/tool-index.ts +18 -0
  147. package/src/tools/ask-answer-registry.ts +15 -1
  148. package/src/tools/ask.ts +129 -32
  149. package/src/tools/bash-skill-urls.ts +14 -3
  150. package/src/tools/bash.ts +2 -2
  151. package/src/tools/index.ts +4 -0
  152. package/src/tools/plan-mode-guard.ts +1 -0
  153. package/src/tools/sqlite-reader.ts +3 -2
  154. package/src/tools/subagent.ts +2 -0
@@ -13,6 +13,18 @@ import * as path from "node:path";
13
13
  import type { NotificationControlServer as NativeNotificationControlServer } from "@gajae-code/natives";
14
14
  import { logger } from "@gajae-code/utils";
15
15
  import { readLinuxProcStartTime } from "../../gjc-runtime/linux-proc";
16
+ import {
17
+ MANAGED_OWNER_PREDECESSOR_GENERATION_ENV,
18
+ MANAGED_OWNER_PREDECESSOR_INCARNATION_ENV,
19
+ MANAGED_OWNER_PREDECESSOR_RUN_ID_ENV,
20
+ MANAGED_OWNER_PREDECESSOR_TOKEN_ENV,
21
+ MANAGED_OWNER_TRANSCRIPT_PATH_ENV,
22
+ } from "../../gjc-runtime/managed-owner-admission";
23
+ import {
24
+ MANAGED_OWNER_INCARNATION_ENV,
25
+ MANAGED_OWNER_RUN_ID_ENV,
26
+ MANAGED_OWNER_SUPERVISOR_ARG,
27
+ } from "../../gjc-runtime/managed-owner-supervisor";
16
28
  import { tmuxRuntimeSessionPath } from "../../gjc-runtime/session-layout";
17
29
  import {
18
30
  GJC_COORDINATOR_SESSION_ID_ENV,
@@ -35,6 +47,7 @@ import {
35
47
  type OwnerIsolationProbe,
36
48
  planTmuxOwnerIsolation,
37
49
  replaceOwnerGeneration,
50
+ resolveManagedOwnerPredecessorSync,
38
51
  type TmuxServerProof,
39
52
  } from "../../gjc-runtime/tmux-owner-isolation";
40
53
  import {
@@ -768,8 +781,10 @@ async function completeLifecycleSpawnTransaction(input: {
768
781
  argv: string[];
769
782
  ownerIsolationProbe?: OwnerIsolationProbe;
770
783
  prepareSpawn?: () => void;
784
+ previousBaseline?: OwnerGenerationBaseline;
771
785
  }): Promise<void> {
772
- const previousBaseline = await captureOwnerGenerationBaseline(input.stateDir, input.sessionId);
786
+ const previousBaseline =
787
+ input.previousBaseline ?? (await captureOwnerGenerationBaseline(input.stateDir, input.sessionId));
773
788
  let ownerExecution: LifecycleAttemptExecution | undefined;
774
789
  try {
775
790
  ownerExecution = await executeLifecycleTmuxOwnerPlan({
@@ -817,6 +832,7 @@ async function completeLifecycleSpawnTransaction(input: {
817
832
  throw new Error("gjc_lifecycle_owner_server_unverifiable");
818
833
  if (!(await isLifecycleGenerationUnchanged(input.stateDir, input.sessionId, previousBaseline)))
819
834
  throw new Error("gjc_lifecycle_owner_generation_changed");
835
+ resolveManagedOwnerPredecessorSync(input.stateDir, input.sessionId, previousBaseline);
820
836
  await replaceOwnerGeneration(input.stateDir, input.sessionId, input.generation, previousBaseline);
821
837
  } catch (error) {
822
838
  let cleanupFailure: unknown;
@@ -857,7 +873,11 @@ export function daemonSpawnCreate(
857
873
  const { cwd, args } = buildCreateArgv(frame, ids);
858
874
  const sessionStateFile = lifecycleRuntimeStateFile(cwd, ids.intendedSessionId, name);
859
875
  const stateDir = path.dirname(sessionStateFile);
876
+ const previousBaseline = await captureOwnerGenerationBaseline(stateDir, ids.intendedSessionId);
877
+ const predecessor = resolveManagedOwnerPredecessorSync(stateDir, ids.intendedSessionId, previousBaseline);
860
878
  const generation = crypto.randomUUID();
879
+ const runId = crypto.randomUUID();
880
+ const incarnation = crypto.randomUUID();
861
881
  // Detached: no interactive TTY needed (daemon-safe). These values contain
862
882
  // only opaque ids and paths needed by the resident sidecar to publish its
863
883
  // exact-owner terminal verdict.
@@ -871,12 +891,24 @@ export function daemonSpawnCreate(
871
891
  [GJC_TMUX_OWNER_GENERATION_ENV]: generation,
872
892
  [GJC_TMUX_OWNER_STATE_DIR_ENV]: stateDir,
873
893
  [GJC_TMUX_OWNER_SERVER_KEY_ENV]: "default",
894
+ GJC_MANAGED_OWNER_COMMAND_JSON: JSON.stringify(["gjc", ...args]),
895
+ [MANAGED_OWNER_RUN_ID_ENV]: runId,
896
+ [MANAGED_OWNER_INCARNATION_ENV]: incarnation,
897
+ ...(predecessor
898
+ ? {
899
+ [MANAGED_OWNER_PREDECESSOR_TOKEN_ENV]: predecessor.predecessorToken,
900
+ [MANAGED_OWNER_PREDECESSOR_GENERATION_ENV]: predecessor.generation,
901
+ [MANAGED_OWNER_PREDECESSOR_RUN_ID_ENV]: predecessor.runId,
902
+ [MANAGED_OWNER_PREDECESSOR_INCARNATION_ENV]: predecessor.incarnation,
903
+ [MANAGED_OWNER_TRANSCRIPT_PATH_ENV]: env.GJC_SESSION_FILE ?? "",
904
+ }
905
+ : {}),
874
906
  };
875
907
  if (ids.startupPromptRef) childEnv.GJC_STARTUP_PROMPT_REF = ids.startupPromptRef;
876
908
  const envPairs = Object.entries(childEnv)
877
909
  .map(([key, value]) => `${key}=${shellQuote(value)}`)
878
910
  .join(" ");
879
- const command = `cd ${shellQuote(cwd)} && exec env ${envPairs} gjc ${args.map(shellQuote).join(" ")}`;
911
+ const command = `cd ${shellQuote(cwd)} && exec env ${envPairs} gjc ${shellQuote(MANAGED_OWNER_SUPERVISOR_ARG)}`;
880
912
  await completeLifecycleSpawnTransaction({
881
913
  tmux,
882
914
  env,
@@ -891,6 +923,7 @@ export function daemonSpawnCreate(
891
923
  prepareSpawn: () => {
892
924
  if (frame.target.kind === "plain_dir") fs.mkdirSync(cwd, { recursive: true });
893
925
  },
926
+ previousBaseline,
894
927
  });
895
928
 
896
929
  return {
@@ -1053,7 +1086,11 @@ export function daemonResumeSession(
1053
1086
  const name = tmuxSessionNameFor(resumeId);
1054
1087
  const sessionStateFile = lifecycleRuntimeStateFile(resolvedResumeCwd, resumeId, name);
1055
1088
  const stateDir = path.dirname(sessionStateFile);
1089
+ const previousBaseline = await captureOwnerGenerationBaseline(stateDir, resumeId);
1090
+ const predecessor = resolveManagedOwnerPredecessorSync(stateDir, resumeId, previousBaseline);
1056
1091
  const generation = crypto.randomUUID();
1092
+ const runId = crypto.randomUUID();
1093
+ const incarnation = crypto.randomUUID();
1057
1094
  const childEnv: Record<string, string> = {
1058
1095
  GJC_TMUX_LAUNCHED: "1",
1059
1096
  GJC_NOTIFICATIONS: "1",
@@ -1062,11 +1099,23 @@ export function daemonResumeSession(
1062
1099
  [GJC_TMUX_OWNER_GENERATION_ENV]: generation,
1063
1100
  [GJC_TMUX_OWNER_STATE_DIR_ENV]: stateDir,
1064
1101
  [GJC_TMUX_OWNER_SERVER_KEY_ENV]: "default",
1102
+ GJC_MANAGED_OWNER_COMMAND_JSON: JSON.stringify(["gjc", "--resume", resumeId]),
1103
+ [MANAGED_OWNER_RUN_ID_ENV]: runId,
1104
+ [MANAGED_OWNER_INCARNATION_ENV]: incarnation,
1105
+ ...(predecessor
1106
+ ? {
1107
+ [MANAGED_OWNER_PREDECESSOR_TOKEN_ENV]: predecessor.predecessorToken,
1108
+ [MANAGED_OWNER_PREDECESSOR_GENERATION_ENV]: predecessor.generation,
1109
+ [MANAGED_OWNER_PREDECESSOR_RUN_ID_ENV]: predecessor.runId,
1110
+ [MANAGED_OWNER_PREDECESSOR_INCARNATION_ENV]: predecessor.incarnation,
1111
+ [MANAGED_OWNER_TRANSCRIPT_PATH_ENV]: env.GJC_SESSION_FILE ?? "",
1112
+ }
1113
+ : {}),
1065
1114
  };
1066
1115
  const envPairs = Object.entries(childEnv)
1067
1116
  .map(([key, value]) => `${key}=${shellQuote(value)}`)
1068
1117
  .join(" ");
1069
- const command = `cd ${shellQuote(resolvedResumeCwd)} && exec env ${envPairs} gjc --resume ${shellQuote(resumeId)}`;
1118
+ const command = `cd ${shellQuote(resolvedResumeCwd)} && exec env ${envPairs} gjc ${shellQuote(MANAGED_OWNER_SUPERVISOR_ARG)}`;
1070
1119
  await completeLifecycleSpawnTransaction({
1071
1120
  tmux,
1072
1121
  env,
@@ -1078,6 +1127,7 @@ export function daemonResumeSession(
1078
1127
  sessionStateFile,
1079
1128
  argv: [tmux, "new-session", "-d", "-P", "-F", "#{session_id}", "-s", name, "sh", "-c", command],
1080
1129
  ownerIsolationProbe: opts.ownerIsolationProbe,
1130
+ previousBaseline,
1081
1131
  });
1082
1132
 
1083
1133
  return {
@@ -11,11 +11,13 @@
11
11
  * touches a live owner's lock/state and never kills a process.
12
12
  */
13
13
  import * as crypto from "node:crypto";
14
+ import type { WriteFileOptions } from "node:fs";
14
15
  import * as fsSync from "node:fs";
15
16
  import * as fsPromises from "node:fs/promises";
16
17
  import * as path from "node:path";
17
18
  import * as native from "@gajae-code/natives";
18
19
  import type { Settings } from "../../config/settings";
20
+ import { isProcessIncarnation, processIncarnation } from "../broker/process-incarnation";
19
21
  import {
20
22
  getNotificationConfig,
21
23
  isDiscordConfigured,
@@ -67,6 +69,66 @@ export interface NotificationExactUnlinkResult {
67
69
  ok: boolean;
68
70
  code?: string;
69
71
  detachedPath?: string;
72
+ /** A live publisher successor retained after an exact-unlink race. */
73
+ retainedSuccessorPath?: string;
74
+ /** An internal exchange placeholder whose verified cleanup failed. */
75
+ retainedPlaceholderPath?: string;
76
+ /** A cleanup entry whose identity could not be verified after a race. */
77
+ retainedUnknownPath?: string;
78
+ }
79
+
80
+ /** Read one regular file together with the identity required for exact removal. */
81
+ export async function readNotificationEndpointFile(file: string): Promise<NotificationEndpointFile> {
82
+ const before = await fsPromises.lstat(file, { bigint: true });
83
+ if (!before.isFile() || before.isSymbolicLink()) throw new Error("Endpoint is not a regular file");
84
+ const noFollow = fsSync.constants.O_NOFOLLOW;
85
+ const handle = await fsPromises.open(file, fsSync.constants.O_RDONLY | (noFollow ?? 0));
86
+ try {
87
+ const opened = await handle.stat({ bigint: true });
88
+ if (!opened.isFile() || !sameEndpointFileMetadata(before, opened))
89
+ throw new Error("Endpoint changed before it was opened");
90
+ const bytes = await handle.readFile();
91
+ const after = await handle.stat({ bigint: true });
92
+ const pathname = await fsPromises.lstat(file, { bigint: true });
93
+ if (
94
+ !after.isFile() ||
95
+ !pathname.isFile() ||
96
+ pathname.isSymbolicLink() ||
97
+ !sameEndpointFileMetadata(opened, after) ||
98
+ !sameEndpointFileMetadata(opened, pathname)
99
+ )
100
+ throw new Error("Endpoint changed while it was read");
101
+ return {
102
+ bytes,
103
+ identity: {
104
+ dev: opened.dev,
105
+ ino: opened.ino,
106
+ size: opened.size,
107
+ mtimeNs: opened.mtimeNs,
108
+ sha256: crypto.createHash("sha256").update(bytes).digest("hex"),
109
+ },
110
+ };
111
+ } finally {
112
+ await handle.close();
113
+ }
114
+ }
115
+ /** Bump when the native exact-deletion identity contract changes. */
116
+ export const NATIVE_PATH_IDENTITY_CONTRACT_VERSION = 1;
117
+
118
+ export function exactUnlinkNotificationFile(
119
+ file: string,
120
+ identity: NotificationEndpointFileIdentity,
121
+ quarantineName: string,
122
+ ): NotificationExactUnlinkResult {
123
+ const result = native.exactUnlink(file, { ...identity, quarantineName });
124
+ return {
125
+ ok: result.ok,
126
+ code: result.code,
127
+ detachedPath: result.detachedPath,
128
+ retainedSuccessorPath: result.retainedSuccessorPath,
129
+ retainedPlaceholderPath: result.retainedPlaceholderPath,
130
+ retainedUnknownPath: result.retainedUnknownPath,
131
+ };
70
132
  }
71
133
 
72
134
  /** Minimal filesystem surface the service needs; injectable for tests. */
@@ -76,69 +138,19 @@ export interface NotificationServiceFs {
76
138
  readEndpointFile(file: string): Promise<NotificationEndpointFile>;
77
139
  exactUnlink(file: string, identity: NotificationEndpointFileIdentity): Promise<NotificationExactUnlinkResult>;
78
140
  unlink(file: string): Promise<void>;
79
- /**
80
- * Atomically create `file` with O_EXCL semantics: resolves `true` when this
81
- * call created it, `false` when it already existed. Used to hold the daemon
82
- * steal-mutex ({@link DaemonPaths.steal}) during owner-bound lock removal so
83
- * recovery and a concurrent daemon takeover are mutually exclusive.
84
- */
85
- createExclusive(file: string): Promise<boolean>;
141
+ writeFile?(file: string, data: string, opts?: WriteFileOptions): Promise<void>;
142
+ stat?(file: string): Promise<{ mtimeMs: number }>;
86
143
  }
87
144
 
88
145
  const nodeServiceFs: NotificationServiceFs = {
89
146
  readdir: dir => fsPromises.readdir(dir),
90
147
  readFile: (file, encoding) => fsPromises.readFile(file, encoding),
91
- readEndpointFile: async file => {
92
- const before = await fsPromises.lstat(file, { bigint: true });
93
- if (!before.isFile() || before.isSymbolicLink()) throw new Error("Endpoint is not a regular file");
94
- const noFollow = fsSync.constants.O_NOFOLLOW;
95
- const handle = await fsPromises.open(file, fsSync.constants.O_RDONLY | (noFollow ?? 0));
96
- try {
97
- const opened = await handle.stat({ bigint: true });
98
- if (!opened.isFile() || !sameEndpointFileMetadata(before, opened))
99
- throw new Error("Endpoint changed before it was opened");
100
- const bytes = await handle.readFile();
101
- const after = await handle.stat({ bigint: true });
102
- const pathname = await fsPromises.lstat(file, { bigint: true });
103
- if (
104
- !after.isFile() ||
105
- !pathname.isFile() ||
106
- pathname.isSymbolicLink() ||
107
- !sameEndpointFileMetadata(opened, after) ||
108
- !sameEndpointFileMetadata(opened, pathname)
109
- )
110
- throw new Error("Endpoint changed while it was read");
111
- return {
112
- bytes,
113
- identity: {
114
- dev: opened.dev,
115
- ino: opened.ino,
116
- size: opened.size,
117
- mtimeNs: opened.mtimeNs,
118
- sha256: crypto.createHash("sha256").update(bytes).digest("hex"),
119
- },
120
- };
121
- } finally {
122
- await handle.close();
123
- }
124
- },
125
- exactUnlink: async (file, identity) => {
126
- const result = native.exactUnlink(file, {
127
- ...identity,
128
- quarantineName: `.gjc-delete-notification-endpoint-${crypto.randomUUID()}.json`,
129
- });
130
- return { ok: result.ok, code: result.code, detachedPath: result.detachedPath };
131
- },
148
+ readEndpointFile: readNotificationEndpointFile,
149
+ exactUnlink: async (file, identity) =>
150
+ exactUnlinkNotificationFile(file, identity, `.gjc-delete-notification-endpoint-${crypto.randomUUID()}.json`),
132
151
  unlink: file => fsPromises.unlink(file),
133
- createExclusive: async file => {
134
- try {
135
- const handle = await fsPromises.open(file, "wx", 0o600);
136
- await handle.close();
137
- return true;
138
- } catch {
139
- return false;
140
- }
141
- },
152
+ writeFile: (file, data, opts) => fsPromises.writeFile(file, data, opts),
153
+ stat: file => fsPromises.stat(file),
142
154
  };
143
155
 
144
156
  /** Injectable dependencies shared across service operations. */
@@ -766,6 +778,12 @@ export interface NotificationRecoveryReport {
766
778
  endpointsKept: number;
767
779
  endpointsUnreadable: number;
768
780
  endpointsDetached?: string[];
781
+ /** Successor paths retained after an exact-unlink race, distinct from stale quarantines. */
782
+ endpointsRetainedSuccessors?: string[];
783
+ /** Internal exchange placeholders retained after verified cleanup failure. */
784
+ endpointsRetainedPlaceholders?: string[];
785
+ /** Cleanup entries retained with unverified or mismatching identity. */
786
+ endpointsRetainedUnknown?: string[];
769
787
  daemon: {
770
788
  action: DaemonRecoveryAction;
771
789
  detail: string;
@@ -780,6 +798,179 @@ export interface RecoveryOptions {
780
798
  deps?: NotificationServiceDeps;
781
799
  }
782
800
 
801
+ export interface DaemonTransitionLock {
802
+ pid: number;
803
+ incarnation: string;
804
+ createdAt: number;
805
+ /** Unique fencing generation for this particular transition acquisition. */
806
+ token: string;
807
+ }
808
+
809
+ function isDaemonTransitionLock(value: unknown): value is DaemonTransitionLock {
810
+ if (!value || typeof value !== "object") return false;
811
+ const candidate = value as Record<string, unknown>;
812
+ return (
813
+ Number.isSafeInteger(candidate.pid) &&
814
+ (candidate.pid as number) > 0 &&
815
+ typeof candidate.incarnation === "string" &&
816
+ typeof candidate.createdAt === "number" &&
817
+ typeof candidate.token === "string" &&
818
+ candidate.token.length > 0
819
+ );
820
+ }
821
+
822
+ interface TransitionMarkerSnapshot {
823
+ raw: string;
824
+ identity?: NotificationEndpointFileIdentity;
825
+ }
826
+
827
+ type TransitionMarkerFs = {
828
+ readFile(file: string, encoding: "utf8"): Promise<string>;
829
+ writeFile?(file: string, data: string, opts?: WriteFileOptions): Promise<void>;
830
+ readEndpointFile?(file: string): Promise<NotificationEndpointFile>;
831
+ exactUnlink?(file: string, identity: NotificationEndpointFileIdentity): Promise<NotificationExactUnlinkResult>;
832
+ };
833
+
834
+ async function readTransitionMarker(
835
+ fs: TransitionMarkerFs,
836
+ path: string,
837
+ ): Promise<TransitionMarkerSnapshot | undefined> {
838
+ if (fs.readEndpointFile) {
839
+ const exact = await fs.readEndpointFile(path).catch(() => undefined);
840
+ if (!exact) return undefined;
841
+ return { raw: exact.bytes.toString("utf8"), identity: exact.identity };
842
+ }
843
+ const raw = await fs.readFile(path, "utf8").catch(() => undefined);
844
+ if (raw === undefined) return undefined;
845
+ return { raw };
846
+ }
847
+
848
+ function transitionMarkerMatchesLock(snapshot: TransitionMarkerSnapshot, lock: DaemonTransitionLock): boolean {
849
+ try {
850
+ const current = JSON.parse(snapshot.raw);
851
+ return (
852
+ isDaemonTransitionLock(current) &&
853
+ current.pid === lock.pid &&
854
+ current.incarnation === lock.incarnation &&
855
+ current.token === lock.token
856
+ );
857
+ } catch {
858
+ return false;
859
+ }
860
+ }
861
+
862
+ /** True only while the exact transition acquisition still occupies the marker path. */
863
+ export async function daemonTransitionLockIsHeld(input: {
864
+ fs: Pick<TransitionMarkerFs, "readFile" | "readEndpointFile">;
865
+ path: string;
866
+ lock: DaemonTransitionLock;
867
+ }): Promise<boolean> {
868
+ const snapshot = await readTransitionMarker(input.fs, input.path);
869
+ return Boolean(snapshot && transitionMarkerMatchesLock(snapshot, input.lock));
870
+ }
871
+
872
+ /** Atomically detaches only the captured transition marker, never a pathname successor. */
873
+ async function detachTransitionMarker(
874
+ fs: TransitionMarkerFs,
875
+ path: string,
876
+ snapshot: TransitionMarkerSnapshot,
877
+ ): Promise<boolean> {
878
+ if (!snapshot.identity || !fs.exactUnlink) return false;
879
+ try {
880
+ return (await fs.exactUnlink(path, snapshot.identity)).ok;
881
+ } catch {
882
+ return false;
883
+ }
884
+ }
885
+
886
+ /** Removes only the caller's exact marker through the identity-bound detach primitive. */
887
+ export async function releaseDaemonTransitionLock(input: {
888
+ fs: TransitionMarkerFs;
889
+ path: string;
890
+ lock: DaemonTransitionLock;
891
+ }): Promise<boolean> {
892
+ const snapshot = await readTransitionMarker(input.fs, input.path);
893
+ if (!snapshot || !transitionMarkerMatchesLock(snapshot, input.lock)) return false;
894
+ return await detachTransitionMarker(input.fs, input.path, snapshot);
895
+ }
896
+
897
+ /**
898
+ * Acquire the daemon lifecycle transition lock using durable owner metadata.
899
+ * The full marker is published in the single O_EXCL write which reserves it;
900
+ * canonical markers are detached only through their captured filesystem identity.
901
+ *
902
+ * Malformed and empty markers deliberately remain blocked regardless of age. They
903
+ * have no owner provenance, so reclaiming them could detach a generation-6 empty
904
+ * reservation while its paused legacy writer can still resume. Operators must
905
+ * manually clean up such legacy debris after confirming no legacy process remains.
906
+ */
907
+ export async function acquireDaemonTransitionLock(input: {
908
+ fs: TransitionMarkerFs;
909
+ path: string;
910
+ pid: number;
911
+ pidAlive: (pid: number) => boolean;
912
+ pidIncarnation: (pid: number) => string | undefined;
913
+ now?: () => number;
914
+ sleep?: (ms: number) => Promise<void>;
915
+ retries?: number;
916
+ retryDelayMs?: number;
917
+ randomToken?: () => string;
918
+ }): Promise<DaemonTransitionLock | undefined> {
919
+ const sleep = input.sleep ?? (async (ms: number) => await Bun.sleep(ms));
920
+ const now = input.now ?? Date.now;
921
+ const retries = Math.max(input.retries ?? 5, 0);
922
+ const retryDelayMs = Math.max(input.retryDelayMs ?? 20, 0);
923
+ const incarnation = input.pidIncarnation(input.pid);
924
+ if (!isProcessIncarnation(incarnation)) return undefined;
925
+ const token = input.randomToken?.() ?? crypto.randomUUID();
926
+ if (!token || !input.fs.writeFile || !input.fs.readEndpointFile || !input.fs.exactUnlink) return undefined;
927
+ const owner: DaemonTransitionLock = { pid: input.pid, incarnation, createdAt: now(), token };
928
+ for (let attempt = 0; attempt <= retries; attempt++) {
929
+ try {
930
+ await input.fs.writeFile(input.path, `${JSON.stringify(owner)}\n`, { mode: 0o600, flag: "wx" });
931
+ return owner;
932
+ } catch (error) {
933
+ if (["EEXIST", "ENOENT"].includes((error as NodeJS.ErrnoException).code ?? "")) {
934
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
935
+ } else throw error;
936
+ }
937
+ const snapshot = await readTransitionMarker(input.fs, input.path);
938
+ let current: unknown;
939
+ try {
940
+ current = snapshot === undefined ? undefined : JSON.parse(snapshot.raw);
941
+ } catch {
942
+ current = undefined;
943
+ }
944
+ // Only canonical owner records prove which process may be reclaimed. An
945
+ // unreadable or empty legacy reservation is intentionally not age-reclaimed:
946
+ // it may belong to a paused generation-6 two-step publisher.
947
+ const ownerAlive = isDaemonTransitionLock(current) && input.pidAlive(current.pid);
948
+ const ownerIncarnation = isDaemonTransitionLock(current) ? input.pidIncarnation(current.pid) : undefined;
949
+ const reclaimable =
950
+ isDaemonTransitionLock(current) &&
951
+ (!ownerAlive ||
952
+ (isProcessIncarnation(current.incarnation) &&
953
+ isProcessIncarnation(ownerIncarnation) &&
954
+ ownerIncarnation !== current.incarnation));
955
+ if (snapshot && reclaimable) await detachTransitionMarker(input.fs, input.path, snapshot);
956
+ if (attempt < retries) await sleep(retryDelayMs);
957
+ }
958
+ return undefined;
959
+ }
960
+
961
+ function defaultTransitionPidAlive(pid: number): boolean {
962
+ try {
963
+ process.kill(pid, 0);
964
+ return true;
965
+ } catch (err) {
966
+ return (err as NodeJS.ErrnoException).code === "EPERM";
967
+ }
968
+ }
969
+
970
+ function defaultTransitionPidIncarnation(pid: number): string | undefined {
971
+ return processIncarnation(pid);
972
+ }
973
+
783
974
  /**
784
975
  * Owner-bound removal of a dead daemon's lock. Closes the classic
785
976
  * check-then-unlink TOCTOU: a naive `unlink(lock)` after observing a dead owner
@@ -795,13 +986,21 @@ async function removeDeadOwnerLock(
795
986
  pidAlive: (pid: number) => boolean,
796
987
  expected: NormalizedDaemonState,
797
988
  ): Promise<"cleared" | "contended" | "superseded" | "now-alive" | "unlink-failed"> {
798
- if (!(await fs.createExclusive(paths.steal))) return "contended";
989
+ const transition = await acquireDaemonTransitionLock({
990
+ fs,
991
+ path: paths.steal,
992
+ pid: process.pid,
993
+ pidAlive: defaultTransitionPidAlive,
994
+ pidIncarnation: defaultTransitionPidIncarnation,
995
+ });
996
+ if (!transition) return "contended";
799
997
  try {
800
998
  const current = await readDaemonStateFile(fs, paths.state);
801
999
  if (!current || current.ownerId !== expected.ownerId || current.pid !== expected.pid) {
802
1000
  return "superseded";
803
1001
  }
804
1002
  if (pidAlive(current.pid)) return "now-alive";
1003
+ if (!(await daemonTransitionLockIsHeld({ fs, path: paths.steal, lock: transition }))) return "contended";
805
1004
  try {
806
1005
  await fs.unlink(paths.lock);
807
1006
  return "cleared";
@@ -809,7 +1008,7 @@ async function removeDeadOwnerLock(
809
1008
  return "unlink-failed";
810
1009
  }
811
1010
  } finally {
812
- await fs.unlink(paths.steal).catch(() => undefined);
1011
+ await releaseDaemonTransitionLock({ fs, path: paths.steal, lock: transition });
813
1012
  }
814
1013
  }
815
1014
 
@@ -833,6 +1032,10 @@ export async function recoverNotifications(opts: RecoveryOptions): Promise<Notif
833
1032
  const files = await listEndpointFiles(fs, dir);
834
1033
  const removed: RecoveredEndpoint[] = [];
835
1034
  const detached: string[] = [];
1035
+ const retainedSuccessors: string[] = [];
1036
+ const retainedPlaceholders: string[] = [];
1037
+ const retainedUnknown: string[] = [];
1038
+ let recoveryFailures = 0;
836
1039
  let kept = 0;
837
1040
  let unreadable = 0;
838
1041
  for (const name of files) {
@@ -854,8 +1057,18 @@ export async function recoverNotifications(opts: RecoveryOptions): Promise<Notif
854
1057
  try {
855
1058
  const result = await fs.exactUnlink(file, record.identity);
856
1059
  if (!result.ok) {
1060
+ recoveryFailures += 1;
857
1061
  if (result.detachedPath) detached.push(result.detachedPath);
858
- else kept += 1;
1062
+ if (result.retainedSuccessorPath) retainedSuccessors.push(result.retainedSuccessorPath);
1063
+ if (result.retainedPlaceholderPath) retainedPlaceholders.push(result.retainedPlaceholderPath);
1064
+ if (result.retainedUnknownPath) retainedUnknown.push(result.retainedUnknownPath);
1065
+ if (
1066
+ !result.detachedPath &&
1067
+ !result.retainedSuccessorPath &&
1068
+ !result.retainedPlaceholderPath &&
1069
+ !result.retainedUnknownPath
1070
+ )
1071
+ kept += 1;
859
1072
  continue;
860
1073
  }
861
1074
  removed.push({
@@ -928,11 +1141,14 @@ export async function recoverNotifications(opts: RecoveryOptions): Promise<Notif
928
1141
  }
929
1142
 
930
1143
  return {
931
- endpointsScanned: removed.length + kept + unreadable + detached.length,
1144
+ endpointsScanned: removed.length + kept + unreadable + recoveryFailures,
932
1145
  endpointsRemoved: removed,
933
1146
  endpointsKept: kept,
934
1147
  endpointsUnreadable: unreadable,
935
1148
  endpointsDetached: detached,
1149
+ endpointsRetainedSuccessors: retainedSuccessors,
1150
+ endpointsRetainedPlaceholders: retainedPlaceholders,
1151
+ endpointsRetainedUnknown: retainedUnknown,
936
1152
  daemon,
937
1153
  };
938
1154
  }
@@ -941,12 +1157,18 @@ export async function recoverNotifications(opts: RecoveryOptions): Promise<Notif
941
1157
  export function formatNotificationRecoveryReport(report: NotificationRecoveryReport): string {
942
1158
  const lines = ["Notification recovery"];
943
1159
  lines.push(
944
- ` endpoints: scanned ${report.endpointsScanned}, removed ${report.endpointsRemoved.length}, kept ${report.endpointsKept}, unreadable ${report.endpointsUnreadable}, detached ${report.endpointsDetached?.length ?? 0}`,
1160
+ ` endpoints: scanned ${report.endpointsScanned}, removed ${report.endpointsRemoved.length}, kept ${report.endpointsKept}, unreadable ${report.endpointsUnreadable}, detached ${report.endpointsDetached?.length ?? 0}, retained successors ${report.endpointsRetainedSuccessors?.length ?? 0}, retained placeholders ${report.endpointsRetainedPlaceholders?.length ?? 0}, retained unknown ${report.endpointsRetainedUnknown?.length ?? 0}`,
945
1161
  );
946
1162
  for (const ep of report.endpointsRemoved) {
947
1163
  lines.push(` - removed ${ep.sessionId} (pid ${ep.pid ?? "?"}, ${ep.reason})`);
948
1164
  }
949
1165
  for (const detached of report.endpointsDetached ?? []) lines.push(` - retained detached endpoint ${detached}`);
1166
+ for (const successor of report.endpointsRetainedSuccessors ?? [])
1167
+ lines.push(` - retained successor endpoint ${successor}`);
1168
+ for (const placeholder of report.endpointsRetainedPlaceholders ?? [])
1169
+ lines.push(` - retained exchange placeholder cleanup path ${placeholder}`);
1170
+ for (const unknown of report.endpointsRetainedUnknown ?? [])
1171
+ lines.push(` - retained unverified cleanup path ${unknown}`);
950
1172
  lines.push(` daemon: ${report.daemon.action} — ${report.daemon.detail}`);
951
1173
  return lines.join("\n");
952
1174
  }
@@ -154,10 +154,12 @@ async function resolveRecentScopes(
154
154
  agentDir,
155
155
  sessionsRoot,
156
156
  });
157
- if (
158
- resolved.kind !== "resolved" ||
159
- (expectedDirectory !== undefined && resolved.scope.directoryPath !== expectedDirectory)
160
- ) {
157
+ if (resolved.kind !== "resolved") {
158
+ if (resolved.code !== "cwd_missing" && resolved.code !== "cwd_not_directory")
159
+ warnings.push("Ignored invalid managed session scope binding.");
160
+ return;
161
+ }
162
+ if (expectedDirectory !== undefined && resolved.scope.directoryPath !== expectedDirectory) {
161
163
  warnings.push("Ignored invalid managed session scope binding.");
162
164
  return;
163
165
  }
@@ -232,7 +234,14 @@ export async function listRecentSessions(deps: RecentActivityDeps): Promise<List
232
234
  return { kind: "error", code: "managed_scan_failed", message: listed.message };
233
235
  }
234
236
  candidates.push(...listed.owned);
235
- warnings.push(...listed.invalid.map(invalid => `Ignored invalid managed session candidate: ${invalid.code}`));
237
+ warnings.push(
238
+ ...listed.invalid
239
+ .filter(
240
+ invalid =>
241
+ !deps.allWorkspaces || (invalid.code !== "cwd_not_found" && invalid.code !== "cwd_not_directory"),
242
+ )
243
+ .map(invalid => `Ignored invalid managed session candidate: ${invalid.code}`),
244
+ );
236
245
  }
237
246
 
238
247
  const entries: RecentSessionEntry[] = [];