@gajae-code/coding-agent 0.11.7 → 0.11.9

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 (213) hide show
  1. package/CHANGELOG.md +73 -1
  2. package/dist/types/cli/args.d.ts +0 -2
  3. package/dist/types/cli.d.ts +1 -0
  4. package/dist/types/config/keybindings.d.ts +33 -3
  5. package/dist/types/config/model-profile-activation.d.ts +4 -3
  6. package/dist/types/config/model-profile-contract.d.ts +38 -0
  7. package/dist/types/config/model-profiles.d.ts +2 -3
  8. package/dist/types/config/settings-schema.d.ts +15 -2
  9. package/dist/types/edit/modes/patch.d.ts +13 -0
  10. package/dist/types/edit/path-mutation-lock.d.ts +15 -0
  11. package/dist/types/eval/py/executor.d.ts +1 -1
  12. package/dist/types/eval/py/tool-bridge.d.ts +1 -2
  13. package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +12 -5
  14. package/dist/types/gjc-runtime/deep-interview-draft.d.ts +8 -0
  15. package/dist/types/gjc-runtime/deep-interview-payload.d.ts +41 -0
  16. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +32 -17
  17. package/dist/types/gjc-runtime/deep-interview-repair.d.ts +17 -0
  18. package/dist/types/gjc-runtime/deep-interview-state.d.ts +135 -4
  19. package/dist/types/gjc-runtime/repository-binding.d.ts +63 -0
  20. package/dist/types/gjc-runtime/state-writer.d.ts +44 -0
  21. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +1 -0
  22. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +24 -0
  23. package/dist/types/gjc-runtime/workflow-manifest.d.ts +5 -3
  24. package/dist/types/main.d.ts +8 -0
  25. package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +2 -1
  26. package/dist/types/modes/components/tool-status-header.d.ts +3 -2
  27. package/dist/types/modes/components/welcome.d.ts +4 -0
  28. package/dist/types/modes/controllers/command-controller.d.ts +2 -0
  29. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  30. package/dist/types/modes/interactive-mode.d.ts +5 -2
  31. package/dist/types/modes/prompt-action-autocomplete.d.ts +6 -1
  32. package/dist/types/modes/prompt-suggestion-controller.d.ts +35 -0
  33. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -2
  34. package/dist/types/modes/types.d.ts +3 -0
  35. package/dist/types/modes/utils/hotkeys-markdown.d.ts +2 -1
  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/runtime-mcp/redaction.d.ts +2 -0
  40. package/dist/types/sdk/broker/broker.d.ts +2 -0
  41. package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
  42. package/dist/types/sdk/bus/chat-daemon-control.d.ts +7 -1
  43. package/dist/types/sdk/bus/config-commands.d.ts +2 -2
  44. package/dist/types/sdk/bus/config.d.ts +2 -0
  45. package/dist/types/sdk/bus/conversation-store.d.ts +1 -0
  46. package/dist/types/sdk/bus/index.d.ts +25 -0
  47. package/dist/types/sdk/bus/notification-orchestration.d.ts +1 -0
  48. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +105 -0
  49. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +15 -3
  50. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -1
  51. package/dist/types/sdk/bus/telegram-daemon.d.ts +128 -1
  52. package/dist/types/sdk/bus/telegram-reference.d.ts +2 -0
  53. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  54. package/dist/types/sdk/host/host.d.ts +1 -1
  55. package/dist/types/sdk/host/query/handlers.d.ts +9 -0
  56. package/dist/types/sdk/index.d.ts +2 -0
  57. package/dist/types/sdk/prompt-status.d.ts +88 -0
  58. package/dist/types/sdk/session-directory.d.ts +1 -1
  59. package/dist/types/sdk/startup-capability.d.ts +3 -0
  60. package/dist/types/session/agent-session.d.ts +2 -0
  61. package/dist/types/session/internal/managed-session-scope.d.ts +22 -3
  62. package/dist/types/session/internal/managed-session-storage.d.ts +24 -4
  63. package/dist/types/session/internal/native-publish-outcome.d.ts +48 -0
  64. package/dist/types/session/session-manager.d.ts +5 -1
  65. package/dist/types/session/session-storage.d.ts +31 -0
  66. package/dist/types/skill-state/active-state.d.ts +4 -0
  67. package/dist/types/task/index.d.ts +3 -1
  68. package/dist/types/task/receipt.d.ts +2 -0
  69. package/dist/types/task/types.d.ts +94 -0
  70. package/dist/types/tools/ask.d.ts +454 -174
  71. package/dist/types/tools/image-gen.d.ts +47 -1
  72. package/dist/types/tools/index.d.ts +4 -0
  73. package/dist/types/utils/prompt-suggestion.d.ts +33 -0
  74. package/package.json +8 -8
  75. package/scripts/capture-platform-shortcut-labels-showcase.ts +342 -0
  76. package/scripts/dogfood-repository-binding.ts +209 -0
  77. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  78. package/scripts/run-test-manifest.ts +4 -4
  79. package/src/cli/args.ts +2 -92
  80. package/src/cli/mcp-cli.ts +4 -21
  81. package/src/cli/notify-cli.ts +5 -38
  82. package/src/cli.ts +50 -0
  83. package/src/commands/deep-interview.ts +9 -4
  84. package/src/commands/sdk.ts +2 -2
  85. package/src/commit/agentic/index.ts +3 -3
  86. package/src/commit/map-reduce/index.ts +2 -2
  87. package/src/config/keybindings.ts +157 -24
  88. package/src/config/model-profile-activation.ts +7 -18
  89. package/src/config/model-profile-contract.ts +161 -0
  90. package/src/config/model-profiles.ts +2 -3
  91. package/src/config/model-registry.ts +11 -1
  92. package/src/config/settings-schema.ts +17 -2
  93. package/src/coordinator-mcp/model-preset.ts +21 -74
  94. package/src/defaults/gjc/skills/deep-interview/SKILL.md +80 -165
  95. package/src/defaults/gjc/skills/ralplan/SKILL.md +3 -1
  96. package/src/edit/index.ts +3 -6
  97. package/src/edit/modes/patch.ts +38 -1
  98. package/src/edit/modes/replace.ts +42 -0
  99. package/src/edit/path-mutation-lock.ts +112 -0
  100. package/src/eval/py/executor.ts +30 -11
  101. package/src/eval/py/prelude.py +2 -2
  102. package/src/eval/py/tool-bridge.ts +34 -18
  103. package/src/extensibility/gjc-plugins/runtime-adapters.ts +2 -1
  104. package/src/gjc-runtime/deep-interview-ambiguity.ts +68 -47
  105. package/src/gjc-runtime/deep-interview-draft.ts +842 -0
  106. package/src/gjc-runtime/deep-interview-payload.ts +262 -0
  107. package/src/gjc-runtime/deep-interview-recorder.ts +294 -226
  108. package/src/gjc-runtime/deep-interview-repair.ts +1674 -0
  109. package/src/gjc-runtime/deep-interview-runtime.ts +264 -103
  110. package/src/gjc-runtime/deep-interview-state.ts +916 -4
  111. package/src/gjc-runtime/launch-tmux.ts +1 -1
  112. package/src/gjc-runtime/managed-owner-supervisor.ts +83 -4
  113. package/src/gjc-runtime/ralplan-runtime.ts +78 -4
  114. package/src/gjc-runtime/repository-binding.ts +267 -0
  115. package/src/gjc-runtime/session-state-sidecar.ts +25 -9
  116. package/src/gjc-runtime/state-writer.ts +166 -2
  117. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -1
  118. package/src/gjc-runtime/tmux-sessions.ts +45 -6
  119. package/src/gjc-runtime/ultragoal-guard.ts +5 -5
  120. package/src/gjc-runtime/ultragoal-runtime.ts +181 -19
  121. package/src/gjc-runtime/workflow-manifest.generated.json +386 -1
  122. package/src/gjc-runtime/workflow-manifest.ts +191 -5
  123. package/src/internal-urls/docs-index.generated.ts +8 -6
  124. package/src/internal-urls/local-protocol.ts +33 -16
  125. package/src/internal-urls/mcp-protocol.ts +73 -11
  126. package/src/main.ts +25 -10
  127. package/src/modes/DESIGN.md +5 -0
  128. package/src/modes/acp/acp-agent.ts +50 -16
  129. package/src/modes/components/model-selector.ts +42 -8
  130. package/src/modes/components/plan-preview-overlay.ts +1 -1
  131. package/src/modes/components/runtime-mcp-add-wizard.ts +5 -0
  132. package/src/modes/components/tool-status-header.ts +20 -4
  133. package/src/modes/components/welcome.ts +32 -20
  134. package/src/modes/controllers/command-controller.ts +97 -54
  135. package/src/modes/controllers/event-controller.ts +10 -0
  136. package/src/modes/controllers/input-controller.ts +7 -2
  137. package/src/modes/controllers/runtime-mcp-command-controller.ts +29 -6
  138. package/src/modes/controllers/selector-controller.ts +46 -54
  139. package/src/modes/interactive-mode.ts +70 -36
  140. package/src/modes/prompt-action-autocomplete.ts +32 -13
  141. package/src/modes/prompt-suggestion-controller.ts +96 -0
  142. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -2
  143. package/src/modes/types.ts +3 -0
  144. package/src/modes/utils/hotkeys-markdown.ts +64 -36
  145. package/src/modes/utils/ui-helpers.ts +5 -3
  146. package/src/prompts/agents/architect.md +0 -1
  147. package/src/prompts/agents/critic.md +0 -1
  148. package/src/prompts/system/prompt-suggestion-system.md +32 -0
  149. package/src/prompts/system/system-prompt.md +7 -4
  150. package/src/prompts/tools/cron.md +3 -1
  151. package/src/runtime-mcp/json-rpc.ts +7 -4
  152. package/src/runtime-mcp/manager.ts +51 -53
  153. package/src/runtime-mcp/oauth-flow.ts +0 -72
  154. package/src/runtime-mcp/plugin-network-boundary.ts +101 -0
  155. package/src/runtime-mcp/redaction.ts +30 -0
  156. package/src/runtime-mcp/transports/http.ts +13 -6
  157. package/src/sdk/broker/broker.ts +34 -20
  158. package/src/sdk/broker/lifecycle.ts +185 -36
  159. package/src/sdk/broker/session-index.ts +11 -1
  160. package/src/sdk/bus/chat-daemon-control.ts +20 -5
  161. package/src/sdk/bus/config-commands.ts +2 -2
  162. package/src/sdk/bus/config.ts +9 -1
  163. package/src/sdk/bus/conversation-store.ts +40 -6
  164. package/src/sdk/bus/index.ts +240 -65
  165. package/src/sdk/bus/lifecycle-commands.ts +47 -3
  166. package/src/sdk/bus/lifecycle-control-runtime.ts +11 -2
  167. package/src/sdk/bus/lifecycle-orchestrator.ts +32 -1
  168. package/src/sdk/bus/notification-orchestration.ts +12 -0
  169. package/src/sdk/bus/notification-service.ts +27 -2
  170. package/src/sdk/bus/prompt-reconciliation.ts +239 -0
  171. package/src/sdk/bus/recent-activity.ts +90 -26
  172. package/src/sdk/bus/telegram-daemon-contract.ts +15 -3
  173. package/src/sdk/bus/telegram-daemon-control.ts +145 -33
  174. package/src/sdk/bus/telegram-daemon.ts +853 -112
  175. package/src/sdk/bus/telegram-reference.ts +20 -2
  176. package/src/sdk/cli/session-cli.ts +15 -2
  177. package/src/sdk/host/control/dispatch.ts +1 -1
  178. package/src/sdk/host/control/operations.ts +1 -1
  179. package/src/sdk/host/host.ts +2 -6
  180. package/src/sdk/host/query/handlers.ts +70 -5
  181. package/src/sdk/index.ts +12 -0
  182. package/src/sdk/prompt-status.ts +97 -0
  183. package/src/sdk/protocol/operation-inventory.generated.json +47 -0
  184. package/src/sdk/protocol/operation-registry.ts +18 -4
  185. package/src/sdk/session-directory.ts +5 -1
  186. package/src/sdk/session.ts +2 -0
  187. package/src/sdk/startup-capability.ts +9 -1
  188. package/src/session/agent-session.ts +138 -5
  189. package/src/session/blob-store.ts +7 -2
  190. package/src/session/internal/managed-session-scope.ts +998 -133
  191. package/src/session/internal/managed-session-storage.ts +268 -203
  192. package/src/session/internal/native-publish-outcome.ts +266 -0
  193. package/src/session/session-manager.ts +406 -360
  194. package/src/session/session-storage.ts +151 -11
  195. package/src/skill-state/active-state.ts +10 -0
  196. package/src/task/executor.ts +13 -5
  197. package/src/task/gjc-command.ts +2 -2
  198. package/src/task/index.ts +121 -15
  199. package/src/task/receipt.ts +3 -0
  200. package/src/task/types.ts +34 -3
  201. package/src/tools/ask.ts +199 -88
  202. package/src/tools/ast-edit.ts +2 -2
  203. package/src/tools/bash-pty-selection.ts +2 -2
  204. package/src/tools/browser/screenshot-format.ts +6 -1
  205. package/src/tools/cron.ts +1 -1
  206. package/src/tools/image-gen.ts +178 -6
  207. package/src/tools/index.ts +4 -0
  208. package/src/tools/render-utils.ts +7 -1
  209. package/src/utils/edit-mode.ts +2 -2
  210. package/src/utils/prompt-suggestion.ts +265 -0
  211. package/src/utils/sixel.ts +3 -3
  212. package/src/web/insane/url-guard.ts +3 -1
  213. package/src/web/scrapers/docs-rs.ts +30 -5
@@ -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 };
@@ -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 {
@@ -900,7 +900,16 @@ async function detachTransitionMarker(
900
900
  ): Promise<boolean> {
901
901
  if (!snapshot.identity || !fs.exactUnlink) return false;
902
902
  try {
903
- 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
+ );
904
913
  } catch {
905
914
  return false;
906
915
  }
@@ -1080,11 +1089,27 @@ export async function recoverNotifications(opts: RecoveryOptions): Promise<Notif
1080
1089
  try {
1081
1090
  const result = await fs.exactUnlink(file, record.identity);
1082
1091
  if (!result.ok) {
1083
- recoveryFailures += 1;
1084
1092
  if (result.detachedPath) detached.push(result.detachedPath);
1085
1093
  if (result.retainedSuccessorPath) retainedSuccessors.push(result.retainedSuccessorPath);
1086
1094
  if (result.retainedPlaceholderPath) retainedPlaceholders.push(result.retainedPlaceholderPath);
1087
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;
1088
1113
  if (
1089
1114
  !result.detachedPath &&
1090
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
+ }
@@ -11,7 +11,7 @@ import * as fs from "node:fs/promises";
11
11
  import * as path from "node:path";
12
12
  import { verifyOwnerOnlyPathSecurity } from "@gajae-code/natives";
13
13
  import { getAgentDir, getSessionsDir } from "@gajae-code/utils";
14
- import { FileSessionStorage } from "../../session/session-storage";
14
+ import { FileSessionStorage, type SessionStorageSnapshot } from "../../session/session-storage";
15
15
  import {
16
16
  type LogicalSessionCandidate,
17
17
  listManagedSessionCandidates,
@@ -58,25 +58,52 @@ export interface RecentActivityDeps {
58
58
  readInitialLines?: (file: string, maxLines: number) => string[];
59
59
  }
60
60
 
61
+ class ManagedCandidateChangedError extends Error {}
62
+ class ManagedCandidateUnavailableError extends Error {}
63
+
64
+ const CHANGED_CANDIDATE_WARNING = "Ignored managed session candidate that changed during inspection.";
65
+
66
+ function isAppendExtension(bytes: Uint8Array, candidate: LogicalSessionCandidate): boolean {
67
+ return (
68
+ bytes.byteLength >= candidate.identity.size &&
69
+ createHash("sha256").update(bytes.subarray(0, candidate.identity.size)).digest("hex") ===
70
+ candidate.identity.sha256
71
+ );
72
+ }
73
+
61
74
  function readCandidateInitialLines(
62
75
  candidate: LogicalSessionCandidate,
63
76
  readInitialLines: ((file: string, maxLines: number) => string[]) | undefined,
77
+ appendBase?: LogicalSessionCandidate,
64
78
  ): string[] {
65
79
  if (readInitialLines) return readInitialLines(candidate.path, 8);
66
80
  if (candidate.provenance !== "legacy") {
67
81
  const security = verifyOwnerOnlyPathSecurity(candidate.path, "file");
68
- if (!security.ok) throw new Error(`Managed session metadata path is unsafe: ${security.code}`);
82
+ if (!security.ok) throw new ManagedCandidateUnavailableError("Managed session metadata path is unsafe.");
69
83
  }
70
- const snapshot = new FileSessionStorage().readSnapshotSync(candidate.path);
84
+ let snapshot: SessionStorageSnapshot;
85
+ try {
86
+ snapshot = new FileSessionStorage().readSnapshotSync(candidate.path);
87
+ } catch (error) {
88
+ const code = (error as NodeJS.ErrnoException).code;
89
+ if (code === "ENOENT" || code === "ELOOP" || code === "ENOTDIR")
90
+ throw new ManagedCandidateUnavailableError("Managed session metadata is unavailable.");
91
+ throw error;
92
+ }
93
+ const sameFile = snapshot.stat.dev === candidate.identity.dev && snapshot.stat.ino === candidate.identity.ino;
71
94
  const digest = createHash("sha256").update(snapshot.bytes).digest("hex");
72
- if (
73
- snapshot.stat.dev !== candidate.identity.dev ||
74
- snapshot.stat.ino !== candidate.identity.ino ||
75
- snapshot.stat.size !== candidate.identity.size ||
76
- snapshot.stat.mtimeNs !== candidate.identity.mtimeNs ||
77
- digest !== candidate.identity.sha256
78
- )
79
- throw new Error("Managed session changed after ownership was verified.");
95
+ const exactIdentity =
96
+ sameFile &&
97
+ snapshot.stat.size === candidate.identity.size &&
98
+ snapshot.stat.mtimeNs === candidate.identity.mtimeNs &&
99
+ digest === candidate.identity.sha256;
100
+ if (appendBase && (!sameFile || !isAppendExtension(snapshot.bytes, appendBase)))
101
+ throw new ManagedCandidateUnavailableError("Managed session no longer extends the verified transcript.");
102
+ if (!exactIdentity) {
103
+ if (sameFile && isAppendExtension(snapshot.bytes, candidate))
104
+ throw new ManagedCandidateChangedError("Managed session was appended after ownership was verified.");
105
+ throw new ManagedCandidateUnavailableError("Managed session changed without an append-only extension.");
106
+ }
80
107
  return Buffer.from(snapshot.bytes).toString("utf8").split("\n").slice(0, 8);
81
108
  }
82
109
 
@@ -227,13 +254,13 @@ export async function listRecentSessions(deps: RecentActivityDeps): Promise<List
227
254
  const resolved = await resolveRecentScopes(deps);
228
255
  if (resolved.kind === "error") return resolved;
229
256
  const warnings = [...resolved.warnings];
230
- const candidates: LogicalSessionCandidate[] = [];
257
+ const candidates: Array<{ scope: ManagedSessionScope; candidate: LogicalSessionCandidate }> = [];
231
258
  for (const scope of resolved.scopes) {
232
259
  const listed = await listManagedSessionCandidates({ scope });
233
260
  if (listed.kind !== "complete") {
234
261
  return { kind: "error", code: "managed_scan_failed", message: listed.message };
235
262
  }
236
- candidates.push(...listed.owned);
263
+ candidates.push(...listed.owned.map(candidate => ({ scope, candidate })));
237
264
  warnings.push(
238
265
  ...listed.invalid
239
266
  .filter(
@@ -245,28 +272,65 @@ export async function listRecentSessions(deps: RecentActivityDeps): Promise<List
245
272
  }
246
273
 
247
274
  const entries: RecentSessionEntry[] = [];
248
- for (const candidate of candidates) {
249
- let initialLines: string[];
275
+ for (const { scope, candidate } of candidates) {
276
+ let selected = candidate;
277
+ let initialLines: string[] | undefined;
250
278
  try {
251
- initialLines = readCandidateInitialLines(candidate, readInitialLines);
279
+ initialLines = readCandidateInitialLines(selected, readInitialLines);
252
280
  } catch (error) {
253
- return {
254
- kind: "error",
255
- code: "managed_scan_failed",
256
- message: `Could not read managed session metadata: ${error instanceof Error ? error.message : String(error)}`,
257
- };
281
+ if (error instanceof ManagedCandidateChangedError) {
282
+ const refreshed = await listManagedSessionCandidates({ scope });
283
+ if (refreshed.kind !== "complete")
284
+ return { kind: "error", code: "managed_scan_failed", message: refreshed.message };
285
+ const sameFile = refreshed.owned.find(
286
+ current =>
287
+ path.resolve(current.path) === path.resolve(candidate.path) &&
288
+ current.identity.dev === candidate.identity.dev &&
289
+ current.identity.ino === candidate.identity.ino,
290
+ );
291
+ if (sameFile) {
292
+ try {
293
+ initialLines = readCandidateInitialLines(sameFile, readInitialLines, candidate);
294
+ selected = sameFile;
295
+ } catch (retryError) {
296
+ if (
297
+ !(retryError instanceof ManagedCandidateChangedError) &&
298
+ !(retryError instanceof ManagedCandidateUnavailableError)
299
+ )
300
+ return {
301
+ kind: "error",
302
+ code: "managed_scan_failed",
303
+ message: `Could not read managed session metadata: ${
304
+ retryError instanceof Error ? retryError.message : String(retryError)
305
+ }`,
306
+ };
307
+ }
308
+ }
309
+ } else if (!(error instanceof ManagedCandidateUnavailableError)) {
310
+ return {
311
+ kind: "error",
312
+ code: "managed_scan_failed",
313
+ message: `Could not read managed session metadata: ${
314
+ error instanceof Error ? error.message : String(error)
315
+ }`,
316
+ };
317
+ }
318
+ if (!initialLines) {
319
+ warnings.push(CHANGED_CANDIDATE_WARNING);
320
+ continue;
321
+ }
258
322
  }
259
323
  const meta = headerMeta(initialLines[0]);
260
324
  const internal = isInternalSession(initialLines);
261
325
  if (internal && !includeInternal) continue;
262
326
  entries.push({
263
- sessionId: candidate.sessionId,
264
- path: candidate.cwd,
327
+ sessionId: selected.sessionId,
328
+ path: selected.cwd,
265
329
  branch: meta.branch,
266
330
  title: meta.title,
267
- sessionStateFile: candidate.path,
268
- mtimeMs: candidate.identity.mtimeMs,
269
- currentTerminal: breadcrumbs.has(path.resolve(candidate.path)) || undefined,
331
+ sessionStateFile: selected.path,
332
+ mtimeMs: selected.identity.mtimeMs,
333
+ currentTerminal: breadcrumbs.has(path.resolve(selected.path)) || undefined,
270
334
  internal: internal || undefined,
271
335
  });
272
336
  }
@@ -26,7 +26,19 @@ export const NOTIFICATION_PROTOCOL_VERSION = 3;
26
26
  * legacy stopped-lock reclamation in generation 14, Windows expected-identity
27
27
  * ACL verification and repair in generation 15, identity-fenced stale endpoint
28
28
  * startup recovery in generation 16, Telegram topic recovery authority fencing
29
- * in generation 17, and fail-closed blank-token validation plus lifecycle-startup
30
- * stop fencing in generation 18.
29
+ * in generation 17, fail-closed blank-token validation plus lifecycle-startup
30
+ * stop fencing in generation 18, recommended ask metadata rendering in
31
+ * generation 19, authoritative terminal session-close delivery and cleanup
32
+ * fencing, attested generation-bearing pre-incarnation owner handoff in
33
+ * generation 20, guarded modern generation-absent predecessor signaling in
34
+ * generation 21, dead Windows v0.10 owner replacement in generation 22, and
35
+ * retained native cleanup authority revalidation in generation 23, and typed
36
+ * retained exact-unlink cleanup authority acceptance (concrete detached
37
+ * quarantine plus proven canonical absence) in generation 24.
38
+ * Generation 25 adds startup dead-root prune + leak-artifact self-heal
39
+ * on TelegramNotificationDaemon.run (#2958). Generation 26 adds bounded reload
40
+ * cooldown and lazy Telegram topic lifecycle safeguards (#2956, #2960, #2984).
41
+ * Generation 27 refreshes retained native path and process authority semantics.
42
+ * Generation 28 rejects special files before retained native authority opens.
31
43
  */
32
- export const DAEMON_GENERATION = 18;
44
+ export const DAEMON_GENERATION = 28;