@gajae-code/coding-agent 0.12.11 → 0.12.13

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 (140) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/dist/types/cli/marketplace-hint.d.ts +28 -0
  3. package/dist/types/cli/notify-cli.d.ts +42 -1
  4. package/dist/types/cli/update-cli.d.ts +9 -3
  5. package/dist/types/cli.d.ts +10 -0
  6. package/dist/types/commands/notify.d.ts +6 -0
  7. package/dist/types/config/file-lock-gc.d.ts +13 -1
  8. package/dist/types/config/file-lock.d.ts +3 -0
  9. package/dist/types/config/model-registry.d.ts +6 -0
  10. package/dist/types/config/models-config-schema.d.ts +99 -0
  11. package/dist/types/coordinator/contract.d.ts +1 -1
  12. package/dist/types/coordinator-mcp/question-state.d.ts +3 -1
  13. package/dist/types/extensibility/extensions/index.d.ts +1 -0
  14. package/dist/types/extensibility/extensions/ouroboros-ooo-bridge.d.ts +13 -0
  15. package/dist/types/extensibility/extensions/prefix-command-bridge.d.ts +0 -1
  16. package/dist/types/extensibility/extensions/types.d.ts +2 -0
  17. package/dist/types/gjc-runtime/deep-interview-state.d.ts +14 -0
  18. package/dist/types/gjc-runtime/gc-runtime.d.ts +11 -0
  19. package/dist/types/main.d.ts +6 -2
  20. package/dist/types/modes/acp/acp-agent.d.ts +24 -1
  21. package/dist/types/modes/components/session-selector.d.ts +1 -1
  22. package/dist/types/modes/utils/host-status.d.ts +23 -0
  23. package/dist/types/runtime-mcp/types.d.ts +2 -0
  24. package/dist/types/sdk/broker/lifecycle.d.ts +12 -0
  25. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +3 -0
  26. package/dist/types/sdk/bus/chat-daemon-command-channel.d.ts +244 -0
  27. package/dist/types/sdk/bus/chat-daemon-control.d.ts +11 -1
  28. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +7 -0
  29. package/dist/types/sdk/bus/control-plane-frames.d.ts +43 -0
  30. package/dist/types/sdk/bus/conversation-store.d.ts +18 -0
  31. package/dist/types/sdk/bus/existing-thread-readiness.d.ts +71 -0
  32. package/dist/types/sdk/bus/html-format.d.ts +10 -0
  33. package/dist/types/sdk/bus/notification-service.d.ts +10 -0
  34. package/dist/types/sdk/bus/slack-daemon.d.ts +27 -0
  35. package/dist/types/sdk/bus/slack-live-provider.d.ts +11 -0
  36. package/dist/types/sdk/bus/slack-provider.d.ts +14 -0
  37. package/dist/types/sdk/bus/slack-thread-binding.d.ts +140 -0
  38. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +2 -0
  39. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +28 -7
  40. package/dist/types/sdk/bus/telegram-daemon.d.ts +119 -17
  41. package/dist/types/sdk/bus/telegram-reference.d.ts +20 -1
  42. package/dist/types/sdk/bus/topic-registry.d.ts +130 -85
  43. package/dist/types/sdk/host/host.d.ts +52 -0
  44. package/dist/types/sdk/lifecycle-session.d.ts +6 -0
  45. package/dist/types/sdk/session-activation.d.ts +41 -0
  46. package/dist/types/sdk/session.d.ts +18 -0
  47. package/dist/types/sdk/startup-capability.d.ts +8 -1
  48. package/dist/types/session/agent-session.d.ts +7 -2
  49. package/dist/types/session/artifacts.d.ts +2 -2
  50. package/dist/types/session/session-manager.d.ts +40 -6
  51. package/dist/types/tools/index.d.ts +15 -0
  52. package/dist/types/utils/npm-registry.d.ts +71 -0
  53. package/examples/extensions/README.md +38 -4
  54. package/examples/extensions/ooo-bridge.ts +15 -0
  55. package/package.json +8 -8
  56. package/scripts/generate-sdk-operation-inventory.ts +4 -0
  57. package/src/async/job-manager.ts +9 -2
  58. package/src/cli/fast-help.ts +1 -0
  59. package/src/cli/marketplace-hint.ts +39 -0
  60. package/src/cli/notify-cli.ts +226 -2
  61. package/src/cli/plugin-cli.ts +10 -0
  62. package/src/cli/update-cli.ts +90 -23
  63. package/src/cli.ts +23 -0
  64. package/src/commands/notify.ts +27 -3
  65. package/src/commands/sdk.ts +1 -0
  66. package/src/config/file-lock-gc.ts +65 -26
  67. package/src/config/file-lock.ts +61 -16
  68. package/src/config/model-profiles.ts +46 -30
  69. package/src/config/model-registry.ts +17 -5
  70. package/src/config/models-config-schema.ts +9 -4
  71. package/src/coordinator/contract.ts +1 -0
  72. package/src/coordinator-mcp/question-state.ts +2 -0
  73. package/src/coordinator-mcp/server.ts +358 -7
  74. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  75. package/src/defaults/gjc/skills/ultragoal/SKILL.md +2 -0
  76. package/src/extensibility/extensions/index.ts +1 -0
  77. package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +235 -0
  78. package/src/extensibility/extensions/prefix-command-bridge.ts +4 -9
  79. package/src/extensibility/extensions/runner.ts +15 -2
  80. package/src/extensibility/extensions/types.ts +2 -0
  81. package/src/gjc-runtime/deep-interview-recorder.ts +9 -5
  82. package/src/gjc-runtime/deep-interview-state.ts +27 -3
  83. package/src/gjc-runtime/gc-render.ts +8 -1
  84. package/src/gjc-runtime/gc-runtime.ts +15 -1
  85. package/src/gjc-runtime/launch-tmux.ts +53 -1
  86. package/src/internal-urls/docs-index.generated.ts +14 -13
  87. package/src/main.ts +214 -70
  88. package/src/modes/DESIGN.md +8 -5
  89. package/src/modes/acp/acp-agent.ts +123 -7
  90. package/src/modes/components/footer.ts +10 -18
  91. package/src/modes/components/model-selector.ts +52 -5
  92. package/src/modes/components/session-selector.ts +26 -3
  93. package/src/modes/components/status-line/gh.ts +23 -2
  94. package/src/modes/controllers/event-controller.ts +3 -0
  95. package/src/modes/controllers/extension-ui-controller.ts +2 -0
  96. package/src/modes/controllers/input-controller.ts +11 -0
  97. package/src/modes/controllers/selector-controller.ts +8 -2
  98. package/src/modes/interactive-mode.ts +7 -1
  99. package/src/modes/print-mode.ts +10 -0
  100. package/src/modes/utils/host-status.ts +38 -0
  101. package/src/prompts/system/project-prompt.md +19 -0
  102. package/src/runtime-mcp/types.ts +2 -0
  103. package/src/sdk/broker/lifecycle.ts +60 -3
  104. package/src/sdk/bus/chat-daemon-cli.ts +42 -0
  105. package/src/sdk/bus/chat-daemon-command-channel.ts +921 -0
  106. package/src/sdk/bus/chat-daemon-control.ts +16 -3
  107. package/src/sdk/bus/chat-daemon-runtime.ts +257 -21
  108. package/src/sdk/bus/control-plane-frames.ts +51 -0
  109. package/src/sdk/bus/conversation-store.ts +29 -1
  110. package/src/sdk/bus/existing-thread-readiness.ts +155 -0
  111. package/src/sdk/bus/html-format.ts +19 -3
  112. package/src/sdk/bus/index.ts +91 -12
  113. package/src/sdk/bus/notification-service.ts +28 -1
  114. package/src/sdk/bus/rich-render.ts +3 -2
  115. package/src/sdk/bus/slack-daemon.ts +99 -4
  116. package/src/sdk/bus/slack-live-provider.ts +32 -2
  117. package/src/sdk/bus/slack-provider.ts +11 -0
  118. package/src/sdk/bus/slack-thread-binding.ts +641 -0
  119. package/src/sdk/bus/telegram-daemon-cli.ts +11 -0
  120. package/src/sdk/bus/telegram-daemon-contract.ts +28 -7
  121. package/src/sdk/bus/telegram-daemon.ts +2007 -509
  122. package/src/sdk/bus/telegram-reference.ts +114 -15
  123. package/src/sdk/bus/topic-registry.ts +674 -181
  124. package/src/sdk/host/host.ts +180 -1
  125. package/src/sdk/lifecycle-session.ts +8 -2
  126. package/src/sdk/protocol/operation-inventory.generated.json +22 -0
  127. package/src/sdk/session-activation.ts +127 -0
  128. package/src/sdk/session.ts +144 -29
  129. package/src/sdk/startup-capability.ts +10 -1
  130. package/src/session/agent-session.ts +220 -13
  131. package/src/session/artifacts.ts +56 -13
  132. package/src/session/internal/managed-session-storage.ts +228 -7
  133. package/src/session/session-manager.ts +225 -60
  134. package/src/setup/provider-presets.json +3 -3
  135. package/src/task/index.ts +92 -44
  136. package/src/thinking-metadata.ts +2 -1
  137. package/src/tools/ask.ts +10 -0
  138. package/src/tools/index.ts +15 -0
  139. package/src/tools/todo-write.ts +4 -4
  140. package/src/utils/npm-registry.ts +660 -0
@@ -0,0 +1,641 @@
1
+ /**
2
+ * Adoption of an operator-supplied Slack thread as a live session's root.
3
+ *
4
+ * Three separate authorities have to agree before a mapping exists:
5
+ * 1. *Configuration*: a complete Slack target (both tokens plus workspace and
6
+ * channel) must already be configured; binding never accepts a target.
7
+ * 2. *Daemon*: only the running owner may mutate mappings, and only while it is
8
+ * still the exact owner (`ownerId`/`pid`/`incarnation`/daemon generation)
9
+ * captured before the request was published.
10
+ * 3. *Session*: the SDK session must be attachable right now — indexed, live,
11
+ * non-terminal, with a readable, non-stale endpoint whose pid matches the
12
+ * indexed host and whose generation has not rolled.
13
+ *
14
+ * Provider verification happens before any lock is taken, and the mapping commit
15
+ * re-proves session authority inside the store lock, so an authority that
16
+ * changes mid-flight leaves no mapping behind.
17
+ */
18
+
19
+ import * as fs from "node:fs/promises";
20
+ import * as path from "node:path";
21
+ import type { Settings } from "../../config/settings";
22
+ import type { SessionIndex } from "../broker/session-index";
23
+ import { readSdkSessionEndpoint, type SdkSessionEndpoint, type SdkSessionEndpointScope } from "../client/discovery";
24
+ import {
25
+ type ChatDaemonCommandOwner,
26
+ type ChatDaemonCommandSubmission,
27
+ submitChatDaemonCommand,
28
+ } from "./chat-daemon-command-channel";
29
+ import {
30
+ ChatDaemonController,
31
+ chatDaemonGeneration,
32
+ type EnsureChatDaemonResult,
33
+ ensureSlackDaemon,
34
+ hasSafeChatDaemonStateShape,
35
+ readChatDaemonState,
36
+ } from "./chat-daemon-control";
37
+ import { getNotificationConfig, isSlackComplete } from "./config";
38
+ import { ConversationStore } from "./conversation-store";
39
+ import { normalizeSlackConversation, type SlackConversation, slackConversationKey } from "./slack-conversation";
40
+
41
+ export type SlackThreadBindingErrorCode =
42
+ | "invalid_root"
43
+ | "target_not_configured"
44
+ | "daemon_unavailable"
45
+ | "daemon_owner_changed"
46
+ | "session_not_live"
47
+ | "root_not_found"
48
+ | "provider_unavailable"
49
+ | "root_conflict"
50
+ | "session_conflict"
51
+ | "binding_outcome_unknown"
52
+ | "binding_failed";
53
+
54
+ const BINDING_ERROR_CODES: readonly SlackThreadBindingErrorCode[] = [
55
+ "invalid_root",
56
+ "target_not_configured",
57
+ "daemon_unavailable",
58
+ "daemon_owner_changed",
59
+ "session_not_live",
60
+ "root_not_found",
61
+ "provider_unavailable",
62
+ "root_conflict",
63
+ "session_conflict",
64
+ "binding_outcome_unknown",
65
+ "binding_failed",
66
+ ];
67
+
68
+ /** A fail-closed rejection while adopting an operator-supplied Slack thread. */
69
+ export class SlackThreadBindingError extends Error {
70
+ constructor(
71
+ readonly code: SlackThreadBindingErrorCode,
72
+ message: string,
73
+ ) {
74
+ super(message);
75
+ this.name = "SlackThreadBindingError";
76
+ }
77
+ }
78
+
79
+ function bindingErrorCode(value: string | undefined): SlackThreadBindingErrorCode {
80
+ return BINDING_ERROR_CODES.find(candidate => candidate === value) ?? "binding_failed";
81
+ }
82
+
83
+ /**
84
+ * Slack message timestamps are `<seconds>.<fraction>` with ASCII digits on both
85
+ * sides. The bound keeps an operator-supplied value from becoming an unbounded
86
+ * store key or provider argument; the provider remains the final authority on
87
+ * whether the timestamp addresses a real message.
88
+ */
89
+ const MAX_SLACK_TS_SEGMENT = 12;
90
+
91
+ function isAsciiDigits(value: string): boolean {
92
+ if (value.length === 0) return false;
93
+ for (let index = 0; index < value.length; index++) {
94
+ const code = value.charCodeAt(index);
95
+ if (code < 0x30 || code > 0x39) return false;
96
+ }
97
+ return true;
98
+ }
99
+
100
+ export function isBoundedSlackRootTs(value: string): boolean {
101
+ if (typeof value !== "string") return false;
102
+ const separator = value.indexOf(".");
103
+ if (separator <= 0 || separator !== value.lastIndexOf(".")) return false;
104
+ const seconds = value.slice(0, separator);
105
+ const fraction = value.slice(separator + 1);
106
+ if (seconds.length > MAX_SLACK_TS_SEGMENT || fraction.length > MAX_SLACK_TS_SEGMENT) return false;
107
+ return isAsciiDigits(seconds) && isAsciiDigits(fraction);
108
+ }
109
+
110
+ /** Reject a non-addressable root before any authority read or persistence. */
111
+ export function assertBoundedSlackRootTs(rootTs: string): void {
112
+ if (!isBoundedSlackRootTs(rootTs))
113
+ throw new SlackThreadBindingError(
114
+ "invalid_root",
115
+ "Slack root timestamp must be a bounded <seconds>.<fraction> message timestamp.",
116
+ );
117
+ }
118
+
119
+ /** Proven right to bind one session at one endpoint generation. */
120
+ async function defaultStatEndpoint(endpointPath: string): Promise<{ mtimeMs: number } | undefined> {
121
+ const stat = await fs.stat(endpointPath).catch(() => undefined);
122
+ return stat ? { mtimeMs: stat.mtimeMs } : undefined;
123
+ }
124
+
125
+ export interface SlackSessionBindingAuthority {
126
+ sessionId: string;
127
+ endpointGeneration: number;
128
+ pid: number;
129
+ repo: string;
130
+ /**
131
+ * The exact endpoint this authority was proven against. Callers must use it
132
+ * rather than re-reading discovery: a second read can observe a different
133
+ * process whose endpoint generation happens to repeat, and the numeric
134
+ * generation alone cannot tell the two apart.
135
+ */
136
+ endpoint: SdkSessionEndpoint;
137
+ scope: SdkSessionEndpointScope;
138
+ endpointMtimeMs: number;
139
+ }
140
+
141
+ export interface SessionBindingAuthorityInput {
142
+ sessionIndex: SessionIndex;
143
+ sessionId: string;
144
+ readEndpoint?: (
145
+ repo: string,
146
+ sessionId: string,
147
+ scope?: SdkSessionEndpointScope,
148
+ ) => Promise<SdkSessionEndpoint | null>;
149
+ statEndpoint?: (endpointPath: string) => Promise<{ mtimeMs: number } | undefined>;
150
+ }
151
+
152
+ /**
153
+ * Resolve exact discovery/attachment authority for one session.
154
+ *
155
+ * `IndexedSession.live` alone is only pid liveness, which a terminated or
156
+ * unregistered session can still satisfy through pid reuse or a stale record.
157
+ * Adoption additionally requires an intact index replay, a non-terminal record,
158
+ * and a discovery endpoint that is present, well-formed, not marked stale, and
159
+ * owned by the same host pid the index recorded.
160
+ */
161
+ export async function resolveSessionBindingAuthority(
162
+ input: SessionBindingAuthorityInput,
163
+ ): Promise<SlackSessionBindingAuthority | undefined> {
164
+ await input.sessionIndex.refresh();
165
+ const listing = input.sessionIndex.listSessions();
166
+ // A truncated replay cannot prove the tail is free of a terminal or
167
+ // unregistration event, so a degraded index is never binding authority.
168
+ if (listing.warnings.length > 0) return undefined;
169
+ const session = listing.sessions.find(candidate => candidate.sessionId === input.sessionId);
170
+ if (!session?.live || session.terminalUncertain) return undefined;
171
+ if (!Number.isSafeInteger(session.endpointGeneration) || session.endpointGeneration <= 0) return undefined;
172
+ if (!Number.isSafeInteger(session.pid) || session.pid <= 0) return undefined;
173
+ // Same scope derivation the runtime's attach() fence uses. Reading a
174
+ // `.gjc/state/chat/sdk` session at the default scope would either miss it or
175
+ // prove the wrong endpoint, so an underivable scope is not authority.
176
+ const repo = path.resolve(session.locator.repo);
177
+ const defaultStateRoot = path.join(repo, ".gjc", "state");
178
+ const indexedStateRoot = path.resolve(session.locator.stateRoot);
179
+ const scope: SdkSessionEndpointScope | undefined =
180
+ indexedStateRoot === defaultStateRoot
181
+ ? "default"
182
+ : indexedStateRoot === path.join(defaultStateRoot, "chat")
183
+ ? "chat"
184
+ : undefined;
185
+ if (!scope || session.endpointMtimeMs === undefined) return undefined;
186
+
187
+ let endpoint: SdkSessionEndpoint | null;
188
+ try {
189
+ endpoint = await (input.readEndpoint ?? readSdkSessionEndpoint)(session.locator.repo, input.sessionId, scope);
190
+ } catch {
191
+ // A malformed discovery record is not authority for anything.
192
+ return undefined;
193
+ }
194
+ if (!endpoint || endpoint.stale === true || !endpoint.url || !endpoint.token) return undefined;
195
+ if (endpoint.pid === undefined || endpoint.pid !== session.pid) return undefined;
196
+
197
+ // The endpoint file must be the exact one the index observed. Without this
198
+ // the numeric generation is the only fence, and it repeats across processes.
199
+ const stat = await (input.statEndpoint ?? defaultStatEndpoint)(endpoint.path);
200
+ if (!stat || stat.mtimeMs !== session.endpointMtimeMs) return undefined;
201
+
202
+ return {
203
+ sessionId: input.sessionId,
204
+ endpointGeneration: session.endpointGeneration,
205
+ pid: session.pid,
206
+ repo: session.locator.repo,
207
+ endpoint,
208
+ scope,
209
+ endpointMtimeMs: session.endpointMtimeMs,
210
+ };
211
+ }
212
+
213
+ /** States in which a mapping still owns its session's root claim. */
214
+ function holdsRootClaim(record: SlackConversation): boolean {
215
+ return record.state === "active" || record.state === "posting_root" || record.state === "resumed_root";
216
+ }
217
+
218
+ export interface SlackThreadClaimInput {
219
+ store: ConversationStore<SlackConversation>;
220
+ /** The session root-claim key shared with stock root publication. */
221
+ key: string;
222
+ teamId: string;
223
+ channelId: string;
224
+ sessionId: string;
225
+ rootTs: string;
226
+ endpointGeneration: number;
227
+ /**
228
+ * Re-proves session authority inside the store lock, immediately before
229
+ * commit. It may be invoked more than once for one claim: authority here is
230
+ * monotone — a rolled endpoint generation, a replaced daemon owner tuple and
231
+ * a retired single-winner request claim never come back — so repeating the
232
+ * proof can only ever narrow the window, never widen it. It must therefore be
233
+ * idempotent and must never perform a remote call.
234
+ */
235
+ revalidate: () => Promise<boolean>;
236
+ now?: () => number;
237
+ }
238
+
239
+ /**
240
+ * Claim an existing Slack root for a session without publishing a replacement.
241
+ *
242
+ * The claim transacts the same `intent:<sessionId>` key that stock root
243
+ * publication uses, so a bind and a concurrent first notification serialize on
244
+ * one invariant: whichever commits first owns the session's single root, and
245
+ * the loser observes it instead of creating a second one.
246
+ */
247
+ export async function claimSlackThreadBinding(input: SlackThreadClaimInput): Promise<SlackConversation> {
248
+ assertBoundedSlackRootTs(input.rootTs);
249
+ const now = input.now ?? Date.now;
250
+ let rejection: SlackThreadBindingError | undefined;
251
+ let commitAuthorityGranted = false;
252
+ let bound: SlackConversation | undefined;
253
+ try {
254
+ bound = await claimTransaction(input, now, {
255
+ writeRejection: value => (rejection = value),
256
+ grantCommitAuthority: () => {
257
+ commitAuthorityGranted = true;
258
+ },
259
+ });
260
+ } catch (error) {
261
+ if (error instanceof SlackThreadBindingError) throw error;
262
+ // An explicit domain rejection is a decision, and a decision is never
263
+ // rewritten by a failure that happened after it.
264
+ if (rejection) throw rejection;
265
+ // Nothing else here is a decision. Once the final authority proof granted
266
+ // commit authority, the replacement was handed to the store, and the store
267
+ // renames the staged document *before* its parent-directory durability
268
+ // barrier and before its lock cleanup run. A generic failure raised from
269
+ // that point on may therefore be the failure of an already-applied
270
+ // mapping, which is indeterminate — never a definitive rejection the
271
+ // caller could safely retry against.
272
+ if (commitAuthorityGranted)
273
+ throw new SlackThreadBindingError(
274
+ "binding_outcome_unknown",
275
+ "The Slack mapping may already be applied but its commit could not be proven; rerun the binding to observe the settled state.",
276
+ );
277
+ throw new SlackThreadBindingError("binding_failed", "Slack root binding could not be claimed.");
278
+ }
279
+ if (rejection) throw rejection;
280
+ if (
281
+ bound?.state !== "active" ||
282
+ bound.sessionId !== input.sessionId ||
283
+ bound.rootTs !== input.rootTs ||
284
+ bound.endpointGeneration !== input.endpointGeneration
285
+ )
286
+ throw new SlackThreadBindingError("binding_failed", "Slack root binding could not be claimed.");
287
+ return bound;
288
+ }
289
+
290
+ interface ClaimTransactionHooks {
291
+ writeRejection: (value: SlackThreadBindingError) => void;
292
+ /** Called exactly when the final authority proof clears a mutation for commit. */
293
+ grantCommitAuthority: () => void;
294
+ }
295
+
296
+ function claimTransaction(
297
+ input: SlackThreadClaimInput,
298
+ now: () => number,
299
+ hooks: ClaimTransactionHooks,
300
+ ): Promise<SlackConversation | undefined> {
301
+ const rejectWith = (code: SlackThreadBindingErrorCode, message: string): undefined => {
302
+ hooks.writeRejection(new SlackThreadBindingError(code, message));
303
+ return undefined;
304
+ };
305
+ /**
306
+ * The final authority fence. Every mutation passes through it — the first
307
+ * create, an endpoint-generation advance, and nothing else — so no mapping
308
+ * can ever be stamped with an authority that had already rolled.
309
+ *
310
+ * It is two proofs, not one, because taking commit authority is itself
311
+ * asynchronous: the command-channel caller's proof re-reads the persisted
312
+ * owner record, takes the single-winner response claim, and re-reads the
313
+ * published request, and the session's endpoint generation can roll inside
314
+ * any of those awaits. A single proof would then report an authority it no
315
+ * longer holds. Authority here is monotone — a rolled generation, a replaced
316
+ * owner tuple and a retired request never come back — so live at the opening
317
+ * proof and live again at the closing proof means live across the whole
318
+ * interval between them.
319
+ *
320
+ * Both proofs are local work only: no provider or network call ever runs
321
+ * under the store lock. Authority that rolls after the closing proof is an
322
+ * ordinary lifecycle event, fenced by the endpoint generation the mapping
323
+ * records.
324
+ */
325
+ const proveCommitAuthority = async (): Promise<boolean> => {
326
+ // The opening proof, then the closing proof.
327
+ for (let proof = 0; proof < 2; proof++) {
328
+ if (await input.revalidate()) continue;
329
+ rejectWith("session_not_live", "Slack session authority changed before the binding could commit.");
330
+ return false;
331
+ }
332
+ hooks.grantCommitAuthority();
333
+ return true;
334
+ };
335
+ return input.store.transactWithSnapshot(input.key, async (current, conversations) => {
336
+ for (const [candidateKey, candidate] of Object.entries(conversations)) {
337
+ if (!holdsRootClaim(candidate)) continue;
338
+ if (
339
+ candidate.teamId === input.teamId &&
340
+ candidate.channelId === input.channelId &&
341
+ candidate.rootTs === input.rootTs &&
342
+ candidate.sessionId !== input.sessionId
343
+ ) {
344
+ rejectWith("root_conflict", "Slack root is already bound to another session.");
345
+ return current;
346
+ }
347
+ if (candidateKey !== input.key && candidate.sessionId === input.sessionId) {
348
+ rejectWith("session_conflict", "Slack session already holds a different root claim.");
349
+ return current;
350
+ }
351
+ }
352
+ if (current) {
353
+ const storedGeneration = current.endpointGeneration ?? 0;
354
+ if (
355
+ current.sessionId !== input.sessionId ||
356
+ current.state !== "active" ||
357
+ current.rootTs !== input.rootTs ||
358
+ storedGeneration > input.endpointGeneration
359
+ ) {
360
+ rejectWith("session_conflict", "Slack session already holds a different root claim.");
361
+ return current;
362
+ }
363
+ // An exact replay must not mutate, but it still has to prove authority:
364
+ // an unauthorized or cancelled request may not be answered with the
365
+ // record a previous, authorized request created. Nothing is staged for
366
+ // this branch, so one proof is the whole fence and a refusal here is a
367
+ // definitive, mutation-free rejection.
368
+ if (storedGeneration === input.endpointGeneration) {
369
+ if (!(await input.revalidate()))
370
+ rejectWith("session_not_live", "Slack session authority changed before the binding could commit.");
371
+ return current;
372
+ }
373
+ }
374
+ if (!(await proveCommitAuthority())) return current;
375
+ return normalizeSlackConversation({
376
+ generation: (current?.generation ?? 0) + 1,
377
+ state: "active",
378
+ teamId: input.teamId,
379
+ channelId: input.channelId,
380
+ rootTs: input.rootTs,
381
+ sessionId: input.sessionId,
382
+ endpointGeneration: input.endpointGeneration,
383
+ updatedAt: now(),
384
+ seenEventIds: current?.seenEventIds ?? [],
385
+ seenContextIds: current?.seenContextIds ?? [],
386
+ seenRetryKeys: current?.seenRetryKeys ?? [],
387
+ seenInteractionIds: current?.seenInteractionIds ?? [],
388
+ inboundDispatches: current?.inboundDispatches ?? [],
389
+ });
390
+ });
391
+ }
392
+
393
+ export interface ConfiguredSlackThreadBindingInput {
394
+ settings: Settings;
395
+ sessionId: string;
396
+ threadTs: string;
397
+ }
398
+
399
+ export interface ConfiguredSlackThreadBindingDeps {
400
+ ensureDaemon?: (settings: Settings) => Promise<EnsureChatDaemonResult>;
401
+ timeoutMs?: number;
402
+ pollIntervalMs?: number;
403
+ /** Bounded wait for a daemon that took terminal authority before the caller gave up. */
404
+ settleGraceMs?: number;
405
+ now?: () => number;
406
+ sleep?: (ms: number) => Promise<void>;
407
+ }
408
+
409
+ /** Safe confirmation of an applied binding. It carries identifiers only. */
410
+ export interface BoundSlackThread {
411
+ sessionId: string;
412
+ endpointGeneration: number;
413
+ teamId: string;
414
+ channelId: string;
415
+ rootTs: string;
416
+ ownerId: string;
417
+ daemonGeneration: number;
418
+ }
419
+
420
+ /**
421
+ * Adopt an existing Slack root for a live session through the running daemon.
422
+ *
423
+ * The CLI never writes the mapping store: it proves the configured target and
424
+ * the exact current owner, then asks that owner to perform the mutation. A
425
+ * daemon that is no longer the captured owner answers `owner_changed`, so a
426
+ * replacement or restart between capture and execution can never apply the
427
+ * request.
428
+ *
429
+ * A submission that is not answered is cancelled through the channel's
430
+ * single-winner arbitration before it is reported as a failure. If that
431
+ * cancellation loses to the daemon's own commit authority, the outcome is
432
+ * reported as unknown instead of as a failure, because a mapping may already
433
+ * exist; re-running the same command observes the settled state idempotently.
434
+ *
435
+ * The command channel proves *correlation*, never authorship: every field a
436
+ * response echoes is copied verbatim out of the plaintext request published
437
+ * beside it. A definitive answer is therefore reported only after this caller
438
+ * itself observes the exact mutation in the conversation store — see
439
+ * `corroborateBoundMapping`.
440
+ */
441
+ export async function bindConfiguredSlackThread(
442
+ input: ConfiguredSlackThreadBindingInput,
443
+ deps: ConfiguredSlackThreadBindingDeps = {},
444
+ ): Promise<BoundSlackThread> {
445
+ assertBoundedSlackRootTs(input.threadTs);
446
+ const config = getNotificationConfig(input.settings);
447
+ if (!isSlackComplete(config))
448
+ throw new SlackThreadBindingError(
449
+ "target_not_configured",
450
+ "Slack notifications must be fully configured before binding an existing thread.",
451
+ );
452
+ const agentDir = input.settings.getAgentDir();
453
+ const ensured = await (deps.ensureDaemon ?? ensureSlackDaemon)(input.settings);
454
+ if (ensured === "disabled")
455
+ throw new SlackThreadBindingError(
456
+ "target_not_configured",
457
+ "Slack notifications must be fully configured before binding an existing thread.",
458
+ );
459
+ const status = await new ChatDaemonController(input.settings, "slack").status();
460
+ if (status.health !== "running")
461
+ throw new SlackThreadBindingError(
462
+ "daemon_unavailable",
463
+ `Slack daemon must be running to bind an existing thread (current: ${status.health}).`,
464
+ );
465
+ const state = await readChatDaemonState(agentDir, "slack");
466
+ if (
467
+ !hasSafeChatDaemonStateShape(state) ||
468
+ state.kind !== "slack" ||
469
+ state.stoppedAt !== undefined ||
470
+ state.ownerId !== status.ownerId ||
471
+ state.pid !== status.pid ||
472
+ state.generation !== chatDaemonGeneration("slack")
473
+ )
474
+ throw new SlackThreadBindingError(
475
+ "daemon_owner_changed",
476
+ "Slack daemon ownership changed; rerun the binding against the current owner.",
477
+ );
478
+ const owner: ChatDaemonCommandOwner = {
479
+ ownerId: state.ownerId,
480
+ pid: state.pid,
481
+ incarnation: state.incarnation,
482
+ generation: state.generation,
483
+ };
484
+ const submission = await submitChatDaemonCommand({
485
+ agentDir,
486
+ kind: "slack",
487
+ owner,
488
+ command: "bind-thread",
489
+ sessionId: input.sessionId,
490
+ rootTs: input.threadTs,
491
+ ...(deps.timeoutMs === undefined ? {} : { timeoutMs: deps.timeoutMs }),
492
+ ...(deps.pollIntervalMs === undefined ? {} : { pollIntervalMs: deps.pollIntervalMs }),
493
+ ...(deps.settleGraceMs === undefined ? {} : { settleGraceMs: deps.settleGraceMs }),
494
+ ...(deps.now === undefined ? {} : { now: deps.now }),
495
+ ...(deps.sleep === undefined ? {} : { sleep: deps.sleep }),
496
+ });
497
+ const target = { teamId: config.slack.workspaceId, channelId: config.slack.channelId };
498
+ const bound = interpretBindSubmission(submission, { owner, input, ...target });
499
+ return await corroborateBoundMapping(agentDir, bound, {
500
+ ...target,
501
+ sessionId: input.sessionId,
502
+ rootTs: input.threadTs,
503
+ });
504
+ }
505
+
506
+ /**
507
+ * Refuse a reported binding that the durable mapping store does not corroborate.
508
+ *
509
+ * The command channel authenticates nothing. Every field a response echoes is
510
+ * copied verbatim out of the plaintext request that sits beside it, and
511
+ * `<id>.response.json` is a single-winner object in the daemon's own command
512
+ * directory, so a complete, envelope-correct `status:"ok"` document is
513
+ * producible by a stale replay or a planted file with no daemon involvement at
514
+ * all — and so is any companion document in the same directory.
515
+ *
516
+ * Authenticating the responder needs an authority the writer cannot copy —
517
+ * kernel-verified peer identity over a local socket. That is not reachable from
518
+ * this runtime, so nothing here claims to authenticate anything. Instead the one
519
+ * definitive answer a caller can act on, `ok`, is refused unless this process
520
+ * itself observes the exact mutation in the conversation store: an authority
521
+ * outside the command directory, which a forged answer never produces because it
522
+ * performs no mutation.
523
+ *
524
+ * An uncorroborated mapping is reported as indeterminate rather than as a
525
+ * failure. A real daemon may have committed and had its mapping superseded
526
+ * before this read, so the operator is told to rerun and observe the settled
527
+ * state instead of being told the binding definitively failed.
528
+ */
529
+ async function corroborateBoundMapping(
530
+ agentDir: string,
531
+ bound: BoundSlackThread,
532
+ requested: { teamId: string; channelId: string; sessionId: string; rootTs: string },
533
+ ): Promise<BoundSlackThread> {
534
+ const key = slackConversationKey({
535
+ teamId: requested.teamId,
536
+ channelId: requested.channelId,
537
+ rootTs: `intent:${requested.sessionId}`,
538
+ });
539
+ let record: SlackConversation | undefined;
540
+ try {
541
+ record = await new ConversationStore<SlackConversation>({ agentDir, kind: "slack" }).read(key);
542
+ } catch {
543
+ // An unreadable store proves nothing about the mapping, which is exactly
544
+ // the indeterminate outcome reported below.
545
+ record = undefined;
546
+ }
547
+ if (
548
+ record?.state !== "active" ||
549
+ record.sessionId !== requested.sessionId ||
550
+ record.rootTs !== requested.rootTs ||
551
+ record.teamId !== requested.teamId ||
552
+ record.channelId !== requested.channelId ||
553
+ record.endpointGeneration !== bound.endpointGeneration
554
+ )
555
+ throw new SlackThreadBindingError(
556
+ "binding_outcome_unknown",
557
+ "The Slack daemon reported a binding the durable mapping store does not corroborate; rerun the binding to observe the settled state.",
558
+ );
559
+ return bound;
560
+ }
561
+
562
+ function interpretBindSubmission(
563
+ submission: ChatDaemonCommandSubmission,
564
+ context: {
565
+ owner: ChatDaemonCommandOwner;
566
+ input: ConfiguredSlackThreadBindingInput;
567
+ teamId: string;
568
+ channelId: string;
569
+ },
570
+ ): BoundSlackThread {
571
+ if (submission.outcome === "unavailable")
572
+ throw new SlackThreadBindingError(
573
+ "daemon_unavailable",
574
+ "The Slack daemon command channel is not usable for this request.",
575
+ );
576
+ if (submission.outcome === "cancelled")
577
+ throw new SlackThreadBindingError(
578
+ "daemon_unavailable",
579
+ "The Slack daemon did not answer in time; the request was cancelled before any mapping changed.",
580
+ );
581
+ if (submission.outcome === "unknown")
582
+ throw new SlackThreadBindingError(
583
+ "binding_outcome_unknown",
584
+ "The Slack daemon took commit authority but did not report an outcome; rerun the binding to observe it.",
585
+ );
586
+ if (submission.outcome === "untrusted")
587
+ throw new SlackThreadBindingError(
588
+ "binding_outcome_unknown",
589
+ "The Slack daemon channel holds an answer that does not carry this request's envelope; rerun the binding to observe the settled state.",
590
+ );
591
+ // `submitChatDaemonCommand` has proven that this document carries this exact
592
+ // request's envelope, which is correlation only: the envelope is public, so a
593
+ // forged answer satisfies it too. Nothing below may therefore be treated as
594
+ // proof that the daemon acted — the definitive `ok` branch is corroborated
595
+ // against the durable mapping store by `corroborateBoundMapping`.
596
+ const response = submission.response;
597
+ if (response.status === "owner_changed")
598
+ throw new SlackThreadBindingError(
599
+ "daemon_owner_changed",
600
+ "Slack daemon ownership changed; rerun the binding against the current owner.",
601
+ );
602
+ if (response.status === "expired")
603
+ throw new SlackThreadBindingError(
604
+ "daemon_unavailable",
605
+ "The Slack thread binding request expired before the daemon executed it.",
606
+ );
607
+ if (response.status === "outcome_unknown")
608
+ throw new SlackThreadBindingError(
609
+ "binding_outcome_unknown",
610
+ "The Slack daemon exercised commit authority but could not prove the mapping's durable state; rerun the binding to observe it.",
611
+ );
612
+ if (response.status === "rejected")
613
+ throw new SlackThreadBindingError(
614
+ bindingErrorCode(response.code),
615
+ `The Slack daemon refused the thread binding (${bindingErrorCode(response.code)}).`,
616
+ );
617
+ if (
618
+ response.ownerId !== context.owner.ownerId ||
619
+ response.pid !== context.owner.pid ||
620
+ response.incarnation !== context.owner.incarnation ||
621
+ response.generation !== context.owner.generation ||
622
+ response.sessionId !== context.input.sessionId ||
623
+ response.rootTs !== context.input.threadTs ||
624
+ response.teamId !== context.teamId ||
625
+ response.channelId !== context.channelId ||
626
+ response.endpointGeneration === undefined
627
+ )
628
+ throw new SlackThreadBindingError(
629
+ "binding_failed",
630
+ "The Slack daemon answered with a binding that does not match the request.",
631
+ );
632
+ return {
633
+ sessionId: response.sessionId,
634
+ endpointGeneration: response.endpointGeneration,
635
+ teamId: response.teamId,
636
+ channelId: response.channelId,
637
+ rootTs: response.rootTs,
638
+ ownerId: response.ownerId,
639
+ daemonGeneration: response.generation,
640
+ };
641
+ }
@@ -14,6 +14,8 @@ import {
14
14
  import { daemonPaths, HEARTBEAT_TTL_MS } from "./daemon-paths";
15
15
  import {
16
16
  type DaemonState,
17
+ FilesystemTopicRegistryCasAuthority,
18
+ loadInstallationHostId,
17
19
  readDaemonState,
18
20
  readOwnerFreshnessSnapshot,
19
21
  type TelegramDaemonOptions,
@@ -45,6 +47,8 @@ export interface RunDaemonInternalDeps {
45
47
  clearInterval?: (timer: Timer) => void;
46
48
  /** Reads persisted daemon ownership state; defaults to the real reader. */
47
49
  readDaemonState?: (settings: Settings) => Promise<DaemonState | undefined>;
50
+ /** Loads the verified machine-local identity; injectable so daemon tests do not touch the host. */
51
+ loadInstallationHostId?: () => Promise<string>;
48
52
  }
49
53
 
50
54
  /** Ownership-watchdog cadence while the daemon process is running. */
@@ -231,6 +235,11 @@ export async function runDaemonInternal(argv: string[], deps: RunDaemonInternalD
231
235
  const settings = await resolveDaemonSettings(resolvedAgentDir, deps);
232
236
  const cfg = getNotificationConfig(settings);
233
237
  if (!isProviderEffectivelyEnabled(cfg, "telegram") || !isTelegramComplete(cfg)) return;
238
+ const installationHostId = await (deps.loadInstallationHostId ?? loadInstallationHostId)();
239
+ const topicRegistryAuthority = new FilesystemTopicRegistryCasAuthority(
240
+ path.join(daemonPaths(resolvedAgentDir).dir, "telegram-topics.json"),
241
+ { installationHostId },
242
+ );
234
243
  const Daemon: TelegramDaemonConstructor = deps.DaemonImpl ?? TelegramNotificationDaemon;
235
244
  const readState = deps.readDaemonState ?? readDaemonState;
236
245
  const daemon = new Daemon({
@@ -247,6 +256,8 @@ export async function runDaemonInternal(argv: string[], deps: RunDaemonInternalD
247
256
  btw: cfg.btw,
248
257
  pid: deps.processPid ?? process.pid,
249
258
  control: createDaemonControlHooks(settings as Settings),
259
+ topicRegistryAuthority,
260
+ installationHostId,
250
261
  });
251
262
  // Signals are a process concern: install them at the daemon-internal boundary,
252
263
  // not inside the embeddable daemon class. SIGTERM is the reload wakeup path.