@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
@@ -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
  }
@@ -21,7 +21,22 @@ export const NOTIFICATION_PROTOCOL_VERSION = 3;
21
21
  * confirmed-dead owner's lock in generation 10, legacy stopped-tombstone
22
22
  * reclamation in generation 11, force-escalated SIGKILL of an unresponsive
23
23
  * older-generation owner during automatic generation-upgrade reload in
24
- * generation 12, and restored macOS daemon signaling (kill(2) with a start-time
25
- * incarnation recheck, replacing the darwin no-op) in generation 13.
24
+ * generation 12, restored macOS daemon signaling (kill(2) with a start-time
25
+ * incarnation recheck, replacing the darwin no-op) in generation 13, retained
26
+ * legacy stopped-lock reclamation in generation 14, Windows expected-identity
27
+ * ACL verification and repair in generation 15, identity-fenced stale endpoint
28
+ * startup recovery in generation 16, Telegram topic recovery authority fencing
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).
26
41
  */
27
- export const DAEMON_GENERATION = 13;
42
+ export const DAEMON_GENERATION = 26;
@@ -25,9 +25,11 @@ import type {
25
25
  import { OWNERSHIP_MISMATCH_MESSAGE, ownershipMismatchRecovery } from "../../daemon/operator-contract";
26
26
  import { resolveGjcRuntimeSpawnInfo } from "../../daemon/runtime";
27
27
  import { isProcessIncarnation } from "../broker/process-incarnation";
28
+
28
29
  import { getNotificationConfig, isTelegramConfigured, tokenFingerprint } from "./config";
29
30
  import { exactUnlinkNotificationFile, readNotificationEndpointFile } from "./notification-service";
30
31
  import {
32
+ type AttestedLegacyDaemonOwner,
31
33
  confirmTelegramDaemonSpawn,
32
34
  type DaemonState,
33
35
  daemonPaths,
@@ -35,6 +37,7 @@ import {
35
37
  isCurrentCompatibleOwner,
36
38
  isFreshLiveOwner,
37
39
  isSignalableMatchingOwner,
40
+ readAttestedLegacyDaemonOwner,
38
41
  readDaemonRoots,
39
42
  readDaemonState,
40
43
  spawnTelegramDaemonOwner,
@@ -109,16 +112,19 @@ export async function clearTelegramControlRequest(
109
112
 
110
113
  export interface DaemonProcessReference {
111
114
  incarnation: string;
115
+ /** Whether the platform delivers the requested signal cooperatively or as a hard termination. */
116
+ termination: "cooperative" | "hard";
112
117
  signalRoot(signal: NodeJS.Signals): void;
113
118
  }
114
119
 
115
- function defaultProcessReference(pid: number, platform = os.platform()): DaemonProcessReference | undefined {
120
+ export function defaultProcessReference(pid: number, platform = os.platform()): DaemonProcessReference | undefined {
116
121
  try {
117
122
  const processRef = Process.fromPid(pid);
118
123
  if (!processRef || !isProcessIncarnation(processRef.incarnation)) return undefined;
119
124
  const incarnation = processRef.incarnation;
120
125
  return {
121
126
  incarnation,
127
+ termination: platform === "win32" ? "hard" : "cooperative",
122
128
  signalRoot: signal => {
123
129
  const nativeSignal = os.constants.signals[signal];
124
130
  if (nativeSignal === undefined) throw new Error(`Unsupported signal: ${signal}`);
@@ -178,8 +184,8 @@ function defaultPidAlive(pid: number): boolean {
178
184
  try {
179
185
  process.kill(pid, 0);
180
186
  return true;
181
- } catch {
182
- return false;
187
+ } catch (error) {
188
+ return (error as NodeJS.ErrnoException).code !== "ESRCH";
183
189
  }
184
190
  }
185
191
 
@@ -190,7 +196,6 @@ export class TelegramDaemonController implements BuiltInDaemonController {
190
196
  private readonly now: () => number;
191
197
  private readonly processReference: (pid: number) => DaemonProcessReference | undefined;
192
198
  private readonly waitStepMs: number;
193
-
194
199
  constructor(
195
200
  private readonly settings: Settings,
196
201
  private readonly deps: TelegramDaemonControlDeps = {},
@@ -315,31 +320,28 @@ export class TelegramDaemonController implements BuiltInDaemonController {
315
320
  tokenFingerprint: string,
316
321
  chatId: string,
317
322
  signal: NodeJS.Signals,
318
- ): Promise<"signaled" | "already_gone" | "ownership_changed"> {
323
+ ): Promise<"signaled" | "already_gone" | "ownership_changed" | "hard_termination"> {
319
324
  const current = await readDaemonState(this.settings, this.fsImpl);
320
- // The signal is a privileged action: immediately before sending it, prove the
321
- // complete state record is still the exact captured owner. A malformed record,
322
- // configuration mutation, or successor using a reused PID is never signalable.
323
- if (
324
- !hasSafeDaemonStateShape(current) ||
325
- current.ownerId !== captured.ownerId ||
326
- current.acquisitionId !== captured.acquisitionId ||
327
- current.pid !== captured.pid ||
328
- current.generation !== captured.generation ||
329
- current.incarnation !== captured.incarnation ||
330
- current.tokenFingerprint !== captured.tokenFingerprint ||
331
- current.chatId !== captured.chatId ||
332
- current.tokenFingerprint !== tokenFingerprint ||
333
- current.chatId !== chatId
334
- )
335
- return "ownership_changed";
336
- // A matching captured owner that exited between the request and recheck has
337
- // completed the handoff. A live owner with changed or unavailable provenance
338
- // remains ambiguous and must not be treated as stopped.
325
+
326
+ const modernCurrentMatches =
327
+ hasSafeDaemonStateShape(current) &&
328
+ current.ownerId === captured.ownerId &&
329
+ current.acquisitionId === captured.acquisitionId &&
330
+ current.pid === captured.pid &&
331
+ current.generation === captured.generation &&
332
+ current.incarnation === captured.incarnation &&
333
+ current.tokenFingerprint === captured.tokenFingerprint &&
334
+ current.chatId === captured.chatId &&
335
+ current.tokenFingerprint === tokenFingerprint &&
336
+ current.chatId === chatId;
337
+ // The signal is privileged: the persisted record must still be the exact
338
+ // modern owner. Legacy generation-3 records remain migration evidence only;
339
+ // this controller never upgrades or signals them.
340
+ if (!modernCurrentMatches) return "ownership_changed";
339
341
  if (!this.pidAlive(captured.pid)) return "already_gone";
340
342
  if (
341
343
  !isSignalableMatchingOwner({
342
- state: current,
344
+ state: modernCurrentMatches ? current : captured,
343
345
  tokenFingerprint,
344
346
  chatId,
345
347
  pidAlive: this.pidAlive,
@@ -352,6 +354,69 @@ export class TelegramDaemonController implements BuiltInDaemonController {
352
354
  // the exit-and-reuse window between ordinary provenance checks and signaling.
353
355
  // Its identity must still be the exact persisted incarnation before use.
354
356
  if (!processRef || processRef.incarnation !== captured.incarnation) return "ownership_changed";
357
+ if (signal === "SIGTERM" && processRef.termination === "hard") return "hard_termination";
358
+ try {
359
+ processRef.signalRoot(signal);
360
+ } catch {
361
+ return "already_gone";
362
+ }
363
+ return "signaled";
364
+ }
365
+
366
+ #currentConfigurationMatches(tokenFingerprintValue: string, chatId: string): boolean {
367
+ const current = getNotificationConfig(this.settings);
368
+ return (
369
+ isTelegramConfigured(current) &&
370
+ tokenFingerprint(current.botToken) === tokenFingerprintValue &&
371
+ current.chatId === chatId
372
+ );
373
+ }
374
+
375
+ async #signalAttestedLegacyOwner(
376
+ captured: AttestedLegacyDaemonOwner,
377
+ tokenFingerprint: string,
378
+ chatId: string,
379
+ signal: NodeJS.Signals,
380
+ ): Promise<"signaled" | "already_gone" | "ownership_changed" | "hard_termination"> {
381
+ if (!this.#currentConfigurationMatches(tokenFingerprint, chatId)) return "ownership_changed";
382
+ const current = await readAttestedLegacyDaemonOwner({
383
+ settings: this.settings,
384
+ fs: this.fsImpl,
385
+ now: this.now,
386
+ pidIncarnation: this.deps.pidIncarnation,
387
+ tokenFingerprint,
388
+ chatId,
389
+ });
390
+ if (
391
+ !current ||
392
+ current.state.pid !== captured.state.pid ||
393
+ current.state.ownerId !== captured.state.ownerId ||
394
+ current.state.startedAt !== captured.state.startedAt ||
395
+ current.incarnation !== captured.incarnation
396
+ )
397
+ return "ownership_changed";
398
+ if (!this.pidAlive(captured.state.pid)) return "already_gone";
399
+ const processRef = this.processReference(captured.state.pid);
400
+ if (!processRef || processRef.incarnation !== captured.incarnation) return "ownership_changed";
401
+ const rechecked = await readAttestedLegacyDaemonOwner({
402
+ settings: this.settings,
403
+ fs: this.fsImpl,
404
+ now: this.now,
405
+ pidIncarnation: this.deps.pidIncarnation,
406
+ tokenFingerprint,
407
+ chatId,
408
+ });
409
+ if (
410
+ !rechecked ||
411
+ rechecked.state.pid !== current.state.pid ||
412
+ rechecked.state.ownerId !== current.state.ownerId ||
413
+ rechecked.state.startedAt !== current.state.startedAt ||
414
+ rechecked.incarnation !== current.incarnation ||
415
+ !this.#currentConfigurationMatches(tokenFingerprint, chatId) ||
416
+ !this.pidAlive(captured.state.pid)
417
+ )
418
+ return "ownership_changed";
419
+ if (signal === "SIGTERM" && processRef.termination === "hard") return "hard_termination";
355
420
  try {
356
421
  processRef.signalRoot(signal);
357
422
  } catch {
@@ -376,7 +441,10 @@ export class TelegramDaemonController implements BuiltInDaemonController {
376
441
  return this.stopOrReload("reload", opts);
377
442
  }
378
443
 
379
- async reloadForGenerationUpgrade(opts: DaemonOperationOptions = {}): Promise<TelegramGenerationReloadResult> {
444
+ async reloadForGenerationUpgrade(
445
+ opts: DaemonOperationOptions = {},
446
+ attestedLegacyUpgrade = false,
447
+ ): Promise<TelegramGenerationReloadResult> {
380
448
  // A generation upgrade MUST replace an incompatible older-generation owner to
381
449
  // avoid a permanent single-poller deadlock. Unlike a manual `gjc daemon
382
450
  // reload`, this automatic path force-escalates to SIGKILL when the old owner
@@ -384,7 +452,7 @@ export class TelegramDaemonController implements BuiltInDaemonController {
384
452
  // self-recovers instead of failing closed and asking the operator to rerun
385
453
  // with --force. The SIGKILL remains fenced to the still-live, still-matching
386
454
  // captured owner (same ownerId + pid), so a fresh replacement is never killed.
387
- const operation = await this.stopOrReload("reload", { ...opts, force: true });
455
+ const operation = await this.stopOrReload("reload", { ...opts, force: true }, attestedLegacyUpgrade);
388
456
  if (!operation.ok) return { outcome: "failed", operation };
389
457
  const after = await this.status();
390
458
  return after.health === "running" ? { outcome: "ready", operation } : { outcome: "failed", operation };
@@ -394,7 +462,11 @@ export class TelegramDaemonController implements BuiltInDaemonController {
394
462
  return this.stopOrReload("stop", opts);
395
463
  }
396
464
 
397
- private async stopOrReload(action: "stop" | "reload", opts: DaemonOperationOptions): Promise<DaemonOperationResult> {
465
+ private async stopOrReload(
466
+ action: "stop" | "reload",
467
+ opts: DaemonOperationOptions,
468
+ attestedLegacyUpgrade = false,
469
+ ): Promise<DaemonOperationResult> {
398
470
  const before = await this.status();
399
471
  const warnings: string[] = [];
400
472
  if (before.runtime.warning) warnings.push(before.runtime.warning);
@@ -409,6 +481,16 @@ export class TelegramDaemonController implements BuiltInDaemonController {
409
481
  const killTimeoutMs = opts.killTimeoutMs ?? DEFAULT_KILL_TIMEOUT_MS;
410
482
 
411
483
  const state = await readDaemonState(this.settings, this.fsImpl);
484
+ const attestedLegacyOwner = attestedLegacyUpgrade
485
+ ? await readAttestedLegacyDaemonOwner({
486
+ settings: this.settings,
487
+ fs: this.fsImpl,
488
+ now: this.now,
489
+ pidIncarnation: this.deps.pidIncarnation,
490
+ tokenFingerprint: fp,
491
+ chatId,
492
+ })
493
+ : undefined;
412
494
  const replaceableLiveOwner =
413
495
  (action === "reload" &&
414
496
  state !== undefined &&
@@ -427,6 +509,7 @@ export class TelegramDaemonController implements BuiltInDaemonController {
427
509
  pidAlive: this.pidAlive,
428
510
  pidIncarnation: this.deps.pidIncarnation,
429
511
  })) ||
512
+ (action === "reload" && attestedLegacyOwner !== undefined && this.pidAlive(attestedLegacyOwner.state.pid)) ||
430
513
  // A physically-live matching owner whose heartbeat is stale (hung) may be
431
514
  // past-TTL yet still holding the poller. Autostart/generation-upgrade reloads
432
515
  // stay conservative and refuse it, but an explicit `reload --force` must be
@@ -483,16 +566,18 @@ export class TelegramDaemonController implements BuiltInDaemonController {
483
566
  }
484
567
 
485
568
  // Running owner: capture identity, request cooperative stop, signal, wait.
486
- if (
487
- !hasSafeDaemonStateShape(state) ||
488
- !isSignalableMatchingOwner({
569
+ const modernCapturedOwner =
570
+ hasSafeDaemonStateShape(state) &&
571
+ isSignalableMatchingOwner({
489
572
  state,
490
573
  tokenFingerprint: fp,
491
574
  chatId,
492
575
  pidAlive: this.pidAlive,
493
576
  pidIncarnation: this.deps.pidIncarnation,
494
577
  })
495
- ) {
578
+ ? state
579
+ : undefined;
580
+ if (!modernCapturedOwner && !attestedLegacyOwner) {
496
581
  return this.result(
497
582
  action,
498
583
  false,
@@ -502,7 +587,7 @@ export class TelegramDaemonController implements BuiltInDaemonController {
502
587
  warnings,
503
588
  );
504
589
  }
505
- const capturedOwner = state;
590
+ const capturedOwner = modernCapturedOwner ?? attestedLegacyOwner!.state;
506
591
  const oldOwnerId = capturedOwner.ownerId;
507
592
  const oldPid = capturedOwner.pid;
508
593
  const requestId = this.deps.randomId?.() ?? `${this.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
@@ -511,7 +596,21 @@ export class TelegramDaemonController implements BuiltInDaemonController {
511
596
  { version: 1, requestId, action, ownerId: oldOwnerId, pid: oldPid, createdAt: this.now() },
512
597
  this.fsImpl,
513
598
  );
514
- if ((await this.signalCapturedOwner(capturedOwner, fp, chatId, "SIGTERM")) === "ownership_changed") {
599
+ const signalResult = attestedLegacyOwner
600
+ ? await this.#signalAttestedLegacyOwner(attestedLegacyOwner, fp, chatId, "SIGTERM")
601
+ : await this.signalCapturedOwner(modernCapturedOwner!, fp, chatId, "SIGTERM");
602
+ if (signalResult === "hard_termination") {
603
+ await this.clearOwnRequest(requestId);
604
+ return this.result(
605
+ action,
606
+ false,
607
+ "telegram daemon has hard process authority; refusing cooperative SIGTERM",
608
+ before,
609
+ await this.status(),
610
+ warnings,
611
+ );
612
+ }
613
+ if (signalResult === "ownership_changed") {
515
614
  await this.clearOwnRequest(requestId);
516
615
  return this.result(
517
616
  action,
@@ -559,7 +658,9 @@ export class TelegramDaemonController implements BuiltInDaemonController {
559
658
  // the captured owner/pid still matches, so we never kill a different owner.
560
659
  const stillSameOwner = current !== undefined && current.ownerId === oldOwnerId && current.pid === oldPid;
561
660
  if (opts.force && stillSameOwner) {
562
- const killResult = await this.signalCapturedOwner(capturedOwner, fp, chatId, "SIGKILL");
661
+ const killResult = attestedLegacyOwner
662
+ ? await this.#signalAttestedLegacyOwner(attestedLegacyOwner, fp, chatId, "SIGKILL")
663
+ : await this.signalCapturedOwner(modernCapturedOwner!, fp, chatId, "SIGKILL");
563
664
  // Kill only a still-live matching owner; an owner that exited between the
564
665
  // graceful timeout and this recheck ("already_gone") is confirmed dead by
565
666
  // waitForPidDeath, while a real ownership change stays fenced.
@@ -585,6 +686,17 @@ export class TelegramDaemonController implements BuiltInDaemonController {
585
686
  return this.result(action, true, "stopped telegram daemon", before, after, warnings);
586
687
  }
587
688
 
689
+ if (!this.#currentConfigurationMatches(fp, chatId)) {
690
+ const after = await this.status();
691
+ return this.result(
692
+ action,
693
+ false,
694
+ "telegram notification configuration changed during reload; refusing to spawn",
695
+ before,
696
+ after,
697
+ warnings,
698
+ );
699
+ }
588
700
  const { spawned, ready } = await this.spawnAndWait(roots, fp, chatId);
589
701
  warnings.push(...spawned.warnings);
590
702
  const after = await this.status();