@gajae-code/coding-agent 0.12.11 → 0.12.12

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 (112) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/types/cli/notify-cli.d.ts +42 -1
  3. package/dist/types/cli/update-cli.d.ts +9 -3
  4. package/dist/types/commands/notify.d.ts +6 -0
  5. package/dist/types/config/file-lock.d.ts +3 -0
  6. package/dist/types/coordinator/contract.d.ts +1 -1
  7. package/dist/types/coordinator-mcp/question-state.d.ts +3 -1
  8. package/dist/types/extensibility/extensions/index.d.ts +1 -0
  9. package/dist/types/extensibility/extensions/ouroboros-ooo-bridge.d.ts +13 -0
  10. package/dist/types/extensibility/extensions/prefix-command-bridge.d.ts +0 -1
  11. package/dist/types/extensibility/extensions/types.d.ts +2 -0
  12. package/dist/types/main.d.ts +6 -2
  13. package/dist/types/modes/components/session-selector.d.ts +1 -1
  14. package/dist/types/modes/utils/host-status.d.ts +23 -0
  15. package/dist/types/runtime-mcp/types.d.ts +2 -0
  16. package/dist/types/sdk/broker/lifecycle.d.ts +12 -0
  17. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +3 -0
  18. package/dist/types/sdk/bus/chat-daemon-command-channel.d.ts +244 -0
  19. package/dist/types/sdk/bus/chat-daemon-control.d.ts +11 -1
  20. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +7 -0
  21. package/dist/types/sdk/bus/control-plane-frames.d.ts +43 -0
  22. package/dist/types/sdk/bus/conversation-store.d.ts +18 -0
  23. package/dist/types/sdk/bus/existing-thread-readiness.d.ts +71 -0
  24. package/dist/types/sdk/bus/notification-service.d.ts +10 -0
  25. package/dist/types/sdk/bus/slack-daemon.d.ts +27 -0
  26. package/dist/types/sdk/bus/slack-live-provider.d.ts +11 -0
  27. package/dist/types/sdk/bus/slack-provider.d.ts +14 -0
  28. package/dist/types/sdk/bus/slack-thread-binding.d.ts +140 -0
  29. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +2 -0
  30. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +24 -7
  31. package/dist/types/sdk/bus/telegram-daemon.d.ts +104 -16
  32. package/dist/types/sdk/bus/telegram-reference.d.ts +10 -1
  33. package/dist/types/sdk/bus/topic-registry.d.ts +130 -85
  34. package/dist/types/sdk/host/host.d.ts +52 -0
  35. package/dist/types/sdk/lifecycle-session.d.ts +6 -0
  36. package/dist/types/sdk/session-activation.d.ts +41 -0
  37. package/dist/types/sdk/session.d.ts +18 -0
  38. package/dist/types/sdk/startup-capability.d.ts +8 -1
  39. package/dist/types/session/agent-session.d.ts +7 -0
  40. package/dist/types/session/artifacts.d.ts +2 -2
  41. package/dist/types/session/session-manager.d.ts +23 -5
  42. package/dist/types/tools/index.d.ts +8 -0
  43. package/dist/types/utils/npm-registry.d.ts +71 -0
  44. package/examples/extensions/README.md +38 -4
  45. package/examples/extensions/ooo-bridge.ts +15 -0
  46. package/package.json +8 -8
  47. package/scripts/generate-sdk-operation-inventory.ts +4 -0
  48. package/src/async/job-manager.ts +9 -2
  49. package/src/cli/notify-cli.ts +226 -2
  50. package/src/cli/update-cli.ts +90 -23
  51. package/src/commands/notify.ts +27 -3
  52. package/src/commands/sdk.ts +1 -0
  53. package/src/config/file-lock-gc.ts +18 -0
  54. package/src/config/file-lock.ts +61 -16
  55. package/src/config/model-profiles.ts +31 -15
  56. package/src/coordinator/contract.ts +1 -0
  57. package/src/coordinator-mcp/question-state.ts +2 -0
  58. package/src/coordinator-mcp/server.ts +358 -7
  59. package/src/defaults/gjc/skills/ultragoal/SKILL.md +2 -0
  60. package/src/extensibility/extensions/index.ts +1 -0
  61. package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +235 -0
  62. package/src/extensibility/extensions/prefix-command-bridge.ts +4 -9
  63. package/src/extensibility/extensions/runner.ts +15 -2
  64. package/src/extensibility/extensions/types.ts +2 -0
  65. package/src/gjc-runtime/launch-tmux.ts +53 -1
  66. package/src/internal-urls/docs-index.generated.ts +11 -10
  67. package/src/main.ts +203 -67
  68. package/src/modes/DESIGN.md +8 -5
  69. package/src/modes/components/footer.ts +10 -18
  70. package/src/modes/components/session-selector.ts +26 -3
  71. package/src/modes/components/status-line/gh.ts +23 -2
  72. package/src/modes/controllers/event-controller.ts +3 -0
  73. package/src/modes/controllers/extension-ui-controller.ts +2 -0
  74. package/src/modes/controllers/input-controller.ts +11 -0
  75. package/src/modes/controllers/selector-controller.ts +8 -2
  76. package/src/modes/interactive-mode.ts +7 -1
  77. package/src/modes/utils/host-status.ts +38 -0
  78. package/src/runtime-mcp/types.ts +2 -0
  79. package/src/sdk/broker/lifecycle.ts +60 -3
  80. package/src/sdk/bus/chat-daemon-cli.ts +42 -0
  81. package/src/sdk/bus/chat-daemon-command-channel.ts +921 -0
  82. package/src/sdk/bus/chat-daemon-control.ts +16 -3
  83. package/src/sdk/bus/chat-daemon-runtime.ts +257 -21
  84. package/src/sdk/bus/control-plane-frames.ts +51 -0
  85. package/src/sdk/bus/conversation-store.ts +29 -1
  86. package/src/sdk/bus/existing-thread-readiness.ts +155 -0
  87. package/src/sdk/bus/index.ts +70 -0
  88. package/src/sdk/bus/notification-service.ts +28 -1
  89. package/src/sdk/bus/rich-render.ts +3 -2
  90. package/src/sdk/bus/slack-daemon.ts +99 -4
  91. package/src/sdk/bus/slack-live-provider.ts +32 -2
  92. package/src/sdk/bus/slack-provider.ts +11 -0
  93. package/src/sdk/bus/slack-thread-binding.ts +641 -0
  94. package/src/sdk/bus/telegram-daemon-cli.ts +11 -0
  95. package/src/sdk/bus/telegram-daemon-contract.ts +24 -7
  96. package/src/sdk/bus/telegram-daemon.ts +1961 -497
  97. package/src/sdk/bus/telegram-reference.ts +42 -6
  98. package/src/sdk/bus/topic-registry.ts +674 -181
  99. package/src/sdk/host/host.ts +180 -1
  100. package/src/sdk/lifecycle-session.ts +8 -2
  101. package/src/sdk/protocol/operation-inventory.generated.json +22 -0
  102. package/src/sdk/session-activation.ts +127 -0
  103. package/src/sdk/session.ts +139 -28
  104. package/src/sdk/startup-capability.ts +10 -1
  105. package/src/session/agent-session.ts +220 -9
  106. package/src/session/artifacts.ts +56 -13
  107. package/src/session/internal/managed-session-storage.ts +206 -7
  108. package/src/session/session-manager.ts +148 -38
  109. package/src/setup/provider-presets.json +1 -1
  110. package/src/task/index.ts +92 -44
  111. package/src/tools/index.ts +8 -0
  112. package/src/utils/npm-registry.ts +660 -0
@@ -51,11 +51,15 @@ export type ChatDaemonAction = "stop" | "reload";
51
51
  * Discord generation 24 / slack generation 23 apply provider-completeness and
52
52
  * effective-enable admission to chat daemon lifecycle controls. Discord
53
53
  * generation 25 / slack generation 24 apply identity-bound exact replacement
54
- * cleanup shared by managed-session and daemon filesystem authority.
54
+ * cleanup shared by managed-session and daemon filesystem authority. Discord
55
+ * generation 26 / slack generation 25 add the in-place operator command channel:
56
+ * an owner serves per-request commands inside its own serving loop and answers
57
+ * them against an exact owner tuple, so an owner at an earlier generation may
58
+ * not serve or answer a request captured against this contract.
55
59
  */
56
60
  export const CHAT_DAEMON_GENERATIONS: Readonly<Record<ChatDaemonKind, number>> = {
57
- discord: 25,
58
- slack: 24,
61
+ discord: 26,
62
+ slack: 25,
59
63
  };
60
64
 
61
65
  export function chatDaemonGeneration(kind: ChatDaemonKind): number {
@@ -279,6 +283,15 @@ export function chatDaemonPaths(
279
283
  };
280
284
  }
281
285
 
286
+ /**
287
+ * Configuration fingerprint that identifies which settings a daemon owner was
288
+ * started for. `undefined` means the current settings cannot configure that
289
+ * transport at all, so no owner can be authorized against them.
290
+ */
291
+ export function chatDaemonIdentity(settings: Settings, kind: ChatDaemonKind): string | undefined {
292
+ return identityFor(settings, kind);
293
+ }
294
+
282
295
  function identityFor(settings: Settings, kind: ChatDaemonKind): string | undefined {
283
296
  const cfg = getNotificationConfig(settings);
284
297
  if (kind === "discord") {
@@ -4,17 +4,26 @@ import * as path from "node:path";
4
4
  import { type IndexedSession, SessionIndex } from "../broker/session-index";
5
5
  import { SdkClient, SdkClientError } from "../client/client";
6
6
  import { readSdkBrokerDiscovery, readSdkSessionEndpoint, type SdkSessionEndpoint } from "../client/discovery";
7
+ import { SESSION_PREPARED_EVENT } from "../host/host";
7
8
 
8
9
  import { createDiscordAdapter, createSlackAdapter } from "./chat-adapters";
9
10
  import { type ChatTransport, projectChatCommandOutcome, sendAuthorizedChatOperation } from "./chat-command-policy";
11
+ import type { ChatDaemonCommandBindInput, ChatDaemonCommandOutcome } from "./chat-daemon-command-channel";
10
12
  import type { ChatDaemonKind } from "./chat-daemon-control";
13
+ import { isControlPlaneFrameType } from "./control-plane-frames";
11
14
  import { type DiscordEndpointBinding, DiscordEndpointBindingError, DiscordNotificationDaemon } from "./discord-daemon";
12
15
  import { DiscordLiveProvider } from "./discord-live-provider";
13
16
  import type { DiscordProvider } from "./discord-provider";
14
17
  import { type NotificationEvent, NotificationPresentationEngine } from "./engine";
15
- import { type SlackEndpoint, SlackEndpointBindingError, SlackNotificationDaemon } from "./slack-daemon";
18
+ import {
19
+ type SlackBindingAuthority,
20
+ type SlackEndpoint,
21
+ SlackEndpointBindingError,
22
+ SlackNotificationDaemon,
23
+ } from "./slack-daemon";
16
24
  import { SlackLiveProvider } from "./slack-live-provider";
17
25
  import { SlackProvider, type SlackProviderClient } from "./slack-provider";
26
+ import { resolveSessionBindingAuthority, SlackThreadBindingError } from "./slack-thread-binding";
18
27
 
19
28
  export interface ChatDaemonRuntimeConfig {
20
29
  identity: string;
@@ -77,29 +86,187 @@ type AttachedSession = Readonly<{
77
86
  dispose: () => void;
78
87
  }>;
79
88
 
80
- function notificationFrame(frame: Record<string, unknown>): Record<string, unknown> {
81
- const payload = frame.type === "event" ? frame.payload : undefined;
89
+ /**
90
+ * The lifecycle signals that decide whether a chat root exists at all. They are
91
+ * the only event names that close, resume, or withhold a session's publication,
92
+ * so their identity may never be assembled from two disagreeing representations
93
+ * of one frame.
94
+ */
95
+ const LIFECYCLE_EVENT_NAMES: ReadonlySet<string> = new Set([
96
+ SESSION_PREPARED_EVENT,
97
+ "session_ready",
98
+ "session_closed",
99
+ "session_terminated",
100
+ ]);
101
+
102
+ function isLifecycleEvent(name: string | undefined): boolean {
103
+ return name !== undefined && LIFECYCLE_EVENT_NAMES.has(name);
104
+ }
105
+
106
+ /**
107
+ * Names whose presence obliges a frame to mean exactly one thing. Lifecycle
108
+ * signals decide whether a chat root exists at all; control-plane
109
+ * discriminants may never be presented at all. Either identity appearing on
110
+ * only one of a frame's two representations is a smuggling attempt, not an
111
+ * event.
112
+ */
113
+ function isReservedIdentity(name: string | undefined): boolean {
114
+ return isLifecycleEvent(name) || isControlPlaneFrameType(name);
115
+ }
116
+
117
+ /** One delivered frame reduced to a single event identity. */
118
+ type CorrelatedFrame = Readonly<{
119
+ /** The event body a notification is projected from. */
120
+ body: Record<string, unknown>;
121
+ name: string | undefined;
122
+ sessionId: string | undefined;
123
+ generation: number | undefined;
124
+ }>;
125
+
126
+ function eventPayload(frame: Record<string, unknown>): Record<string, unknown> | undefined {
127
+ if (frame.type !== "event") return undefined;
128
+ const payload = frame.payload;
82
129
  return payload && typeof payload === "object" && !Array.isArray(payload)
83
130
  ? (payload as Record<string, unknown>)
84
- : frame;
131
+ : undefined;
85
132
  }
86
133
 
87
- function eventName(frame: Record<string, unknown>, normalized: Record<string, unknown>): string | undefined {
88
- if (frame.type === "event") {
89
- if (typeof frame.name === "string") return frame.name;
90
- if (typeof frame.kind === "string") return frame.kind;
91
- }
92
- return typeof normalized.type === "string" ? normalized.type : undefined;
134
+ function readEventName(value: unknown): string | undefined {
135
+ return typeof value === "string" ? value : undefined;
93
136
  }
94
137
 
95
- function sessionIdFrom(frame: Record<string, unknown>): string | undefined {
96
- return typeof frame.sessionId === "string" && frame.sessionId ? frame.sessionId : undefined;
138
+ function readSessionId(value: unknown): string | undefined {
139
+ return typeof value === "string" && value ? value : undefined;
97
140
  }
98
141
 
99
- function generationFrom(frame: Record<string, unknown>): number | undefined {
100
- return typeof frame.generation === "number" && Number.isSafeInteger(frame.generation) && frame.generation >= 0
101
- ? frame.generation
102
- : undefined;
142
+ function readGeneration(value: unknown): number | undefined {
143
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
144
+ }
145
+
146
+ /**
147
+ * What one representation of a frame states about an identity it owns.
148
+ *
149
+ * `absent` means the representation does not own the property at all. `invalid`
150
+ * means it owns the property while stating something that cannot be that
151
+ * identity — which is not the same thing, because reading an invalid duplicate
152
+ * as absent silently promotes the other representation to sole authority over a
153
+ * frame that stated two.
154
+ */
155
+ type IdentityClaim<T> = Readonly<{ state: "absent" } | { state: "invalid" } | { state: "stated"; value: T }>;
156
+
157
+ const ABSENT_IDENTITY: IdentityClaim<never> = { state: "absent" };
158
+
159
+ /**
160
+ * Read one representation's claim about `key`.
161
+ *
162
+ * Ownership of the property is the claim, never its value: a representation
163
+ * that owns `key` has stated it, so `undefined` is a malformed statement rather
164
+ * than silence. Value equality cannot tell the two apart, and treating an owned
165
+ * `undefined` as absence is exactly what lets a frame state one identity twice
166
+ * while only one of the two is ever checked.
167
+ */
168
+ function identityClaim<T>(
169
+ frame: Record<string, unknown> | undefined,
170
+ key: "sessionId" | "generation" | "name" | "kind",
171
+ read: (value: unknown) => T | undefined,
172
+ ): IdentityClaim<T> {
173
+ if (!frame || !Object.hasOwn(frame, key)) return ABSENT_IDENTITY;
174
+ const value = read(frame[key]);
175
+ return value === undefined ? { state: "invalid" } : { state: "stated", value };
176
+ }
177
+
178
+ /**
179
+ * Reduce one identity stated by both representations of a frame to one value.
180
+ *
181
+ * A duplicated identity is a single authority tuple: when both sides supply it
182
+ * they must both be well-typed and equal, whatever the event's class. A claim
183
+ * that cannot be the identity it names is never reconciled at all — on either
184
+ * side, and whether or not the other side stated anything — because the only
185
+ * alternative is to let the frame proceed under an identity it contradicted. A
186
+ * single-sided identity is read from the side that supplied it, so ordinary
187
+ * wrappers that carry the identity only on the envelope stay compatible.
188
+ */
189
+ function reconcileIdentity<T>(
190
+ envelope: IdentityClaim<T>,
191
+ payload: IdentityClaim<T>,
192
+ ): Readonly<{ ok: true; value: T | undefined } | { ok: false }> {
193
+ if (envelope.state === "invalid" || payload.state === "invalid") return { ok: false };
194
+ if (envelope.state === "absent") return { ok: true, value: payload.state === "stated" ? payload.value : undefined };
195
+ if (payload.state === "absent") return { ok: true, value: envelope.value };
196
+ return envelope.value === payload.value ? { ok: true, value: envelope.value } : { ok: false };
197
+ }
198
+
199
+ /**
200
+ * Reduce the two spellings of one event envelope's identity to a single name.
201
+ *
202
+ * `name` and `kind` are aliases, not two authorities: the host emits ordinary
203
+ * frames as `{ kind: <payload.type>, payload }` and lifecycle signals as
204
+ * `{ name: <lifecycle>, … }`, so a frame that owns only one is read from that
205
+ * one. Owning both obliges them to be well-typed and exactly equal. Preferring
206
+ * either alias would let a benign transport name clear lifecycle and
207
+ * control-plane filtering while the other spelling carries the reserved
208
+ * identity — `control_response`, `session_closed`, `event_replay_result` — that
209
+ * a later step consumes.
210
+ */
211
+ function envelopeEventName(
212
+ frame: Record<string, unknown>,
213
+ ): Readonly<{ ok: true; value: string | undefined } | { ok: false }> {
214
+ if (frame.type !== "event") return { ok: true, value: undefined };
215
+ return reconcileIdentity(identityClaim(frame, "name", readEventName), identityClaim(frame, "kind", readEventName));
216
+ }
217
+
218
+ /**
219
+ * Reduce one delivered frame to a single event identity, or reject it whole.
220
+ *
221
+ * An event envelope and its payload are two representations of one event, never
222
+ * two authorities. The host emits ordinary frames as `{ kind: <payload.type>,
223
+ * payload }` and lifecycle signals unwrapped as `{ name: <lifecycle>,
224
+ * sessionId, generation }`, so a frame that names a different session, a
225
+ * different generation, or a different lifecycle identity in each representation
226
+ * is malformed. Correlating first is what stops the envelope from clearing one
227
+ * filter while the payload supplies the identity a later step consumes.
228
+ *
229
+ * Different semantic layers stay legal: an ordinary transport envelope may name
230
+ * `notification` while its payload carries an unrelated event `type`. A reserved
231
+ * identity — a lifecycle signal or a control-plane discriminant — on either side
232
+ * additionally obliges both sides to agree on the event name, and a duplicated
233
+ * session or generation is read from the single side that supplied it.
234
+ *
235
+ * The envelope's own `name`/`kind` aliases are reduced first, before the payload
236
+ * is projected at all, so a frame whose two spellings disagree is inert ahead of
237
+ * every filter and every mutation rather than after one of them.
238
+ */
239
+ function correlateFrame(frame: Record<string, unknown>): CorrelatedFrame | undefined {
240
+ const envelopeName = envelopeEventName(frame);
241
+ if (!envelopeName.ok) return undefined;
242
+ const payload = eventPayload(frame);
243
+ const body = payload ?? frame;
244
+ const sessionId = reconcileIdentity(
245
+ identityClaim(frame, "sessionId", readSessionId),
246
+ identityClaim(payload, "sessionId", readSessionId),
247
+ );
248
+ if (!sessionId.ok) return undefined;
249
+ const bodyName = typeof body.type === "string" ? body.type : undefined;
250
+ // A reserved marker on either side must be the frame's whole identity: an
251
+ // envelope that says something else is smuggling a lifecycle signal past
252
+ // lifecycle filtering, or a control-plane body past control-plane filtering.
253
+ if (
254
+ payload &&
255
+ envelopeName.value !== bodyName &&
256
+ (isReservedIdentity(envelopeName.value) || isReservedIdentity(bodyName))
257
+ )
258
+ return undefined;
259
+ const generation = reconcileIdentity(
260
+ identityClaim(frame, "generation", readGeneration),
261
+ identityClaim(payload, "generation", readGeneration),
262
+ );
263
+ if (!generation.ok) return undefined;
264
+ return {
265
+ body,
266
+ name: envelopeName.value ?? bodyName,
267
+ sessionId: sessionId.value,
268
+ generation: generation.value,
269
+ };
103
270
  }
104
271
 
105
272
  /**
@@ -197,6 +364,7 @@ export class ChatDaemonRuntime {
197
364
  };
198
365
  },
199
366
  resolveEndpoint: async sessionId => await this.resolveEndpoint(sessionId),
367
+ resolveBindingAuthority: async sessionId => await this.#slackBindingAuthority(sessionId),
200
368
  onCommand: async (sessionId, content, endpoint, idempotencyKey) => {
201
369
  const attached = this.#sessions.get(sessionId);
202
370
  if (
@@ -342,6 +510,54 @@ export class ChatDaemonRuntime {
342
510
  const attached = this.#sessions.get(sessionId);
343
511
  return attached ? { ...attached.endpoint, generation: attached.generation } : null;
344
512
  }
513
+
514
+ /**
515
+ * Exact authority for adopting an existing root: the runtime must currently
516
+ * hold this session's attachment, the index must still list it as live and
517
+ * non-terminal with an intact replay, and its discovery endpoint must be
518
+ * readable, non-stale, and owned by the indexed host pid at the same
519
+ * generation. Re-reading the attachment afterwards rejects a session that
520
+ * detached or rolled while the index and endpoint were being consulted.
521
+ */
522
+ async #slackBindingAuthority(sessionId: string): Promise<SlackBindingAuthority | undefined> {
523
+ const attached = this.#sessions.get(sessionId);
524
+ if (!attached) return undefined;
525
+ const authority = await resolveSessionBindingAuthority({ sessionIndex: this.#index, sessionId });
526
+ if (!authority || authority.endpointGeneration !== attached.generation) return undefined;
527
+ if (this.#sessions.get(sessionId) !== attached) return undefined;
528
+ return { sessionId, endpointGeneration: attached.generation };
529
+ }
530
+
531
+ /**
532
+ * Adopt an operator-supplied Slack root for one attached session. Returns a
533
+ * machine-readable rejection instead of throwing so the daemon control plane
534
+ * can answer without exposing internal failure detail.
535
+ */
536
+ async bindExistingRoot(request: ChatDaemonCommandBindInput): Promise<ChatDaemonCommandOutcome> {
537
+ const slack = this.#slack;
538
+ if (!slack) return { ok: false, certainty: "rejected", code: "target_not_configured" };
539
+ try {
540
+ const bound = await slack.bindExistingRoot(request.sessionId, request.rootTs, request.commitAuthority);
541
+ if (!bound.rootTs || bound.endpointGeneration === undefined)
542
+ return { ok: false, certainty: "rejected", code: "binding_failed" };
543
+ return {
544
+ ok: true,
545
+ sessionId: request.sessionId,
546
+ endpointGeneration: bound.endpointGeneration,
547
+ teamId: bound.teamId,
548
+ channelId: bound.channelId,
549
+ rootTs: bound.rootTs,
550
+ };
551
+ } catch (error) {
552
+ // `binding_outcome_unknown` is the store's typed statement that the
553
+ // mapping may already be applied. It must travel as an indeterminate
554
+ // outcome, never as a rejection the operator could act on.
555
+ const code = error instanceof SlackThreadBindingError ? error.code : "binding_failed";
556
+ return code === "binding_outcome_unknown"
557
+ ? { ok: false, certainty: "unknown", code }
558
+ : { ok: false, certainty: "rejected", code };
559
+ }
560
+ }
345
561
  #discordEndpoint(sessionId: string): DiscordEndpointBinding | null {
346
562
  const attached = this.#sessions.get(sessionId);
347
563
  if (!attached) return null;
@@ -378,18 +594,38 @@ export class ChatDaemonRuntime {
378
594
  }
379
595
  private async handleFrame(attached: AttachedSession, frame: Record<string, unknown>): Promise<void> {
380
596
  if (this.#sessions.get(attached.sessionId) !== attached) return;
381
- const normalizedFrame = notificationFrame(frame);
597
+ // Correlate before anything is acted on. A frame whose envelope and payload
598
+ // disagree on session, lifecycle identity, or lifecycle generation is not a
599
+ // usable event, so it is dropped whole — no close, resume, notify, resolve,
600
+ // root, or mapping mutation — on the replay path and the live path alike.
601
+ const correlated = correlateFrame(frame);
602
+ if (!correlated) return;
603
+ const normalizedFrame = correlated.body;
604
+ // The SDK's own request/response traffic arrives on this same observer:
605
+ // `SdkClient` settles a pending request and still forwards that frame to
606
+ // every handler. A protocol answer carries no user-visible content, so it
607
+ // is dropped here — ahead of presentation fanout and of every root,
608
+ // mapping, resume, close, and action mutation — on both delivery paths.
609
+ // Its nested payload is discarded with it and never reprojected.
610
+ const bodyType = typeof normalizedFrame.type === "string" ? normalizedFrame.type : undefined;
611
+ if (isControlPlaneFrameType(correlated.name) || isControlPlaneFrameType(bodyType)) return;
382
612
  if (normalizedFrame.type === "turn_stream" && normalizedFrame.phase === "live") return;
383
- const frameSessionId = sessionIdFrom(normalizedFrame) ?? sessionIdFrom(frame);
384
- if (frameSessionId !== undefined && frameSessionId !== attached.sessionId) return;
613
+ if (correlated.sessionId !== undefined && correlated.sessionId !== attached.sessionId) return;
385
614
  const sessionId = attached.sessionId;
386
- const name = eventName(frame, normalizedFrame);
615
+ const name = correlated.name;
387
616
  if (name === "session_closed" || name === "session_terminated") {
388
617
  await this.close(sessionId);
389
618
  return;
390
619
  }
620
+ // `session_prepared` is control-plane evidence only: the session holds
621
+ // endpoint authority while deliberately withholding readiness. It carries no
622
+ // user-visible content and must never create or adopt a root, notify, or
623
+ // resume — at this attachment's generation or any other. Foreign session ids
624
+ // are already rejected above, so every prepared frame is inert here, and a
625
+ // stale or foreign one cannot mutate state either.
626
+ if (name === SESSION_PREPARED_EVENT || bodyType === SESSION_PREPARED_EVENT) return;
391
627
  if (name === "session_ready") {
392
- if (generationFrom(frame) !== attached.generation) return;
628
+ if (correlated.generation !== attached.generation) return;
393
629
  await this.resume(sessionId, attached.generation, "GJC session ready.");
394
630
  return;
395
631
  }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Typed control-plane discriminants that must never reach chat presentation.
3
+ *
4
+ * A chat daemon observes one session socket for user-visible events, but that
5
+ * socket also carries the SDK's own request/response traffic: `SdkClient`
6
+ * settles a pending request from an inbound frame and still forwards the very
7
+ * same frame to every `onFrame` observer. A runtime that projects unknown frame
8
+ * types into a generic notification therefore renders protocol answers — most
9
+ * visibly `GJC event replay result` — as chat content, and a chat transport
10
+ * with no mapping for that session publishes it as a brand-new root.
11
+ *
12
+ * Membership is exact, typed, and closed. It is never inferred from a `_result`
13
+ * or `_response` suffix: ordinary public frames are free to be named anything,
14
+ * and a fuzzy rule would silently swallow real user content. Every entry below
15
+ * names the source authority that emits it and the code path that proves it can
16
+ * be delivered to a chat daemon's frame observer.
17
+ *
18
+ * In scope (proven reachable at the chat-daemon session-client seam):
19
+ * - `event_replay_result` — `SessionSdkHost` (`sdk/host/host.ts`) answers the
20
+ * `event_replay` request that `ChatDaemonRuntime.attach()` issues on every
21
+ * attachment, so every attached session receives exactly this frame.
22
+ * - `control_response` — the same host's answer to the `control_request` that
23
+ * `ChatDaemonRuntime` sends for an authorized `/sdk control …` command, and
24
+ * the endpoint-stale refusal the session runtime writes for a fenced
25
+ * connection.
26
+ * - `query_response` — the host's answer to the matching `query_request`, and
27
+ * the same endpoint-stale refusal path.
28
+ * - `hello` — `ServerMessage::Hello` from the native session server
29
+ * (`crates/gjc-sdk/src/protocol.rs`), sent to every connection at accept time
30
+ * and re-observed by frame handlers whenever the client reconnects and
31
+ * adopts a new connection id.
32
+ *
33
+ * Deliberately out of scope — typed server→client frames that exist but are
34
+ * unicast only to a connection that issued a request a chat daemon never
35
+ * sends, so no source path delivers them here: `session_activate_result`,
36
+ * `register_provider_result`, `lease_state`, `reverse_response`,
37
+ * `reverse_request`, `reverse_cancel`, `global_response`, `pong`, and
38
+ * `transport_error`. Add one only together with the source path that proves it
39
+ * reaches this seam.
40
+ */
41
+ export const CONTROL_PLANE_FRAME_TYPES: ReadonlySet<string> = new Set([
42
+ "event_replay_result",
43
+ "control_response",
44
+ "query_response",
45
+ "hello",
46
+ ]);
47
+
48
+ /** Whether an exact frame/event discriminant is protocol-only control plane. */
49
+ export function isControlPlaneFrameType(name: string | undefined): boolean {
50
+ return name !== undefined && CONTROL_PLANE_FRAME_TYPES.has(name);
51
+ }
@@ -164,10 +164,38 @@ export class ConversationStore<T extends ConversationRecord> {
164
164
 
165
165
  /** Apply a synchronous update under the mapping lock, retrying no stale state. */
166
166
  async transact(key: string, update: (current: T | undefined) => T | undefined): Promise<T | undefined> {
167
+ return await this.transactWithSnapshot(key, current => update(current));
168
+ }
169
+
170
+ /**
171
+ * Apply one key update while atomically observing every mapping in the same
172
+ * store snapshot. The observation and the write share the cross-process file
173
+ * lock, so a caller can enforce uniqueness constraints that span keys.
174
+ *
175
+ * The update may be asynchronous so a caller can re-prove local authority
176
+ * (process, endpoint, or session state) inside the lock, immediately before
177
+ * its commit; returning `current` unchanged aborts without any mutation. It
178
+ * must never perform a remote/network call while holding the lock: the lock
179
+ * is a short fence, not a place to wait on a provider.
180
+ *
181
+ * Authority proven by the update is the last proof before the atomic
182
+ * replacement. Authority that rolls after it is an ordinary lifecycle event:
183
+ * the mapping records the endpoint generation it was proven under, so a newer
184
+ * generation fences it through the same monotonic compare-and-swap every
185
+ * other writer uses.
186
+ */
187
+ async transactWithSnapshot(
188
+ key: string,
189
+ update: (
190
+ current: T | undefined,
191
+ conversations: Readonly<Record<string, T>>,
192
+ ) => T | undefined | Promise<T | undefined>,
193
+ ): Promise<T | undefined> {
167
194
  return this.#withLock(async () => {
168
195
  const document = await this.#readDocument();
169
196
  const current = document.conversations[key];
170
- const next = update(current);
197
+ const snapshot: Readonly<Record<string, T>> = Object.freeze({ ...document.conversations });
198
+ const next = await update(current, snapshot);
171
199
  if (!next || next === current) return current;
172
200
  const expectedGeneration = current?.generation;
173
201
  const expectedNext = (expectedGeneration ?? 0) + 1;
@@ -0,0 +1,155 @@
1
+ /**
2
+ * The prepare → bind → activate lifecycle for adopting an existing chat root.
3
+ *
4
+ * Stock startup publishes a session's readiness immediately, so the running
5
+ * chat daemon surfaces the session and creates its own root before an operator
6
+ * could ever name an existing one. A session that opts in is *prepared*
7
+ * instead: its id, endpoint, and broker registration are discoverable authority
8
+ * while readiness stays withheld, which leaves exactly one root claim for
9
+ * `gjc notify bind-thread` to fill. Activation then publishes readiness once,
10
+ * and the daemon adopts the bound root instead of replacing it.
11
+ *
12
+ * Nothing here mutates a mapping. The gate only reads the daemon-owned store to
13
+ * prove a binding exists, and the activation client only asks the session's own
14
+ * host to publish the readiness it withheld.
15
+ */
16
+
17
+ import type { SessionIndex } from "../broker/session-index";
18
+ import { SdkClient } from "../client/client";
19
+ import type { SdkSessionEndpoint, SdkSessionEndpointScope } from "../client/discovery";
20
+ import type { SessionActivationGate } from "../host";
21
+ import {
22
+ type ActivatedPreparedSession,
23
+ type PreparedSessionActivationClient,
24
+ requestPreparedSessionActivation,
25
+ SessionActivationError,
26
+ } from "../session-activation";
27
+ import type { ConversationStore } from "./conversation-store";
28
+ import type { SlackConversation } from "./slack-conversation";
29
+ import { resolveSessionBindingAuthority } from "./slack-thread-binding";
30
+
31
+ /** Explicit, session-scoped opt-in for withholding readiness until a root is bound. */
32
+ export const EXISTING_THREAD_BIND_ENV = "GJC_NOTIFY_BIND_EXISTING_THREAD";
33
+
34
+ /**
35
+ * Existing-thread binding is opt-in, and only the exact value `1` opts in.
36
+ *
37
+ * Every other value — absent, empty, `0`, or anything truthy-looking — keeps
38
+ * the stock immediate-readiness contract, so no session silently loses its
39
+ * root publication because of an ambiguous environment.
40
+ */
41
+ export function isExistingThreadBindingRequested(env: NodeJS.ProcessEnv = process.env): boolean {
42
+ return env[EXISTING_THREAD_BIND_ENV] === "1";
43
+ }
44
+
45
+ export interface SlackBindingActivationInput {
46
+ store: ConversationStore<SlackConversation>;
47
+ teamId: string;
48
+ channelId: string;
49
+ }
50
+
51
+ /**
52
+ * Authorize activation only against an applied binding for this exact session.
53
+ *
54
+ * The proof is the daemon-owned mapping itself: an active record for this
55
+ * session, in the configured workspace and channel, carrying a root and the
56
+ * exact endpoint generation being activated. A missing, foreign, or
57
+ * stale-generation mapping is a refusal, and an unreadable store raises so the
58
+ * caller reports unavailable authority instead of an authorization.
59
+ */
60
+ export function createSlackBindingActivationGate(input: SlackBindingActivationInput): SessionActivationGate {
61
+ return async ({ sessionId, generation }) => {
62
+ const document = await input.store.load();
63
+ return Object.values(document.conversations).some(
64
+ record =>
65
+ record.state === "active" &&
66
+ record.sessionId === sessionId &&
67
+ record.teamId === input.teamId &&
68
+ record.channelId === input.channelId &&
69
+ typeof record.rootTs === "string" &&
70
+ record.rootTs.length > 0 &&
71
+ record.endpointGeneration === generation,
72
+ );
73
+ };
74
+ }
75
+
76
+ export type {
77
+ ActivatedPreparedSession,
78
+ PreparedSessionActivationClient,
79
+ SessionActivationErrorCode,
80
+ } from "../session-activation";
81
+ export { requestPreparedSessionActivation, SessionActivationError } from "../session-activation";
82
+
83
+ export interface PreparedSessionActivationInput {
84
+ sessionIndex: SessionIndex;
85
+ sessionId: string;
86
+ }
87
+
88
+ export interface PreparedSessionActivationDeps {
89
+ readEndpoint?: (
90
+ repo: string,
91
+ sessionId: string,
92
+ scope?: SdkSessionEndpointScope,
93
+ ) => Promise<SdkSessionEndpoint | null>;
94
+ connect?: (endpoint: { url: string; token: string }) => Promise<PreparedSessionActivationClient>;
95
+ }
96
+
97
+ /**
98
+ * Ask a prepared session to publish the readiness it withheld.
99
+ *
100
+ * The same discovery authority the binding requires is resolved first, so an
101
+ * unindexed, terminated, or endpoint-rolled session is refused before any
102
+ * connection is opened. The request names the exact session and endpoint
103
+ * generation, and the answer is accepted only when it proves both, so a
104
+ * replacement session can never be activated in place of the requested one.
105
+ * Activation is idempotent: an exact retry answers `already` without a second
106
+ * readiness signal.
107
+ */
108
+ export async function activatePreparedSession(
109
+ input: PreparedSessionActivationInput,
110
+ deps: PreparedSessionActivationDeps = {},
111
+ ): Promise<ActivatedPreparedSession> {
112
+ const authority = await resolveSessionBindingAuthority({
113
+ sessionIndex: input.sessionIndex,
114
+ sessionId: input.sessionId,
115
+ ...(deps.readEndpoint ? { readEndpoint: deps.readEndpoint } : {}),
116
+ });
117
+ if (!authority)
118
+ throw new SessionActivationError(
119
+ "session_not_live",
120
+ "Session activation requires an exact live session endpoint.",
121
+ );
122
+ // Deliberately not a second discovery read. The authority proof is bound to
123
+ // one endpoint file, verified by pid and mtime at one scope; re-resolving here
124
+ // could connect to a replacement process whose endpoint generation repeats,
125
+ // and the generation carried below could not tell the two apart.
126
+ const endpoint = authority.endpoint;
127
+ if (!endpoint.url || !endpoint.token)
128
+ throw new SessionActivationError(
129
+ "session_not_live",
130
+ "Session activation requires a readable session discovery endpoint.",
131
+ );
132
+ let client: PreparedSessionActivationClient;
133
+ try {
134
+ client = await (deps.connect ?? defaultActivationClient)({ url: endpoint.url, token: endpoint.token });
135
+ } catch {
136
+ // Nothing was ever sent, so no activation can have been applied.
137
+ throw new SessionActivationError("activation_unavailable", "The session endpoint could not be reached.");
138
+ }
139
+ try {
140
+ return await requestPreparedSessionActivation(client, input.sessionId, authority.endpointGeneration);
141
+ } finally {
142
+ await client.close().catch(() => undefined);
143
+ }
144
+ }
145
+
146
+ async function defaultActivationClient(endpoint: {
147
+ url: string;
148
+ token: string;
149
+ }): Promise<PreparedSessionActivationClient> {
150
+ const client = await SdkClient.connect(endpoint.url, endpoint.token, { reconnectAttempts: 0 });
151
+ return {
152
+ request: async frame => (await client.request(frame)) as Record<string, unknown>,
153
+ close: async () => await client.close(),
154
+ };
155
+ }