@gajae-code/coding-agent 0.11.6 → 0.11.8

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 (259) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/dist/types/capability/index.d.ts +10 -20
  3. package/dist/types/capability/types.d.ts +3 -0
  4. package/dist/types/cli/args.d.ts +0 -2
  5. package/dist/types/commands/daemon.d.ts +2 -2
  6. package/dist/types/commands/sdk.d.ts +20 -2
  7. package/dist/types/config/keybindings.d.ts +34 -4
  8. package/dist/types/config/model-profile-activation.d.ts +4 -3
  9. package/dist/types/config/model-profile-contract.d.ts +38 -0
  10. package/dist/types/config/settings-schema.d.ts +70 -7
  11. package/dist/types/edit/modes/patch.d.ts +13 -0
  12. package/dist/types/edit/path-mutation-lock.d.ts +15 -0
  13. package/dist/types/eval/py/executor.d.ts +1 -1
  14. package/dist/types/eval/py/runner-artifact.d.ts +8 -0
  15. package/dist/types/eval/py/tool-bridge.d.ts +1 -2
  16. package/dist/types/gjc-runtime/repository-binding.d.ts +63 -0
  17. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +6 -5
  18. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +20 -0
  19. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +43 -5
  20. package/dist/types/main.d.ts +32 -1
  21. package/dist/types/modes/components/btw-panel.d.ts +1 -0
  22. package/dist/types/modes/components/model-selector.d.ts +2 -0
  23. package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +2 -1
  24. package/dist/types/modes/components/tool-status-header.d.ts +4 -2
  25. package/dist/types/modes/components/welcome.d.ts +2 -0
  26. package/dist/types/modes/controllers/btw-controller.d.ts +5 -0
  27. package/dist/types/modes/controllers/command-controller.d.ts +2 -0
  28. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  29. package/dist/types/modes/interactive-mode.d.ts +7 -2
  30. package/dist/types/modes/prompt-action-autocomplete.d.ts +6 -1
  31. package/dist/types/modes/prompt-suggestion-controller.d.ts +35 -0
  32. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -2
  33. package/dist/types/modes/types.d.ts +4 -0
  34. package/dist/types/modes/utils/hotkeys-markdown.d.ts +2 -1
  35. package/dist/types/runtime-mcp/content-limits.d.ts +7 -0
  36. package/dist/types/runtime-mcp/manager.d.ts +1 -1
  37. package/dist/types/runtime-mcp/oauth-flow.d.ts +0 -5
  38. package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +14 -0
  39. package/dist/types/sdk/broker/broker.d.ts +2 -0
  40. package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
  41. package/dist/types/sdk/bus/chat-daemon-control.d.ts +6 -1
  42. package/dist/types/sdk/bus/config-commands.d.ts +2 -2
  43. package/dist/types/sdk/bus/config.d.ts +2 -0
  44. package/dist/types/sdk/bus/conversation-store.d.ts +1 -0
  45. package/dist/types/sdk/bus/index.d.ts +26 -1
  46. package/dist/types/sdk/bus/lifecycle-commands.d.ts +5 -4
  47. package/dist/types/sdk/bus/notification-orchestration.d.ts +1 -0
  48. package/dist/types/sdk/bus/notification-service.d.ts +34 -0
  49. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +105 -0
  50. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +18 -3
  51. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -1
  52. package/dist/types/sdk/bus/telegram-daemon.d.ts +189 -4
  53. package/dist/types/sdk/bus/telegram-reference.d.ts +2 -0
  54. package/dist/types/sdk/bus/topic-registry.d.ts +88 -2
  55. package/dist/types/sdk/client/discovery.d.ts +2 -0
  56. package/dist/types/sdk/client/liveness.d.ts +7 -0
  57. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  58. package/dist/types/sdk/host/host.d.ts +1 -1
  59. package/dist/types/sdk/host/query/handlers.d.ts +9 -0
  60. package/dist/types/sdk/index.d.ts +2 -0
  61. package/dist/types/sdk/prompt-status.d.ts +88 -0
  62. package/dist/types/sdk/session-directory.d.ts +1 -1
  63. package/dist/types/sdk/startup-capability.d.ts +3 -0
  64. package/dist/types/sdk/transport/auth-preface.d.ts +9 -0
  65. package/dist/types/sdk/transport/index.d.ts +12 -0
  66. package/dist/types/sdk/transport/relay.d.ts +43 -0
  67. package/dist/types/sdk/transport/serve-cli.d.ts +4 -0
  68. package/dist/types/sdk/transport/socket.d.ts +7 -0
  69. package/dist/types/sdk/transport/stdio.d.ts +3 -0
  70. package/dist/types/session/agent-session.d.ts +30 -5
  71. package/dist/types/session/btw-contract.d.ts +16 -0
  72. package/dist/types/session/internal/managed-session-scope.d.ts +32 -9
  73. package/dist/types/session/internal/managed-session-storage.d.ts +27 -10
  74. package/dist/types/session/internal/native-publish-outcome.d.ts +48 -0
  75. package/dist/types/session/session-manager.d.ts +25 -4
  76. package/dist/types/session/session-storage.d.ts +18 -0
  77. package/dist/types/setup/credential-import.d.ts +5 -1
  78. package/dist/types/setup/provider-onboarding.d.ts +2 -0
  79. package/dist/types/ssh/utils.d.ts +1 -0
  80. package/dist/types/task/discovery.d.ts +3 -1
  81. package/dist/types/task/index.d.ts +3 -1
  82. package/dist/types/task/receipt.d.ts +2 -0
  83. package/dist/types/task/types.d.ts +94 -0
  84. package/dist/types/tools/ask.d.ts +454 -174
  85. package/dist/types/tools/fetch.d.ts +5 -8
  86. package/dist/types/tools/image-gen.d.ts +63 -1
  87. package/dist/types/tools/index.d.ts +4 -0
  88. package/dist/types/tools/skill-discovery.d.ts +7 -0
  89. package/dist/types/tools/sqlite-reader.d.ts +7 -0
  90. package/dist/types/utils/prompt-suggestion.d.ts +33 -0
  91. package/dist/types/utils/shell-snapshot.d.ts +12 -0
  92. package/dist/types/web/insane/bridge.d.ts +1 -0
  93. package/dist/types/web/insane/url-guard.d.ts +17 -0
  94. package/package.json +8 -8
  95. package/scripts/capture-platform-shortcut-labels-showcase.ts +342 -0
  96. package/scripts/dogfood-repository-binding.ts +209 -0
  97. package/scripts/generate-sdk-operation-inventory.ts +4 -0
  98. package/scripts/run-test-manifest.ts +4 -4
  99. package/src/capability/index.ts +50 -42
  100. package/src/capability/types.ts +3 -0
  101. package/src/cli/args.ts +2 -92
  102. package/src/cli/notify-cli.ts +5 -38
  103. package/src/cli/setup-cli.ts +10 -4
  104. package/src/cli.ts +52 -9
  105. package/src/commands/daemon.ts +22 -5
  106. package/src/commands/sdk.ts +44 -11
  107. package/src/config/keybindings.ts +158 -25
  108. package/src/config/model-profile-activation.ts +7 -18
  109. package/src/config/model-profile-contract.ts +161 -0
  110. package/src/config/model-profiles.ts +18 -0
  111. package/src/config/model-registry.ts +11 -1
  112. package/src/config/settings-schema.ts +73 -7
  113. package/src/coordinator-mcp/model-preset.ts +21 -74
  114. package/src/defaults/gjc/skills/deep-interview/SKILL.md +2 -2
  115. package/src/defaults/gjc/skills/ralplan/SKILL.md +3 -1
  116. package/src/defaults/gjc/skills/ultragoal/SKILL.md +42 -1
  117. package/src/discovery/ssh.ts +12 -1
  118. package/src/edit/index.ts +3 -6
  119. package/src/edit/modes/patch.ts +38 -1
  120. package/src/edit/modes/replace.ts +42 -0
  121. package/src/edit/path-mutation-lock.ts +112 -0
  122. package/src/eval/py/executor.ts +30 -11
  123. package/src/eval/py/kernel.ts +1 -21
  124. package/src/eval/py/prelude.py +2 -2
  125. package/src/eval/py/runner-artifact.ts +84 -0
  126. package/src/eval/py/tool-bridge.ts +34 -18
  127. package/src/extensibility/gjc-plugins/runtime-adapters.ts +2 -1
  128. package/src/extensibility/plugins/marketplace/fetcher.ts +191 -7
  129. package/src/gjc-runtime/launch-tmux.ts +1 -1
  130. package/src/gjc-runtime/managed-owner-supervisor.ts +83 -4
  131. package/src/gjc-runtime/ralplan-runtime.ts +75 -3
  132. package/src/gjc-runtime/repository-binding.ts +267 -0
  133. package/src/gjc-runtime/session-state-sidecar.ts +25 -9
  134. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -1
  135. package/src/gjc-runtime/tmux-sessions.ts +45 -6
  136. package/src/gjc-runtime/ultragoal-guard.ts +93 -15
  137. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +103 -0
  138. package/src/gjc-runtime/ultragoal-runtime.ts +456 -31
  139. package/src/gjc-runtime/workflow-manifest.generated.json +58 -0
  140. package/src/gjc-runtime/workflow-manifest.ts +23 -0
  141. package/src/internal-urls/docs-index.generated.ts +12 -10
  142. package/src/internal-urls/local-protocol.ts +64 -21
  143. package/src/internal-urls/mcp-protocol.ts +73 -11
  144. package/src/main.ts +111 -37
  145. package/src/modes/DESIGN.md +5 -0
  146. package/src/modes/acp/acp-agent.ts +50 -16
  147. package/src/modes/action-registry.ts +1 -1
  148. package/src/modes/components/btw-panel.ts +82 -16
  149. package/src/modes/components/model-selector.ts +73 -11
  150. package/src/modes/components/plan-preview-overlay.ts +1 -1
  151. package/src/modes/components/runtime-mcp-add-wizard.ts +5 -0
  152. package/src/modes/components/tool-status-header.ts +25 -4
  153. package/src/modes/components/welcome.ts +22 -18
  154. package/src/modes/controllers/btw-controller.ts +119 -36
  155. package/src/modes/controllers/command-controller.ts +97 -54
  156. package/src/modes/controllers/event-controller.ts +10 -0
  157. package/src/modes/controllers/input-controller.ts +38 -2
  158. package/src/modes/controllers/runtime-mcp-command-controller.ts +29 -6
  159. package/src/modes/controllers/selector-controller.ts +149 -63
  160. package/src/modes/interactive-mode.ts +57 -34
  161. package/src/modes/prompt-action-autocomplete.ts +32 -13
  162. package/src/modes/prompt-suggestion-controller.ts +96 -0
  163. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -2
  164. package/src/modes/types.ts +4 -0
  165. package/src/modes/utils/hotkeys-markdown.ts +64 -36
  166. package/src/modes/utils/ui-helpers.ts +5 -3
  167. package/src/prompts/agents/architect.md +0 -1
  168. package/src/prompts/agents/critic.md +0 -1
  169. package/src/prompts/system/btw-user.md +3 -8
  170. package/src/prompts/system/prompt-suggestion-system.md +32 -0
  171. package/src/prompts/system/system-prompt.md +6 -4
  172. package/src/prompts/tools/cron.md +3 -1
  173. package/src/prompts/tools/skill-discovery.md +1 -0
  174. package/src/runtime-mcp/client.ts +41 -55
  175. package/src/runtime-mcp/content-limits.ts +71 -0
  176. package/src/runtime-mcp/json-rpc.ts +23 -9
  177. package/src/runtime-mcp/manager.ts +51 -53
  178. package/src/runtime-mcp/oauth-flow.ts +0 -72
  179. package/src/runtime-mcp/plugin-network-boundary.ts +101 -0
  180. package/src/runtime-mcp/transports/http.ts +63 -22
  181. package/src/sdk/broker/broker.ts +34 -20
  182. package/src/sdk/broker/lifecycle.ts +185 -36
  183. package/src/sdk/bus/chat-daemon-control.ts +19 -5
  184. package/src/sdk/bus/config-commands.ts +2 -2
  185. package/src/sdk/bus/config.ts +9 -1
  186. package/src/sdk/bus/conversation-store.ts +40 -6
  187. package/src/sdk/bus/index.ts +244 -70
  188. package/src/sdk/bus/lifecycle-commands.ts +63 -10
  189. package/src/sdk/bus/lifecycle-control-runtime.ts +11 -2
  190. package/src/sdk/bus/lifecycle-orchestrator.ts +32 -1
  191. package/src/sdk/bus/notification-orchestration.ts +12 -0
  192. package/src/sdk/bus/notification-service.ts +68 -20
  193. package/src/sdk/bus/prompt-reconciliation.ts +239 -0
  194. package/src/sdk/bus/recent-activity.ts +90 -26
  195. package/src/sdk/bus/telegram-daemon-contract.ts +18 -3
  196. package/src/sdk/bus/telegram-daemon-control.ts +147 -35
  197. package/src/sdk/bus/telegram-daemon.ts +2463 -450
  198. package/src/sdk/bus/telegram-reference.ts +20 -2
  199. package/src/sdk/bus/topic-registry.ts +451 -12
  200. package/src/sdk/cli/session-cli.ts +15 -2
  201. package/src/sdk/client/discovery.ts +19 -3
  202. package/src/sdk/client/liveness.ts +49 -0
  203. package/src/sdk/host/control/dispatch.ts +1 -1
  204. package/src/sdk/host/control/operations.ts +1 -1
  205. package/src/sdk/host/host.ts +2 -6
  206. package/src/sdk/host/query/handlers.ts +70 -5
  207. package/src/sdk/index.ts +12 -0
  208. package/src/sdk/prompt-status.ts +97 -0
  209. package/src/sdk/protocol/operation-inventory.generated.json +58 -0
  210. package/src/sdk/protocol/operation-registry.ts +18 -4
  211. package/src/sdk/session-directory.ts +5 -1
  212. package/src/sdk/session.ts +31 -5
  213. package/src/sdk/startup-capability.ts +9 -1
  214. package/src/sdk/transport/auth-preface.ts +82 -0
  215. package/src/sdk/transport/index.ts +18 -0
  216. package/src/sdk/transport/relay.ts +252 -0
  217. package/src/sdk/transport/serve-cli.ts +105 -0
  218. package/src/sdk/transport/socket.ts +197 -0
  219. package/src/sdk/transport/stdio.ts +17 -0
  220. package/src/session/agent-session.ts +292 -154
  221. package/src/session/blob-store.ts +25 -7
  222. package/src/session/btw-contract.ts +59 -0
  223. package/src/session/internal/managed-session-scope.ts +1163 -137
  224. package/src/session/internal/managed-session-storage.ts +397 -234
  225. package/src/session/internal/native-publish-outcome.ts +266 -0
  226. package/src/session/session-manager.ts +620 -403
  227. package/src/session/session-storage.ts +128 -11
  228. package/src/setup/credential-import.ts +28 -3
  229. package/src/setup/provider-onboarding.ts +30 -1
  230. package/src/setup/provider-presets.json +2 -1
  231. package/src/slash-commands/builtin-registry.ts +1 -1
  232. package/src/ssh/utils.ts +14 -0
  233. package/src/task/discovery.ts +8 -2
  234. package/src/task/gjc-command.ts +2 -2
  235. package/src/task/index.ts +121 -15
  236. package/src/task/receipt.ts +3 -0
  237. package/src/task/types.ts +34 -3
  238. package/src/tools/ask.ts +199 -89
  239. package/src/tools/ast-edit.ts +2 -2
  240. package/src/tools/bash-pty-selection.ts +2 -2
  241. package/src/tools/browser/screenshot-format.ts +6 -1
  242. package/src/tools/cron.ts +1 -1
  243. package/src/tools/fetch.ts +13 -131
  244. package/src/tools/image-gen.ts +476 -23
  245. package/src/tools/index.ts +4 -0
  246. package/src/tools/read.ts +2 -0
  247. package/src/tools/render-utils.ts +7 -1
  248. package/src/tools/skill-discovery.ts +39 -3
  249. package/src/tools/sqlite-reader.ts +63 -5
  250. package/src/utils/edit-mode.ts +2 -2
  251. package/src/utils/pasted-image-path.ts +15 -6
  252. package/src/utils/prompt-suggestion.ts +265 -0
  253. package/src/utils/shell-snapshot.ts +173 -63
  254. package/src/utils/sixel.ts +3 -3
  255. package/src/web/insane/bridge.ts +5 -1
  256. package/src/web/insane/url-guard.ts +140 -27
  257. package/src/web/scrapers/docs-rs.ts +30 -5
  258. package/src/web/scrapers/types.ts +19 -49
  259. package/src/web/scrapers/utils.ts +12 -26
@@ -49,6 +49,49 @@ const USAGE = [
49
49
  "/session_recent [create|resume]",
50
50
  ].join("\n");
51
51
 
52
+ function tokenizeLifecycleCommand(value: string): string[] | undefined {
53
+ const tokens: string[] = [];
54
+ let token = "";
55
+ let quote: '"' | "'" | undefined;
56
+ let started = false;
57
+ for (let index = 0; index < value.length; index++) {
58
+ const character = value[index]!;
59
+ if (character === "\\") {
60
+ const next = value[index + 1];
61
+ if (next !== undefined && (next === "\\" || next === '"' || next === "'" || /\s/u.test(next))) {
62
+ token += next;
63
+ index++;
64
+ } else token += character;
65
+ started = true;
66
+ continue;
67
+ }
68
+ if (quote) {
69
+ if (character === quote) quote = undefined;
70
+ else token += character;
71
+ started = true;
72
+ continue;
73
+ }
74
+ if (character === '"' || character === "'") {
75
+ quote = character;
76
+ started = true;
77
+ continue;
78
+ }
79
+ if (/\s/u.test(character)) {
80
+ if (started) {
81
+ tokens.push(token);
82
+ token = "";
83
+ started = false;
84
+ }
85
+ continue;
86
+ }
87
+ token += character;
88
+ started = true;
89
+ }
90
+ if (quote) return undefined;
91
+ if (started) tokens.push(token);
92
+ return tokens;
93
+ }
94
+
52
95
  /** True when the text begins with any /session_* token, regardless of addressability. */
53
96
  export function isLifecycleCommandLikeText(text: string | undefined): boolean {
54
97
  if (!text) return false;
@@ -98,8 +141,10 @@ export function parseLifecycleCommand(
98
141
  text: string | undefined,
99
142
  ctx: { chatType?: string; botUsername?: string } = {},
100
143
  ): ParsedLifecycleCommand {
144
+ const tokens = tokenizeLifecycleCommand((text ?? "").trim());
145
+ if (!tokens) return { kind: "usage", message: USAGE };
146
+ const [rawCommand, ...args] = tokens;
101
147
  const raw = (text ?? "").trim();
102
- const [rawCommand, ...args] = raw.split(/\s+/);
103
148
  const command = normalizeLifecycleCommandToken(rawCommand ?? "", ctx);
104
149
  if (command === undefined) return { kind: "none" };
105
150
 
@@ -140,9 +185,8 @@ export function parseLifecycleCommand(
140
185
 
141
186
  // /session_create <kind> ... [--mpreset <profile>]
142
187
  const { positional, modelPreset } = extractModelPreset(args);
143
- if (modelPreset !== undefined && !isSafeIdentifier(modelPreset)) {
188
+ if (modelPreset !== undefined && modelPreset.length === 0)
144
189
  return { kind: "reject", reason: "invalid_target", message: `Invalid model preset name.\n\n${USAGE}` };
145
- }
146
190
  const kind = positional[0];
147
191
  if (kind === "path") {
148
192
  if (positional.length !== 2) return { kind: "usage", message: USAGE };
@@ -238,11 +282,12 @@ function isSafeBranch(value: string): boolean {
238
282
  /**
239
283
  * Map a lifecycle response/error to a user-facing Telegram message (G010).
240
284
  *
241
- * Only derives text from sessionId, mode, reason, a safe message, and candidate
242
- * {sessionId,path} — never a token or prompt. Each error reason gets tailored,
243
- * actionable copy; an "in progress" pending response is surfaced distinctly.
285
+ * Only derives text from sessionId, mode, reason, a safe message, the originating
286
+ * lifecycle verb, and candidate {sessionId,path} — never a token or prompt. Each
287
+ * error reason gets tailored, actionable copy; an "in progress" pending response
288
+ * is surfaced distinctly.
244
289
  */
245
- export function formatLifecycleOutcome(r: SessionLifecycleResponse): string {
290
+ export function formatLifecycleOutcome(r: SessionLifecycleResponse, verb?: LifecycleCommandVerb): string {
246
291
  switch (r.type) {
247
292
  case "session_create_response":
248
293
  return `\u{1f680} Launching session ${r.sessionId} in tmux. It will appear once ready \u2014 check /session_recent.`;
@@ -282,9 +327,17 @@ export function formatLifecycleOutcome(r: SessionLifecycleResponse): string {
282
327
  case "unsupported_platform":
283
328
  return "Remote session lifecycle is unavailable on this psmux host because GJC cannot prove immutable session identity. No lifecycle action was performed. Use a local GJC terminal with a supported tmux provider.";
284
329
  case "terminal_uncertain":
285
- return /in progress/i.test(r.message)
286
- ? "\u23f3 That request is already in progress \u2014 hold on."
287
- : "\u26a0\ufe0f Outcome uncertain. Check /session_recent before retrying so you don't double-spawn.";
330
+ if (/in progress/i.test(r.message)) return "\u23f3 That request is already in progress \u2014 hold on.";
331
+ switch (verb) {
332
+ case "session_create":
333
+ return "\u26a0\ufe0f Create outcome uncertain. The session may already be starting \u2014 check /session_recent before retrying to avoid starting it twice.";
334
+ case "session_close":
335
+ return "\u26a0\ufe0f Close outcome uncertain. The session may already be closed \u2014 check /session_recent before retrying.";
336
+ case "session_resume":
337
+ return "\u26a0\ufe0f Resume outcome uncertain. The session may already be reattached or restarting \u2014 check /session_recent before retrying.";
338
+ default:
339
+ return "\u26a0\ufe0f Outcome uncertain. Check /session_recent before retrying.";
340
+ }
288
341
  default:
289
342
  return `\u26a0\ufe0f ${r.reason}: ${r.message}`;
290
343
  }
@@ -1178,16 +1178,25 @@ export function outcomeToResponse(frame: SessionLifecycleRequest, outcome: Lifec
1178
1178
  if (frame.type === "session_close") {
1179
1179
  if (typeof e.sessionId !== "string" || typeof e.processGone !== "boolean")
1180
1180
  throw new Error("invalid close success ledger entry");
1181
+ if (!e.processGone) {
1182
+ return {
1183
+ type: "session_lifecycle_error",
1184
+ requestId: frame.requestId,
1185
+ status: "error",
1186
+ reason: "terminal_uncertain",
1187
+ message: "close did not confirm process termination",
1188
+ };
1189
+ }
1181
1190
  return {
1182
1191
  type: "session_close_response",
1183
1192
  requestId: frame.requestId,
1184
1193
  status: "ok",
1185
1194
  sessionId: e.sessionId,
1186
- processGone: e.processGone,
1195
+ processGone: true,
1187
1196
  historyPreserved: true,
1188
1197
  // The killed session's per-session endpoint record is reaped by the
1189
1198
  // daemon's dead-PID scan (scanRoots), so it is effectively stale.
1190
- endpointStale: e.processGone,
1199
+ endpointStale: true,
1191
1200
  };
1192
1201
  }
1193
1202
  if (
@@ -309,6 +309,21 @@ export async function handleLifecycleRequest(
309
309
 
310
310
  // 4. Durable idempotency.
311
311
  const doc = await deps.store.read();
312
+ const existing = doc.entries[key];
313
+ if (
314
+ frame.type === "session_close" &&
315
+ existing?.verb === "session_close" &&
316
+ existing.requestHash === hash &&
317
+ existing.state === "success" &&
318
+ typeof existing.sessionId === "string" &&
319
+ existing.processGone === false
320
+ ) {
321
+ existing.state = "terminal_uncertain";
322
+ existing.reason = "terminal_uncertain";
323
+ existing.updatedAt = deps.now();
324
+ delete existing.processGone;
325
+ await deps.store.write(doc);
326
+ }
312
327
  const dup = classifyDuplicate(doc.entries[key], hash);
313
328
  switch (dup.kind) {
314
329
  case "conflict":
@@ -394,12 +409,28 @@ export async function handleLifecycleRequest(
394
409
 
395
410
  if (frame.type === "session_close") {
396
411
  const closed = await deps.closeSession(frame.target);
412
+ if (!closed.processGone) {
413
+ Object.assign(entry, {
414
+ state: "terminal_uncertain",
415
+ updatedAt: deps.now(),
416
+ reason: "terminal_uncertain",
417
+ sessionId: frame.target.sessionId,
418
+ tmuxSession: frame.target.tmuxSession,
419
+ });
420
+ await deps.store.write(doc);
421
+ await deps.audit({ ...baseAudit, event: "terminal_uncertain", reason: "terminal_uncertain" });
422
+ return {
423
+ status: "error",
424
+ reason: "terminal_uncertain",
425
+ message: "session_close could not confirm process disappearance; manual check",
426
+ };
427
+ }
397
428
  Object.assign(entry, {
398
429
  state: "success",
399
430
  updatedAt: deps.now(),
400
431
  sessionId: frame.target.sessionId,
401
432
  tmuxSession: frame.target.tmuxSession,
402
- processGone: closed.processGone,
433
+ processGone: true,
403
434
  });
404
435
  await deps.store.write(doc);
405
436
  await deps.audit({ ...baseAudit, event: "success" });
@@ -1,5 +1,6 @@
1
1
  import type { CasRestoreResult } from "../../config/atomic-yaml-patch";
2
2
  import type { RawSettings, SettingsAtomicPatch, SettingsAtomicReceipt } from "../../config/settings";
3
+ import { isProcessIncarnation, processIncarnation } from "../broker/process-incarnation";
3
4
  import {
4
5
  getNotificationConfig,
5
6
  hasNonBlankValue,
@@ -53,6 +54,7 @@ export interface ProposedTelegramIdentityPreflightInput {
53
54
  deps?: {
54
55
  fs?: TelegramDaemonFs;
55
56
  pidAlive?: (pid: number) => boolean;
57
+ pidIncarnation?: (pid: number) => string | undefined;
56
58
  };
57
59
  }
58
60
 
@@ -109,6 +111,16 @@ export async function proposedTelegramIdentity(
109
111
 
110
112
  const pidAlive = input.deps?.pidAlive ?? defaultPidAlive;
111
113
  if (!pidAlive(metadata.pid)) return { status: "absent" };
114
+ const pidIncarnation = input.deps?.pidIncarnation ?? processIncarnation;
115
+ const persistedIncarnation = state.incarnation;
116
+ const currentIncarnation = pidIncarnation(metadata.pid);
117
+ if (
118
+ !isProcessIncarnation(persistedIncarnation) ||
119
+ !isProcessIncarnation(currentIncarnation) ||
120
+ persistedIncarnation !== currentIncarnation
121
+ ) {
122
+ return { status: "absent" };
123
+ }
112
124
 
113
125
  if (state.tokenFingerprint === tokenFingerprint(input.botToken) && state.chatId === input.chatId) {
114
126
  return {
@@ -251,19 +251,29 @@ export function formatNotificationStatusReport(report: NotificationStatusReport)
251
251
 
252
252
  // --- endpoint / daemon file readers -------------------------------------
253
253
 
254
- interface EndpointView {
254
+ export interface NotificationEndpointView {
255
255
  sessionId: string;
256
256
  pid: number | undefined;
257
257
  stale: boolean;
258
258
  }
259
259
 
260
- type EndpointFileRead =
261
- | { kind: "endpoint"; view: EndpointView; identity: NotificationEndpointFileIdentity }
260
+ export type NotificationEndpointLiveness = "live" | "dead" | "unknown";
261
+
262
+ /**
263
+ * Classification used by recovery and startup takeover. A file is an endpoint
264
+ * only when it has endpoint authority fields; lifecycle/audit records are never
265
+ * candidates for endpoint cleanup.
266
+ */
267
+ export type NotificationEndpointClassification =
268
+ | {
269
+ kind: "endpoint";
270
+ view: NotificationEndpointView;
271
+ liveness: NotificationEndpointLiveness;
272
+ identity: NotificationEndpointFileIdentity;
273
+ }
262
274
  | { kind: "non-endpoint" }
263
275
  | { kind: "unreadable" };
264
276
 
265
- type EndpointLiveness = "live" | "dead" | "unknown";
266
-
267
277
  /**
268
278
  * Classify an endpoint using owner-proof semantics. An endpoint is only `dead`
269
279
  * with positive proof: an explicit `stale` tombstone, or a recorded pid that is
@@ -271,7 +281,10 @@ type EndpointLiveness = "live" | "dead" | "unknown";
271
281
  * must never be treated as dead — removing it could delete a live session's
272
282
  * discovery file that simply omitted a pid.
273
283
  */
274
- function endpointLiveness(view: EndpointView, pidAlive: (pid: number) => boolean): EndpointLiveness {
284
+ export function notificationEndpointLiveness(
285
+ view: NotificationEndpointView,
286
+ pidAlive: (pid: number) => boolean,
287
+ ): NotificationEndpointLiveness {
275
288
  if (view.stale) return "dead";
276
289
  if (view.pid === undefined) return "unknown";
277
290
  return pidAlive(view.pid) ? "live" : "dead";
@@ -314,11 +327,19 @@ function isCanonicalLifecycleArtifactName(name: string): boolean {
314
327
  );
315
328
  }
316
329
 
317
- function unreadableEndpointResult(file: string): EndpointFileRead {
330
+ function unreadableEndpointResult(file: string): NotificationEndpointClassification {
318
331
  return isCanonicalLifecycleArtifactName(path.basename(file)) ? { kind: "non-endpoint" } : { kind: "unreadable" };
319
332
  }
320
333
 
321
- async function readEndpointView(fs: NotificationServiceFs, file: string): Promise<EndpointFileRead> {
334
+ /**
335
+ * Read and classify one endpoint candidate. The returned identity belongs to
336
+ * exactly the bytes inspected and is required for any later deletion.
337
+ */
338
+ export async function classifyNotificationEndpoint(
339
+ fs: Pick<NotificationServiceFs, "readEndpointFile">,
340
+ file: string,
341
+ pidAlive: (pid: number) => boolean,
342
+ ): Promise<NotificationEndpointClassification> {
322
343
  let endpoint: NotificationEndpointFile;
323
344
  let raw: string;
324
345
  try {
@@ -334,16 +355,19 @@ async function readEndpointView(fs: NotificationServiceFs, file: string): Promis
334
355
  return unreadableEndpointResult(file);
335
356
  }
336
357
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { kind: "non-endpoint" };
358
+ if (path.basename(file) === "broker.json") return { kind: "non-endpoint" };
337
359
  const rec = parsed as Record<string, unknown>;
338
360
  if (isLifecycleArtifact(rec) || typeof rec.url !== "string" || typeof rec.token !== "string")
339
361
  return { kind: "non-endpoint" };
362
+ const view: NotificationEndpointView = {
363
+ sessionId: typeof rec.sessionId === "string" ? rec.sessionId : path.basename(file, ".json"),
364
+ pid: safePositiveInteger(rec.pid),
365
+ stale: rec.stale === true,
366
+ };
340
367
  return {
341
368
  kind: "endpoint",
342
- view: {
343
- sessionId: typeof rec.sessionId === "string" ? rec.sessionId : path.basename(file, ".json"),
344
- pid: safePositiveInteger(rec.pid),
345
- stale: rec.stale === true,
346
- },
369
+ view,
370
+ liveness: notificationEndpointLiveness(view, pidAlive),
347
371
  identity: endpoint.identity,
348
372
  };
349
373
  }
@@ -604,14 +628,13 @@ export async function checkNotificationHealth(opts: HealthOptions): Promise<Noti
604
628
  let unknownEndpoints = 0;
605
629
  let unreadable = 0;
606
630
  for (const name of files) {
607
- const record = await readEndpointView(fs, path.join(dir, name));
631
+ const record = await classifyNotificationEndpoint(fs, path.join(dir, name), pidAlive);
608
632
  if (record.kind === "non-endpoint") continue;
609
633
  if (record.kind === "unreadable") {
610
634
  unreadable += 1;
611
635
  continue;
612
636
  }
613
- const view = record.view;
614
- switch (endpointLiveness(view, pidAlive)) {
637
+ switch (record.liveness) {
615
638
  case "live":
616
639
  live += 1;
617
640
  break;
@@ -877,7 +900,16 @@ async function detachTransitionMarker(
877
900
  ): Promise<boolean> {
878
901
  if (!snapshot.identity || !fs.exactUnlink) return false;
879
902
  try {
880
- return (await fs.exactUnlink(path, snapshot.identity)).ok;
903
+ const removed = await fs.exactUnlink(path, snapshot.identity);
904
+ if (removed.ok) return true;
905
+ // Accept only typed retained authority: a concrete detached quarantine plus
906
+ // a proven-absent canonical marker pathname. Anything else stays fail-closed.
907
+ return (
908
+ removed.code === "cleanup_pending" &&
909
+ typeof removed.detachedPath === "string" &&
910
+ removed.detachedPath.length > 0 &&
911
+ (await fs.readFile(path, "utf8").catch(() => undefined)) === undefined
912
+ );
881
913
  } catch {
882
914
  return false;
883
915
  }
@@ -1040,7 +1072,7 @@ export async function recoverNotifications(opts: RecoveryOptions): Promise<Notif
1040
1072
  let unreadable = 0;
1041
1073
  for (const name of files) {
1042
1074
  const file = path.join(dir, name);
1043
- const record = await readEndpointView(fs, file);
1075
+ const record = await classifyNotificationEndpoint(fs, file, pidAlive);
1044
1076
  if (record.kind === "non-endpoint") continue;
1045
1077
  if (record.kind === "unreadable") {
1046
1078
  // Leave unparseable files untouched: they may be mid-write by a live server.
@@ -1048,7 +1080,7 @@ export async function recoverNotifications(opts: RecoveryOptions): Promise<Notif
1048
1080
  continue;
1049
1081
  }
1050
1082
  const view = record.view;
1051
- if (endpointLiveness(view, pidAlive) !== "dead") {
1083
+ if (record.liveness !== "dead") {
1052
1084
  // Keep live AND unknown (PID-less) endpoints: only positive proof of
1053
1085
  // death (a stale tombstone or a dead pid) authorizes removal.
1054
1086
  kept += 1;
@@ -1057,11 +1089,27 @@ export async function recoverNotifications(opts: RecoveryOptions): Promise<Notif
1057
1089
  try {
1058
1090
  const result = await fs.exactUnlink(file, record.identity);
1059
1091
  if (!result.ok) {
1060
- recoveryFailures += 1;
1061
1092
  if (result.detachedPath) detached.push(result.detachedPath);
1062
1093
  if (result.retainedSuccessorPath) retainedSuccessors.push(result.retainedSuccessorPath);
1063
1094
  if (result.retainedPlaceholderPath) retainedPlaceholders.push(result.retainedPlaceholderPath);
1064
1095
  if (result.retainedUnknownPath) retainedUnknown.push(result.retainedUnknownPath);
1096
+ // Typed retained authority with a concrete quarantine and a proven-absent
1097
+ // canonical endpoint counts as removed; the detached path above is the
1098
+ // durable evidence. Anything else is a genuine recovery failure.
1099
+ const retainedRemoved =
1100
+ result.code === "cleanup_pending" &&
1101
+ typeof result.detachedPath === "string" &&
1102
+ result.detachedPath.length > 0 &&
1103
+ (await fs.readFile(file, "utf8").catch(() => undefined)) === undefined;
1104
+ if (retainedRemoved) {
1105
+ removed.push({
1106
+ sessionId: view.sessionId,
1107
+ pid: view.pid,
1108
+ reason: view.stale ? "stale-flag" : "dead-pid",
1109
+ });
1110
+ continue;
1111
+ }
1112
+ recoveryFailures += 1;
1065
1113
  if (
1066
1114
  !result.detachedPath &&
1067
1115
  !result.retainedSuccessorPath &&
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Bounded authoritative reconciliation state for Q26 `turn.prompt_status`.
3
+ *
4
+ * Separate from the lifecycle delivery buffers (promptSubmissions /
5
+ * promptTerminalTombstones), which exist to deliver frames and intentionally
6
+ * forget outcomes. This record preserves accepted/in_flight/terminal_ok/failed
7
+ * plus bounded sanitized failure metadata so a caller can reconcile a prior
8
+ * `turn.prompt` after disconnect/reconnect.
9
+ *
10
+ * Semantics (public contract, mirrored in sdk/prompt-status.ts):
11
+ * - Active records are NEVER converted to terminal by age or capacity.
12
+ * - Terminal records are retained for TERMINAL_TTL_MS and bounded to
13
+ * TERMINAL_CAPACITY, evicted oldest-terminal-first by terminalAt; only then
14
+ * does a lookup honestly report `unknown`.
15
+ * - The durability floor is the live session process: restart means `unknown`.
16
+ * - The clientRef index is session-runtime scoped; a ref conflicts only while
17
+ * retained and must never be reused as a retry mechanism.
18
+ * - Terminal transitions settle once: the first terminal outcome wins.
19
+ */
20
+
21
+ export const PROMPT_RECONCILIATION_ACTIVE_CAPACITY = 128;
22
+ export const PROMPT_RECONCILIATION_TERMINAL_CAPACITY = 256;
23
+ export const PROMPT_RECONCILIATION_TERMINAL_TTL_MS = 15 * 60_000;
24
+ export const PROMPT_FAILURE_CODE_MAX = 64;
25
+
26
+ export type PromptReconciliationStatus = "accepted" | "in_flight" | "terminal_ok" | "failed";
27
+
28
+ export interface PromptCorrelation {
29
+ commandId: string;
30
+ turnId: string;
31
+ }
32
+
33
+ export interface PromptReconciliationRecord extends PromptCorrelation {
34
+ clientRef?: string;
35
+ status: PromptReconciliationStatus;
36
+ error?: { code: string; message: string };
37
+ acceptedAt: number;
38
+ startedAt?: number;
39
+ terminalAt?: number;
40
+ }
41
+
42
+ export type TurnPromptReconciliation =
43
+ | {
44
+ status: "accepted";
45
+ commandId: string;
46
+ turnId: string;
47
+ clientRef?: string;
48
+ acceptedAt: number;
49
+ }
50
+ | {
51
+ status: "in_flight";
52
+ commandId: string;
53
+ turnId: string;
54
+ clientRef?: string;
55
+ acceptedAt: number;
56
+ startedAt: number;
57
+ }
58
+ | {
59
+ status: "terminal_ok";
60
+ commandId: string;
61
+ turnId: string;
62
+ clientRef?: string;
63
+ acceptedAt: number;
64
+ startedAt?: number;
65
+ terminalAt: number;
66
+ }
67
+ | {
68
+ status: "failed";
69
+ commandId: string;
70
+ turnId: string;
71
+ clientRef?: string;
72
+ acceptedAt: number;
73
+ startedAt?: number;
74
+ terminalAt: number;
75
+ error: { code: string; message: string };
76
+ }
77
+ | { status: "unknown" };
78
+
79
+ export interface PromptReconciliation {
80
+ /** Fail-closed admission BEFORE any execution; holds an identity-bound reservation. */
81
+ admit(clientRef?: string): void;
82
+ /** Discard one admission reservation without creating a record (rejection/cancellation). */
83
+ releaseAdmission(clientRef?: string): void;
84
+ /** Transition a reservation into the accepted record at preflight acceptance. */
85
+ noteAccepted(correlation: PromptCorrelation, clientRef?: string): void;
86
+ /** Lifecycle transition; terminal outcomes settle exactly once. */
87
+ noteTransition(
88
+ correlation: PromptCorrelation | undefined,
89
+ frame: { type: "agent_start" | "agent_end" } | { type: "agent_failed"; error: unknown },
90
+ ): void;
91
+ lookup(selector: { commandId?: string; turnId?: string; clientRef?: string }): TurnPromptReconciliation;
92
+ cleanup(): void;
93
+ activeCount(): number;
94
+ }
95
+
96
+ /** Safe-token code capped at 64; arbitrary failure text is never retained. */
97
+ export function sanitizePromptFailure(error: unknown): { code: string; message: string } {
98
+ const candidate = error as { code?: unknown } | undefined;
99
+ const rawCode = typeof candidate?.code === "string" ? candidate.code : "";
100
+ const code = rawCode.length <= PROMPT_FAILURE_CODE_MAX && /^[A-Za-z0-9._-]+$/.test(rawCode) ? rawCode : "internal";
101
+ return { code, message: "Prompt submission failed." };
102
+ }
103
+
104
+ export function createPromptReconciliation(options: { now?: () => number } = {}): PromptReconciliation {
105
+ const now = options.now ?? Date.now;
106
+ const records = new Map<string, PromptReconciliationRecord>();
107
+ const clientRefIndex = new Map<string, string>();
108
+ const reservedClientRefs = new Set<string>();
109
+ // One identity-bound entry per admission. Transitions consume exactly one
110
+ // matching entry and stale/duplicate transitions are no-ops, so reservation
111
+ // accounting is exactly-once even under repeated calls.
112
+ const reservations: Array<string | undefined> = [];
113
+ const keyOf = (correlation: PromptCorrelation) => `${correlation.commandId}:${correlation.turnId}`;
114
+
115
+ const remove = (key: string) => {
116
+ const record = records.get(key);
117
+ if (!record) return;
118
+ records.delete(key);
119
+ if (record.clientRef !== undefined && clientRefIndex.get(record.clientRef) === key)
120
+ clientRefIndex.delete(record.clientRef);
121
+ };
122
+
123
+ const cleanup = () => {
124
+ const at = now();
125
+ for (const [key, record] of records)
126
+ if (record.terminalAt !== undefined && record.terminalAt + PROMPT_RECONCILIATION_TERMINAL_TTL_MS <= at)
127
+ remove(key);
128
+ const terminalEntries = [...records.entries()].filter(([, record]) => record.terminalAt !== undefined);
129
+ if (terminalEntries.length <= PROMPT_RECONCILIATION_TERMINAL_CAPACITY) return;
130
+ // Evict oldest-terminal-first by terminalAt, not acceptance/insertion
131
+ // order; the stable sort keeps acceptance order for exact ties.
132
+ terminalEntries.sort((a, b) => (a[1].terminalAt as number) - (b[1].terminalAt as number));
133
+ for (const [key] of terminalEntries.slice(0, terminalEntries.length - PROMPT_RECONCILIATION_TERMINAL_CAPACITY))
134
+ remove(key);
135
+ };
136
+
137
+ const activeCount = () => {
138
+ let count = 0;
139
+ for (const record of records.values()) if (record.terminalAt === undefined) count++;
140
+ return count;
141
+ };
142
+
143
+ /** Consume one reservation matching clientRef; no-op when none is outstanding. */
144
+ const consumeReservation = (clientRef?: string) => {
145
+ const index = reservations.indexOf(clientRef);
146
+ if (index === -1) return;
147
+ reservations.splice(index, 1);
148
+ if (clientRef !== undefined && !reservations.includes(clientRef)) reservedClientRefs.delete(clientRef);
149
+ };
150
+
151
+ const admit = (clientRef?: string) => {
152
+ cleanup();
153
+ if (clientRef !== undefined && (clientRefIndex.has(clientRef) || reservedClientRefs.has(clientRef)))
154
+ throw Object.assign(
155
+ new Error("A prompt with this clientRef is already retained; never reuse a clientRef for retry."),
156
+ { code: "client_ref_conflict" },
157
+ );
158
+ if (activeCount() + reservations.length >= PROMPT_RECONCILIATION_ACTIVE_CAPACITY)
159
+ throw Object.assign(new Error("Too many active prompt submissions; reconcile or await terminal state."), {
160
+ code: "reconciliation_capacity",
161
+ });
162
+ reservations.push(clientRef);
163
+ if (clientRef !== undefined) reservedClientRefs.add(clientRef);
164
+ };
165
+
166
+ const releaseAdmission = (clientRef?: string) => {
167
+ consumeReservation(clientRef);
168
+ };
169
+
170
+ const noteAccepted = (correlation: PromptCorrelation, clientRef?: string) => {
171
+ cleanup();
172
+ consumeReservation(clientRef);
173
+ const at = now();
174
+ const key = keyOf(correlation);
175
+ records.set(key, {
176
+ commandId: correlation.commandId,
177
+ turnId: correlation.turnId,
178
+ ...(clientRef !== undefined ? { clientRef } : {}),
179
+ status: "accepted",
180
+ acceptedAt: at,
181
+ });
182
+ if (clientRef !== undefined) clientRefIndex.set(clientRef, key);
183
+ };
184
+
185
+ const noteTransition = (
186
+ correlation: PromptCorrelation | undefined,
187
+ frame: { type: "agent_start" | "agent_end" } | { type: "agent_failed"; error: unknown },
188
+ ) => {
189
+ if (!correlation) return;
190
+ const record = records.get(keyOf(correlation));
191
+ if (!record || record.terminalAt !== undefined) return;
192
+ if (frame.type === "agent_start") {
193
+ if (record.status === "accepted") {
194
+ record.status = "in_flight";
195
+ record.startedAt = now();
196
+ }
197
+ return;
198
+ }
199
+ record.terminalAt = now();
200
+ if (frame.type === "agent_failed") {
201
+ record.status = "failed";
202
+ record.error = sanitizePromptFailure(frame.error);
203
+ } else {
204
+ record.status = "terminal_ok";
205
+ }
206
+ // Enforce terminal retention immediately at settlement, not lazily.
207
+ cleanup();
208
+ };
209
+
210
+ const lookup = (selector: { commandId?: string; turnId?: string; clientRef?: string }): TurnPromptReconciliation => {
211
+ cleanup();
212
+ const key =
213
+ selector.clientRef !== undefined
214
+ ? clientRefIndex.get(selector.clientRef)
215
+ : selector.commandId !== undefined && selector.turnId !== undefined
216
+ ? keyOf({ commandId: selector.commandId, turnId: selector.turnId })
217
+ : undefined;
218
+ const record = key === undefined ? undefined : records.get(key);
219
+ if (!record) return { status: "unknown" };
220
+ const identity = {
221
+ commandId: record.commandId,
222
+ turnId: record.turnId,
223
+ ...(record.clientRef !== undefined ? { clientRef: record.clientRef } : {}),
224
+ acceptedAt: record.acceptedAt,
225
+ };
226
+ if (record.status === "accepted") return { status: "accepted", ...identity };
227
+ if (record.status === "in_flight")
228
+ return { status: "in_flight", ...identity, startedAt: record.startedAt as number };
229
+ const terminal = {
230
+ ...identity,
231
+ ...(record.startedAt !== undefined ? { startedAt: record.startedAt } : {}),
232
+ terminalAt: record.terminalAt as number,
233
+ };
234
+ if (record.status === "terminal_ok") return { status: "terminal_ok", ...terminal };
235
+ return { status: "failed", ...terminal, error: record.error ?? sanitizePromptFailure(undefined) };
236
+ };
237
+
238
+ return { admit, releaseAdmission, noteAccepted, noteTransition, lookup, cleanup, activeCount };
239
+ }