@gajae-code/coding-agent 0.12.10 → 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 +47 -2
- 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 +13 -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 +25 -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/internal/managed-session-storage.d.ts +12 -2
- package/dist/types/session/session-manager.d.ts +40 -5
- package/dist/types/session/session-storage.d.ts +1 -0
- package/dist/types/tools/computer.d.ts +35 -2
- package/dist/types/tools/index.d.ts +8 -0
- package/dist/types/tools/todo-write.d.ts +2 -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 +13 -12
- 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/prompts/tools/computer.md +6 -1
- 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 +18 -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 +25 -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 +331 -30
- package/src/session/artifacts.ts +56 -13
- package/src/session/internal/managed-session-storage.ts +440 -243
- package/src/session/session-manager.ts +288 -44
- package/src/session/session-storage.ts +3 -0
- package/src/setup/provider-presets.json +1 -1
- package/src/task/index.ts +92 -44
- package/src/tools/computer.ts +322 -45
- package/src/tools/index.ts +8 -0
- package/src/tools/todo-write.ts +29 -11
- 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.
|