@gajae-code/coding-agent 0.7.3 → 0.7.5

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 (118) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/bin/gjc.js +4 -0
  3. package/dist/types/cli/plugin-cli.d.ts +2 -0
  4. package/dist/types/commands/plugin.d.ts +6 -0
  5. package/dist/types/commands/session.d.ts +6 -0
  6. package/dist/types/config/model-profile-activation.d.ts +8 -1
  7. package/dist/types/extensibility/gjc-plugins/compiler.d.ts +19 -0
  8. package/dist/types/extensibility/gjc-plugins/constrained-hooks.d.ts +29 -0
  9. package/dist/types/extensibility/gjc-plugins/index.d.ts +9 -0
  10. package/dist/types/extensibility/gjc-plugins/injection.d.ts +9 -0
  11. package/dist/types/extensibility/gjc-plugins/installer.d.ts +13 -0
  12. package/dist/types/extensibility/gjc-plugins/mcp-policy.d.ts +26 -0
  13. package/dist/types/extensibility/gjc-plugins/observability.d.ts +27 -0
  14. package/dist/types/extensibility/gjc-plugins/prompt-appendix.d.ts +16 -0
  15. package/dist/types/extensibility/gjc-plugins/registry.d.ts +32 -0
  16. package/dist/types/extensibility/gjc-plugins/runtime-adapters.d.ts +64 -0
  17. package/dist/types/extensibility/gjc-plugins/session-validation.d.ts +42 -0
  18. package/dist/types/extensibility/gjc-plugins/types.d.ts +158 -2
  19. package/dist/types/extensibility/gjc-plugins/validation.d.ts +8 -1
  20. package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
  21. package/dist/types/gjc-runtime/psmux-detect.d.ts +78 -0
  22. package/dist/types/gjc-runtime/team-runtime.d.ts +2 -0
  23. package/dist/types/gjc-runtime/tmux-common.d.ts +30 -2
  24. package/dist/types/gjc-runtime/tmux-sessions.d.ts +18 -0
  25. package/dist/types/main.d.ts +2 -0
  26. package/dist/types/modes/components/model-selector.d.ts +6 -0
  27. package/dist/types/notifications/html-format.d.ts +11 -0
  28. package/dist/types/notifications/index.d.ts +149 -1
  29. package/dist/types/notifications/lifecycle-commands.d.ts +72 -0
  30. package/dist/types/notifications/lifecycle-control-runtime.d.ts +98 -0
  31. package/dist/types/notifications/lifecycle-orchestrator.d.ts +144 -0
  32. package/dist/types/notifications/rate-limit-pool.d.ts +2 -0
  33. package/dist/types/notifications/recent-activity.d.ts +35 -0
  34. package/dist/types/notifications/telegram-daemon.d.ts +60 -0
  35. package/dist/types/notifications/telegram-reference.d.ts +3 -1
  36. package/dist/types/notifications/topic-registry.d.ts +10 -9
  37. package/dist/types/runtime-mcp/types.d.ts +7 -0
  38. package/dist/types/sdk.d.ts +2 -0
  39. package/dist/types/session/agent-session.d.ts +14 -4
  40. package/dist/types/session/blob-store.d.ts +25 -0
  41. package/dist/types/session/session-manager.d.ts +57 -0
  42. package/dist/types/slash-commands/helpers/fast-status-report.d.ts +6 -0
  43. package/dist/types/system-prompt.d.ts +2 -0
  44. package/dist/types/task/executor.d.ts +9 -1
  45. package/dist/types/tools/index.d.ts +3 -1
  46. package/dist/types/utils/changelog.d.ts +1 -0
  47. package/package.json +11 -9
  48. package/scripts/g004-tmux-smoke.ts +100 -0
  49. package/scripts/g005-daemon-smoke.ts +181 -0
  50. package/scripts/g011-daemon-path-smoke.ts +153 -0
  51. package/src/cli/plugin-cli.ts +66 -3
  52. package/src/cli.ts +21 -4
  53. package/src/commands/plugin.ts +4 -0
  54. package/src/commands/session.ts +18 -0
  55. package/src/config/model-profile-activation.ts +55 -7
  56. package/src/defaults/gjc/extensions/grok-cli-vendor/biome.json +1 -1
  57. package/src/defaults/gjc/skills/deep-interview/SKILL.md +3 -3
  58. package/src/defaults/gjc/skills/team/SKILL.md +5 -4
  59. package/src/defaults/gjc/skills/ultragoal/SKILL.md +41 -13
  60. package/src/export/html/index.ts +2 -2
  61. package/src/extensibility/gjc-plugins/compiler.ts +351 -0
  62. package/src/extensibility/gjc-plugins/constrained-hooks.ts +170 -0
  63. package/src/extensibility/gjc-plugins/index.ts +9 -0
  64. package/src/extensibility/gjc-plugins/injection.ts +109 -0
  65. package/src/extensibility/gjc-plugins/installer.ts +434 -0
  66. package/src/extensibility/gjc-plugins/loader.ts +3 -1
  67. package/src/extensibility/gjc-plugins/mcp-policy.ts +239 -0
  68. package/src/extensibility/gjc-plugins/observability.ts +84 -0
  69. package/src/extensibility/gjc-plugins/paths.ts +1 -1
  70. package/src/extensibility/gjc-plugins/prompt-appendix.ts +109 -0
  71. package/src/extensibility/gjc-plugins/registry.ts +180 -0
  72. package/src/extensibility/gjc-plugins/runtime-adapters.ts +234 -0
  73. package/src/extensibility/gjc-plugins/schema.ts +250 -20
  74. package/src/extensibility/gjc-plugins/session-validation.ts +147 -0
  75. package/src/extensibility/gjc-plugins/types.ts +199 -3
  76. package/src/extensibility/gjc-plugins/validation.ts +80 -0
  77. package/src/extensibility/skills.ts +15 -0
  78. package/src/gjc-runtime/launch-tmux.ts +58 -15
  79. package/src/gjc-runtime/psmux-detect.ts +239 -0
  80. package/src/gjc-runtime/team-runtime.ts +56 -23
  81. package/src/gjc-runtime/tmux-common.ts +85 -3
  82. package/src/gjc-runtime/tmux-sessions.ts +111 -9
  83. package/src/gjc-runtime/ultragoal-runtime.ts +75 -15
  84. package/src/internal-urls/docs-index.generated.ts +5 -4
  85. package/src/main.ts +14 -3
  86. package/src/modes/components/assistant-message.ts +49 -1
  87. package/src/modes/components/hook-editor.ts +1 -1
  88. package/src/modes/components/hook-selector.ts +67 -43
  89. package/src/modes/components/model-selector.ts +44 -11
  90. package/src/modes/controllers/extension-ui-controller.ts +0 -27
  91. package/src/modes/controllers/selector-controller.ts +50 -11
  92. package/src/modes/interactive-mode.ts +2 -0
  93. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  94. package/src/notifications/html-format.ts +38 -0
  95. package/src/notifications/index.ts +242 -12
  96. package/src/notifications/lifecycle-commands.ts +228 -0
  97. package/src/notifications/lifecycle-control-runtime.ts +400 -0
  98. package/src/notifications/lifecycle-orchestrator.ts +358 -0
  99. package/src/notifications/rate-limit-pool.ts +19 -0
  100. package/src/notifications/recent-activity.ts +132 -0
  101. package/src/notifications/telegram-daemon.ts +433 -8
  102. package/src/notifications/telegram-reference.ts +25 -7
  103. package/src/notifications/topic-registry.ts +18 -9
  104. package/src/prompts/agents/executor.md +2 -2
  105. package/src/runtime-mcp/transports/stdio.ts +38 -4
  106. package/src/runtime-mcp/types.ts +7 -0
  107. package/src/sdk.ts +157 -10
  108. package/src/session/agent-session.ts +166 -74
  109. package/src/session/blob-store.ts +196 -8
  110. package/src/session/session-manager.ts +739 -12
  111. package/src/slash-commands/builtin-registry.ts +23 -3
  112. package/src/slash-commands/helpers/fast-status-report.ts +13 -3
  113. package/src/system-prompt.ts +9 -0
  114. package/src/task/executor.ts +31 -7
  115. package/src/task/index.ts +2 -0
  116. package/src/tools/ask.ts +5 -1
  117. package/src/tools/index.ts +3 -1
  118. package/src/utils/changelog.ts +8 -0
@@ -0,0 +1,400 @@
1
+ /**
2
+ * Wires the authenticated Rust control endpoint (NotificationControlServer) to
3
+ * the lifecycle orchestrator with REAL daemon-side effects: a daemon-safe tmux
4
+ * launcher (create / cold-restart), force-close, and reattach-or-cold-restart
5
+ * resume. Kept separate from telegram-daemon.ts so the effects + wiring are
6
+ * unit-testable; the daemon calls {@link attachLifecycleControl} once it owns
7
+ * the control server.
8
+ */
9
+ import * as crypto from "node:crypto";
10
+ import * as fs from "node:fs";
11
+ import * as path from "node:path";
12
+
13
+ import {
14
+ buildGjcTmuxExactOptionTarget,
15
+ buildGjcTmuxProfileCommands,
16
+ resolveGjcTmuxCommand,
17
+ } from "../gjc-runtime/tmux-common";
18
+ import {
19
+ findGjcTmuxSessionByName,
20
+ forceCloseGjcTmuxSession,
21
+ listGjcTmuxSessions,
22
+ statusGjcTmuxSession,
23
+ } from "../gjc-runtime/tmux-sessions";
24
+ import type { ResumeCandidate, SessionCreateFrame, SessionLifecycleRequest, SessionLifecycleResponse } from "./index";
25
+ import {
26
+ type AuditEvent,
27
+ type CreateEffectResult,
28
+ handleLifecycleRequest,
29
+ type LedgerDoc,
30
+ type LedgerStore,
31
+ type LifecycleOutcome,
32
+ type OrchestratorDeps,
33
+ type ResumeEffectResult,
34
+ } from "./lifecycle-orchestrator";
35
+ import { listRecentSessions } from "./recent-activity";
36
+
37
+ /** Minimal view of the native control server this runtime depends on. */
38
+ export interface ControlServerLike {
39
+ onLifecycleRequest(
40
+ cb: (err: Error | null, req: { kind: string; requestId: string; payloadJson: string }) => void,
41
+ ): void;
42
+ respond(responseJson: string): void;
43
+ }
44
+
45
+ /**
46
+ * A startable control server (the native NotificationControlServer, or a fake in
47
+ * tests). Extends {@link ControlServerLike} with the start/stop lifecycle the
48
+ * daemon owns.
49
+ */
50
+ export interface LifecycleControlServer extends ControlServerLike {
51
+ start(): Promise<unknown>;
52
+ stop(): void;
53
+ }
54
+
55
+ /** Factory the daemon uses to construct a control server bound to its ownership. */
56
+ export type LifecycleControlServerFactory = (input: {
57
+ token: string;
58
+ ownerId: string;
59
+ agentDir: string;
60
+ }) => LifecycleControlServer;
61
+
62
+ /** Atomic + fsynced file-backed idempotency ledger store. */
63
+ export function fileLedgerStore(idempotencyFile: string): LedgerStore {
64
+ return {
65
+ async read(): Promise<LedgerDoc> {
66
+ try {
67
+ return JSON.parse(fs.readFileSync(idempotencyFile, "utf8")) as LedgerDoc;
68
+ } catch {
69
+ return { version: 1, entries: {} };
70
+ }
71
+ },
72
+ async write(doc: LedgerDoc): Promise<void> {
73
+ fs.mkdirSync(path.dirname(idempotencyFile), { recursive: true });
74
+ const tmp = `${idempotencyFile}.${process.pid}.${Date.now()}.tmp`;
75
+ const fd = fs.openSync(tmp, "w", 0o600);
76
+ fs.writeSync(fd, JSON.stringify(doc));
77
+ fs.fsyncSync(fd);
78
+ fs.closeSync(fd);
79
+ fs.renameSync(tmp, idempotencyFile);
80
+ // fsync the parent directory so the rename itself is durable across a
81
+ // crash / power loss (the temp-file fsync alone does not persist the
82
+ // directory entry).
83
+ try {
84
+ const dirFd = fs.openSync(path.dirname(idempotencyFile), "r");
85
+ try {
86
+ fs.fsyncSync(dirFd);
87
+ } finally {
88
+ fs.closeSync(dirFd);
89
+ }
90
+ } catch {
91
+ // Some platforms reject directory fsync; the rename is still atomic.
92
+ }
93
+ },
94
+ };
95
+ }
96
+
97
+ /** Append-only JSONL audit sink (0600). Never receives tokens or raw prompts. */
98
+ export function fileAudit(auditPath: string): (e: AuditEvent) => void {
99
+ return (e: AuditEvent) => {
100
+ fs.mkdirSync(path.dirname(auditPath), { recursive: true });
101
+ fs.appendFileSync(auditPath, `${JSON.stringify(e)}\n`, { mode: 0o600 });
102
+ };
103
+ }
104
+
105
+ /** Simple per-chat sliding-window create rate limiter. */
106
+ export function createRateLimiter(maxPerWindow: number, windowMs: number): (chatId: string, nowMs: number) => boolean {
107
+ const hits = new Map<string, number[]>();
108
+ return (chatId: string, nowMs: number) => {
109
+ const arr = (hits.get(chatId) ?? []).filter(t => nowMs - t < windowMs);
110
+ if (arr.length >= maxPerWindow) {
111
+ hits.set(chatId, arr);
112
+ return false;
113
+ }
114
+ arr.push(nowMs);
115
+ hits.set(chatId, arr);
116
+ return true;
117
+ };
118
+ }
119
+
120
+ function tmuxSessionNameFor(sessionId: string): string {
121
+ return `gjc_lc_${sessionId}`;
122
+ }
123
+
124
+ /** Build the `gjc` argv for a create target (existing path / worktree / dir).
125
+ *
126
+ * The launched session id is carried via `GJC_SESSION_ID` in the child env (see
127
+ * {@link daemonSpawnCreate}); the root `gjc` launcher has no `--session-id`
128
+ * flag, so it must never appear in argv. Only flags the launch parser actually
129
+ * supports are emitted (`--worktree <branch>` for worktree targets). */
130
+ export function buildCreateArgv(
131
+ frame: SessionCreateFrame,
132
+ _ids: { intendedSessionId: string; startupPromptRef?: string },
133
+ ): { cwd: string; args: string[] } {
134
+ if (frame.target.kind === "worktree") {
135
+ // Use the `--worktree=<branch>` form so the branch is a single argv token:
136
+ // a flag-shaped branch (e.g. `-x`) can never be mis-parsed as a separate
137
+ // launcher flag / detached-mode trigger.
138
+ return { cwd: frame.target.repo, args: [`--worktree=${frame.target.branch}`] };
139
+ }
140
+ return { cwd: frame.target.path, args: [] };
141
+ }
142
+
143
+ /** Real daemon-safe tmux launcher: detached `tmux new-session -d` + GJC tags. */
144
+ export function daemonSpawnCreate(env: NodeJS.ProcessEnv = process.env) {
145
+ return async (
146
+ frame: SessionCreateFrame,
147
+ ids: { lifecycleRequestId: string; intendedSessionId: string; startupPromptRef?: string },
148
+ ): Promise<CreateEffectResult> => {
149
+ const tmux = resolveGjcTmuxCommand(env);
150
+ const name = tmuxSessionNameFor(ids.intendedSessionId);
151
+ const { cwd, args } = buildCreateArgv(frame, ids);
152
+ // A `plain_dir` target is a NEW working directory: create it before spawn
153
+ // so `/session_create dir <newdir>` works as documented.
154
+ if (frame.target.kind === "plain_dir") {
155
+ fs.mkdirSync(cwd, { recursive: true });
156
+ }
157
+ // Detached: no interactive TTY needed (daemon-safe).
158
+ const childEnv: Record<string, string> = {
159
+ GJC_TMUX_LAUNCHED: "1",
160
+ GJC_NOTIFICATIONS: "1",
161
+ GJC_SESSION_ID: ids.intendedSessionId,
162
+ GJC_LIFECYCLE_REQUEST_ID: ids.lifecycleRequestId,
163
+ };
164
+ if (ids.startupPromptRef) childEnv.GJC_STARTUP_PROMPT_REF = ids.startupPromptRef;
165
+ const envPairs = Object.entries(childEnv)
166
+ .map(([k, v]) => `${k}=${shellQuote(v)}`)
167
+ .join(" ");
168
+ const command = `cd ${shellQuote(cwd)} && exec env ${envPairs} gjc ${args.map(shellQuote).join(" ")}`;
169
+ const created = Bun.spawnSync([tmux, "new-session", "-d", "-s", name, "sh", "-c", command], {
170
+ stdout: "pipe",
171
+ stderr: "pipe",
172
+ env,
173
+ });
174
+ if (created.exitCode !== 0) {
175
+ throw new Error(created.stderr.toString().trim() || "gjc_lifecycle_spawn_failed");
176
+ }
177
+ const target = buildGjcTmuxExactOptionTarget(name);
178
+ const metaCommands = buildGjcTmuxProfileCommands(target, env, {
179
+ sessionId: ids.intendedSessionId,
180
+ project: cwd,
181
+ });
182
+ for (const cmd of metaCommands) {
183
+ Bun.spawnSync([tmux, ...cmd.args], { stdout: "pipe", stderr: "pipe", env });
184
+ }
185
+ const status = statusGjcTmuxSession(name, env);
186
+ return {
187
+ sessionId: ids.intendedSessionId,
188
+ tmuxSession: name,
189
+ sessionStateFile: status.sessionStateFile,
190
+ endpointUrl: "",
191
+ topicThreadId: "",
192
+ };
193
+ };
194
+ }
195
+
196
+ /** Real force-close effect (GJC-managed only, id-matched). */
197
+ export function daemonCloseSession(env: NodeJS.ProcessEnv = process.env) {
198
+ return async (target: { sessionId: string; tmuxSession?: string; sessionStateFile?: string }) => {
199
+ const name = target.tmuxSession ?? tmuxSessionNameFor(target.sessionId);
200
+ forceCloseGjcTmuxSession(name, env, target.sessionId, target.sessionStateFile);
201
+ return { processGone: findGjcTmuxSessionByName(name, env) === undefined };
202
+ };
203
+ }
204
+
205
+ /** Real resume effect: reattach if a live GJC session matches; else resolve the
206
+ * prefix against saved history and fail closed (`ambiguous`/`notFound`) before
207
+ * cold-restarting exactly one resolved session via the daemon-safe launcher. */
208
+ export function daemonResumeSession(env: NodeJS.ProcessEnv = process.env, opts: { sessionsRoot?: string } = {}) {
209
+ return async (target: {
210
+ sessionIdOrPrefix: string;
211
+ path?: string;
212
+ }): Promise<ResumeEffectResult | { ambiguous: ResumeCandidate[] } | { notFound: true }> => {
213
+ const live = listGjcTmuxSessions(env).filter(
214
+ s => s.sessionId === target.sessionIdOrPrefix || s.sessionId?.startsWith(target.sessionIdOrPrefix),
215
+ );
216
+ if (live.length > 1) {
217
+ return {
218
+ ambiguous: live.map(s => ({ sessionId: s.sessionId ?? s.name, path: s.project })),
219
+ };
220
+ }
221
+ if (live.length === 1) {
222
+ const s = live[0]!;
223
+ return {
224
+ sessionId: s.sessionId ?? s.name,
225
+ tmuxSession: s.name,
226
+ sessionStateFile: s.sessionStateFile,
227
+ endpointUrl: "",
228
+ topicThreadId: "",
229
+ mode: "reattached",
230
+ };
231
+ }
232
+ // Dead: resolve the id/prefix against saved session history BEFORE cold
233
+ // restart, so an unknown or ambiguous prefix fails closed instead of
234
+ // blindly spawning `gjc --resume <prefix>` against a non-authoritative id.
235
+ let resumeId = target.sessionIdOrPrefix;
236
+ if (opts.sessionsRoot) {
237
+ const saved = listRecentSessions({ sessionsRoot: opts.sessionsRoot, limit: 1000 });
238
+ const prefixed = saved.filter(
239
+ s => s.sessionId === target.sessionIdOrPrefix || s.sessionId.startsWith(target.sessionIdOrPrefix),
240
+ );
241
+ const exact = prefixed.filter(s => s.sessionId === target.sessionIdOrPrefix);
242
+ const resolved = exact.length > 0 ? exact : prefixed;
243
+ if (resolved.length === 0) return { notFound: true };
244
+ if (resolved.length > 1) {
245
+ return { ambiguous: resolved.map(s => ({ sessionId: s.sessionId, path: s.path })) };
246
+ }
247
+ resumeId = resolved[0]!.sessionId;
248
+ }
249
+ const tmux = resolveGjcTmuxCommand(env);
250
+ const name = tmuxSessionNameFor(resumeId);
251
+ const command = `exec env GJC_TMUX_LAUNCHED=1 GJC_NOTIFICATIONS=1 gjc --resume ${shellQuote(resumeId)}`;
252
+ const r = Bun.spawnSync([tmux, "new-session", "-d", "-s", name, "sh", "-c", command], {
253
+ stdout: "pipe",
254
+ stderr: "pipe",
255
+ env,
256
+ });
257
+ if (r.exitCode !== 0) throw new Error(r.stderr.toString().trim() || "gjc_lifecycle_resume_failed");
258
+ const tgt = buildGjcTmuxExactOptionTarget(name);
259
+ for (const cmd of buildGjcTmuxProfileCommands(tgt, env, { sessionId: resumeId })) {
260
+ Bun.spawnSync([tmux, ...cmd.args], { stdout: "pipe", stderr: "pipe", env });
261
+ }
262
+ return {
263
+ sessionId: resumeId,
264
+ tmuxSession: name,
265
+ endpointUrl: "",
266
+ topicThreadId: "",
267
+ mode: "cold_restarted",
268
+ };
269
+ };
270
+ }
271
+
272
+ function shellQuote(value: string): string {
273
+ return `'${value.replaceAll("'", "'\\''")}'`;
274
+ }
275
+
276
+ /** Translate an orchestrator outcome into a wire response frame. */
277
+ export function outcomeToResponse(frame: SessionLifecycleRequest, outcome: LifecycleOutcome): SessionLifecycleResponse {
278
+ if (outcome.status === "error" || outcome.status === "pending") {
279
+ const reason = outcome.status === "pending" ? "terminal_uncertain" : outcome.reason;
280
+ return {
281
+ type: "session_lifecycle_error",
282
+ requestId: frame.requestId,
283
+ status: "error",
284
+ reason,
285
+ message: outcome.status === "pending" ? "request already in progress" : outcome.message,
286
+ ...(outcome.status === "error" && outcome.candidates ? { candidates: outcome.candidates } : {}),
287
+ };
288
+ }
289
+ const e = outcome.entry;
290
+ if (frame.type === "session_create") {
291
+ return {
292
+ type: "session_create_response",
293
+ requestId: frame.requestId,
294
+ status: "ok",
295
+ lifecycleRequestId: frame.lifecycleRequestId,
296
+ sessionId: e.sessionId ?? e.intendedSessionId ?? "",
297
+ matchedBy: "spawn_marker",
298
+ endpoint: { url: e.endpointUrl ?? "", token: "" },
299
+ topic: { chatId: frame.chatId, threadId: "" },
300
+ target: frame.target,
301
+ };
302
+ }
303
+ if (frame.type === "session_close") {
304
+ return {
305
+ type: "session_close_response",
306
+ requestId: frame.requestId,
307
+ status: "ok",
308
+ sessionId: e.sessionId ?? "",
309
+ processGone: e.processGone ?? false,
310
+ historyPreserved: true,
311
+ // The killed session's per-session endpoint record is reaped by the
312
+ // daemon's dead-PID scan (scanRoots), so it is effectively stale.
313
+ endpointStale: e.processGone ?? false,
314
+ };
315
+ }
316
+ return {
317
+ type: "session_resume_response",
318
+ requestId: frame.requestId,
319
+ status: "ok",
320
+ sessionId: e.sessionId ?? "",
321
+ mode: outcome.mode ?? "reattached",
322
+ endpoint: { url: e.endpointUrl ?? "", token: "" },
323
+ topic: { chatId: frame.chatId, threadId: "" },
324
+ };
325
+ }
326
+
327
+ /**
328
+ * Wire a control server's lifecycle requests through the orchestrator.
329
+ *
330
+ * Handlers run on a single serial queue (a promise chain): the daemon owns the
331
+ * one control endpoint, so serializing here makes each request's ledger
332
+ * read -> classify -> write atomic with respect to every other request. Two
333
+ * identical updates that arrive nearly simultaneously can no longer both
334
+ * classify as `new` and both spawn — the second sees the first's persisted
335
+ * `in_progress`/`success` entry and re-acks instead.
336
+ */
337
+ export function attachLifecycleControl(server: ControlServerLike, deps: OrchestratorDeps): void {
338
+ let queue: Promise<void> = Promise.resolve();
339
+ server.onLifecycleRequest((err, req) => {
340
+ if (err) return;
341
+ let frame: SessionLifecycleRequest;
342
+ try {
343
+ frame = JSON.parse(req.payloadJson) as SessionLifecycleRequest;
344
+ } catch {
345
+ return;
346
+ }
347
+ queue = queue
348
+ .then(async () => {
349
+ const outcome = await handleLifecycleRequest(frame, deps);
350
+ server.respond(JSON.stringify(outcomeToResponse(frame, outcome)));
351
+ })
352
+ .catch(() => {
353
+ // A handler failure must not break the queue for later requests.
354
+ });
355
+ });
356
+ }
357
+
358
+ /** Assemble real orchestrator deps for the daemon (ledger/audit under agentDir). */
359
+ export function buildOrchestratorDeps(input: {
360
+ pairedChatId: string;
361
+ agentNotificationsDir: string;
362
+ /** Root of saved session histories (`<agentDir>/sessions`), for resume resolution. */
363
+ sessionsRoot?: string;
364
+ env?: NodeJS.ProcessEnv;
365
+ }): OrchestratorDeps {
366
+ const env = input.env ?? process.env;
367
+ return {
368
+ pairedChatId: input.pairedChatId,
369
+ now: () => Date.now(),
370
+ store: fileLedgerStore(path.join(input.agentNotificationsDir, "telegram-lifecycle-idempotency.json")),
371
+ audit: fileAudit(path.join(input.agentNotificationsDir, "telegram-lifecycle-audit.jsonl")),
372
+ allowCreate: createRateLimiter(3, 10 * 60 * 1000),
373
+ writeStartupPrompt: async (requestId, prompt) => {
374
+ if (prompt === undefined) return undefined;
375
+ const ref = path.join(input.agentNotificationsDir, `startup-prompt-${requestId}`);
376
+ fs.mkdirSync(path.dirname(ref), { recursive: true });
377
+ const fd = fs.openSync(ref, "w", 0o600);
378
+ fs.writeSync(fd, prompt);
379
+ fs.fsyncSync(fd);
380
+ fs.closeSync(fd);
381
+ return ref;
382
+ },
383
+ spawnCreate: daemonSpawnCreate(env),
384
+ closeSession: daemonCloseSession(env),
385
+ resumeSession: daemonResumeSession(env, { sessionsRoot: input.sessionsRoot }),
386
+ newLifecycleRequestId: () => `lc-${crypto.randomUUID()}`,
387
+ newSessionId: () => `s${crypto.randomUUID().slice(0, 8)}`,
388
+ };
389
+ }
390
+
391
+ /**
392
+ * Default production factory: a real native NotificationControlServer bound to
393
+ * the daemon's control token, owner id, and agent dir.
394
+ */
395
+ export const createNativeControlServer: LifecycleControlServerFactory = ({ token, ownerId, agentDir }) => {
396
+ // Lazy require so loading this module (for the orchestrator / wiring / tests)
397
+ // never eagerly resolves the native addon — only a real production start does.
398
+ const { NotificationControlServer } = require("@gajae-code/natives") as typeof import("@gajae-code/natives");
399
+ return new NotificationControlServer(token, ownerId, agentDir) as unknown as LifecycleControlServer;
400
+ };