@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.
- package/CHANGELOG.md +38 -0
- package/dist/types/cli/notify-cli.d.ts +42 -1
- package/dist/types/cli/update-cli.d.ts +9 -3
- package/dist/types/commands/notify.d.ts +6 -0
- package/dist/types/config/file-lock.d.ts +3 -0
- package/dist/types/coordinator/contract.d.ts +1 -1
- package/dist/types/coordinator-mcp/question-state.d.ts +3 -1
- package/dist/types/extensibility/extensions/index.d.ts +1 -0
- package/dist/types/extensibility/extensions/ouroboros-ooo-bridge.d.ts +13 -0
- package/dist/types/extensibility/extensions/prefix-command-bridge.d.ts +0 -1
- package/dist/types/extensibility/extensions/types.d.ts +2 -0
- package/dist/types/main.d.ts +6 -2
- package/dist/types/modes/components/session-selector.d.ts +1 -1
- package/dist/types/modes/utils/host-status.d.ts +23 -0
- package/dist/types/runtime-mcp/types.d.ts +2 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +12 -0
- package/dist/types/sdk/bus/chat-daemon-cli.d.ts +3 -0
- package/dist/types/sdk/bus/chat-daemon-command-channel.d.ts +244 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +11 -1
- package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +7 -0
- package/dist/types/sdk/bus/control-plane-frames.d.ts +43 -0
- package/dist/types/sdk/bus/conversation-store.d.ts +18 -0
- package/dist/types/sdk/bus/existing-thread-readiness.d.ts +71 -0
- package/dist/types/sdk/bus/notification-service.d.ts +10 -0
- package/dist/types/sdk/bus/slack-daemon.d.ts +27 -0
- package/dist/types/sdk/bus/slack-live-provider.d.ts +11 -0
- package/dist/types/sdk/bus/slack-provider.d.ts +14 -0
- package/dist/types/sdk/bus/slack-thread-binding.d.ts +140 -0
- package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +2 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +24 -7
- package/dist/types/sdk/bus/telegram-daemon.d.ts +104 -16
- package/dist/types/sdk/bus/telegram-reference.d.ts +10 -1
- package/dist/types/sdk/bus/topic-registry.d.ts +130 -85
- package/dist/types/sdk/host/host.d.ts +52 -0
- package/dist/types/sdk/lifecycle-session.d.ts +6 -0
- package/dist/types/sdk/session-activation.d.ts +41 -0
- package/dist/types/sdk/session.d.ts +18 -0
- package/dist/types/sdk/startup-capability.d.ts +8 -1
- package/dist/types/session/agent-session.d.ts +7 -0
- package/dist/types/session/artifacts.d.ts +2 -2
- package/dist/types/session/session-manager.d.ts +23 -5
- package/dist/types/tools/index.d.ts +8 -0
- package/dist/types/utils/npm-registry.d.ts +71 -0
- package/examples/extensions/README.md +38 -4
- package/examples/extensions/ooo-bridge.ts +15 -0
- package/package.json +8 -8
- package/scripts/generate-sdk-operation-inventory.ts +4 -0
- package/src/async/job-manager.ts +9 -2
- package/src/cli/notify-cli.ts +226 -2
- package/src/cli/update-cli.ts +90 -23
- package/src/commands/notify.ts +27 -3
- package/src/commands/sdk.ts +1 -0
- package/src/config/file-lock-gc.ts +18 -0
- package/src/config/file-lock.ts +61 -16
- package/src/config/model-profiles.ts +31 -15
- package/src/coordinator/contract.ts +1 -0
- package/src/coordinator-mcp/question-state.ts +2 -0
- package/src/coordinator-mcp/server.ts +358 -7
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +2 -0
- package/src/extensibility/extensions/index.ts +1 -0
- package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +235 -0
- package/src/extensibility/extensions/prefix-command-bridge.ts +4 -9
- package/src/extensibility/extensions/runner.ts +15 -2
- package/src/extensibility/extensions/types.ts +2 -0
- package/src/gjc-runtime/launch-tmux.ts +53 -1
- package/src/internal-urls/docs-index.generated.ts +11 -10
- package/src/main.ts +203 -67
- package/src/modes/DESIGN.md +8 -5
- package/src/modes/components/footer.ts +10 -18
- package/src/modes/components/session-selector.ts +26 -3
- package/src/modes/components/status-line/gh.ts +23 -2
- package/src/modes/controllers/event-controller.ts +3 -0
- package/src/modes/controllers/extension-ui-controller.ts +2 -0
- package/src/modes/controllers/input-controller.ts +11 -0
- package/src/modes/controllers/selector-controller.ts +8 -2
- package/src/modes/interactive-mode.ts +7 -1
- package/src/modes/utils/host-status.ts +38 -0
- package/src/runtime-mcp/types.ts +2 -0
- package/src/sdk/broker/lifecycle.ts +60 -3
- package/src/sdk/bus/chat-daemon-cli.ts +42 -0
- package/src/sdk/bus/chat-daemon-command-channel.ts +921 -0
- package/src/sdk/bus/chat-daemon-control.ts +16 -3
- package/src/sdk/bus/chat-daemon-runtime.ts +257 -21
- package/src/sdk/bus/control-plane-frames.ts +51 -0
- package/src/sdk/bus/conversation-store.ts +29 -1
- package/src/sdk/bus/existing-thread-readiness.ts +155 -0
- package/src/sdk/bus/index.ts +70 -0
- package/src/sdk/bus/notification-service.ts +28 -1
- package/src/sdk/bus/rich-render.ts +3 -2
- package/src/sdk/bus/slack-daemon.ts +99 -4
- package/src/sdk/bus/slack-live-provider.ts +32 -2
- package/src/sdk/bus/slack-provider.ts +11 -0
- package/src/sdk/bus/slack-thread-binding.ts +641 -0
- package/src/sdk/bus/telegram-daemon-cli.ts +11 -0
- package/src/sdk/bus/telegram-daemon-contract.ts +24 -7
- package/src/sdk/bus/telegram-daemon.ts +1961 -497
- package/src/sdk/bus/telegram-reference.ts +42 -6
- package/src/sdk/bus/topic-registry.ts +674 -181
- package/src/sdk/host/host.ts +180 -1
- package/src/sdk/lifecycle-session.ts +8 -2
- package/src/sdk/protocol/operation-inventory.generated.json +22 -0
- package/src/sdk/session-activation.ts +127 -0
- package/src/sdk/session.ts +139 -28
- package/src/sdk/startup-capability.ts +10 -1
- package/src/session/agent-session.ts +220 -9
- package/src/session/artifacts.ts +56 -13
- package/src/session/internal/managed-session-storage.ts +206 -7
- package/src/session/session-manager.ts +148 -38
- package/src/setup/provider-presets.json +1 -1
- package/src/task/index.ts +92 -44
- package/src/tools/index.ts +8 -0
- 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:
|
|
58
|
-
slack:
|
|
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 {
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
:
|
|
131
|
+
: undefined;
|
|
85
132
|
}
|
|
86
133
|
|
|
87
|
-
function
|
|
88
|
-
|
|
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
|
|
96
|
-
return typeof
|
|
138
|
+
function readSessionId(value: unknown): string | undefined {
|
|
139
|
+
return typeof value === "string" && value ? value : undefined;
|
|
97
140
|
}
|
|
98
141
|
|
|
99
|
-
function
|
|
100
|
-
return typeof
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 (
|
|
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
|
|
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
|
+
}
|