@gajae-code/coding-agent 0.7.2 → 0.7.4
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 +86 -0
- package/bin/gjc.js +4 -0
- package/dist/types/cli/mcp-cli.d.ts +25 -0
- package/dist/types/cli/plugin-cli.d.ts +2 -0
- package/dist/types/cli.d.ts +6 -0
- package/dist/types/commands/mcp.d.ts +70 -0
- package/dist/types/commands/plugin.d.ts +6 -0
- package/dist/types/commands/session.d.ts +6 -0
- package/dist/types/config/keybindings.d.ts +2 -2
- package/dist/types/config/model-profile-activation.d.ts +8 -1
- package/dist/types/deep-interview/plaintext-gate-guard.d.ts +11 -0
- package/dist/types/extensibility/gjc-plugins/compiler.d.ts +19 -0
- package/dist/types/extensibility/gjc-plugins/constrained-hooks.d.ts +29 -0
- package/dist/types/extensibility/gjc-plugins/index.d.ts +9 -0
- package/dist/types/extensibility/gjc-plugins/injection.d.ts +9 -0
- package/dist/types/extensibility/gjc-plugins/installer.d.ts +13 -0
- package/dist/types/extensibility/gjc-plugins/mcp-policy.d.ts +26 -0
- package/dist/types/extensibility/gjc-plugins/observability.d.ts +27 -0
- package/dist/types/extensibility/gjc-plugins/prompt-appendix.d.ts +16 -0
- package/dist/types/extensibility/gjc-plugins/registry.d.ts +32 -0
- package/dist/types/extensibility/gjc-plugins/runtime-adapters.d.ts +64 -0
- package/dist/types/extensibility/gjc-plugins/session-validation.d.ts +42 -0
- package/dist/types/extensibility/gjc-plugins/types.d.ts +158 -2
- package/dist/types/extensibility/gjc-plugins/validation.d.ts +8 -1
- package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
- package/dist/types/gjc-runtime/psmux-detect.d.ts +78 -0
- package/dist/types/gjc-runtime/team-runtime.d.ts +2 -0
- package/dist/types/gjc-runtime/tmux-common.d.ts +20 -1
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +18 -0
- package/dist/types/main.d.ts +2 -0
- package/dist/types/modes/components/custom-editor.d.ts +1 -1
- package/dist/types/modes/components/model-selector.d.ts +8 -0
- package/dist/types/modes/components/status-line/git-utils.d.ts +6 -0
- package/dist/types/modes/theme/defaults/index.d.ts +99 -0
- package/dist/types/notifications/html-format.d.ts +11 -0
- package/dist/types/notifications/index.d.ts +149 -1
- package/dist/types/notifications/lifecycle-commands.d.ts +72 -0
- package/dist/types/notifications/lifecycle-control-runtime.d.ts +98 -0
- package/dist/types/notifications/lifecycle-orchestrator.d.ts +144 -0
- package/dist/types/notifications/operator-runtime.d.ts +52 -0
- package/dist/types/notifications/rate-limit-pool.d.ts +2 -0
- package/dist/types/notifications/recent-activity.d.ts +35 -0
- package/dist/types/notifications/telegram-daemon.d.ts +114 -16
- package/dist/types/notifications/telegram-reference.d.ts +3 -1
- package/dist/types/notifications/topic-registry.d.ts +12 -9
- package/dist/types/runtime-mcp/types.d.ts +7 -0
- package/dist/types/sdk.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +14 -4
- package/dist/types/session/blob-store.d.ts +25 -0
- package/dist/types/session/session-manager.d.ts +57 -0
- package/dist/types/slash-commands/helpers/fast-status-report.d.ts +6 -0
- package/dist/types/system-prompt.d.ts +2 -0
- package/dist/types/task/executor.d.ts +9 -1
- package/dist/types/tools/composer-bash-policy.d.ts +14 -0
- package/dist/types/tools/index.d.ts +3 -1
- package/dist/types/utils/changelog.d.ts +1 -0
- package/dist/types/web/insane/url-guard.d.ts +6 -3
- package/dist/types/web/scrapers/types.d.ts +5 -0
- package/dist/types/web/scrapers/utils.d.ts +7 -1
- package/package.json +11 -9
- package/scripts/g004-tmux-smoke.ts +100 -0
- package/scripts/g005-daemon-smoke.ts +181 -0
- package/scripts/g011-daemon-path-smoke.ts +153 -0
- package/src/cli/mcp-cli.ts +272 -0
- package/src/cli/plugin-cli.ts +66 -3
- package/src/cli.ts +27 -6
- package/src/commands/mcp.ts +117 -0
- package/src/commands/plugin.ts +4 -0
- package/src/commands/session.ts +18 -0
- package/src/config/keybindings.ts +2 -2
- package/src/config/model-profile-activation.ts +55 -7
- package/src/deep-interview/plaintext-gate-guard.ts +94 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/biome.json +1 -1
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +7 -6
- package/src/defaults/gjc/skills/team/SKILL.md +5 -3
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +41 -13
- package/src/export/html/index.ts +2 -2
- package/src/extensibility/extensions/runner.ts +1 -0
- package/src/extensibility/gjc-plugins/compiler.ts +351 -0
- package/src/extensibility/gjc-plugins/constrained-hooks.ts +170 -0
- package/src/extensibility/gjc-plugins/index.ts +9 -0
- package/src/extensibility/gjc-plugins/injection.ts +109 -0
- package/src/extensibility/gjc-plugins/installer.ts +434 -0
- package/src/extensibility/gjc-plugins/loader.ts +3 -1
- package/src/extensibility/gjc-plugins/mcp-policy.ts +239 -0
- package/src/extensibility/gjc-plugins/observability.ts +84 -0
- package/src/extensibility/gjc-plugins/paths.ts +1 -1
- package/src/extensibility/gjc-plugins/prompt-appendix.ts +109 -0
- package/src/extensibility/gjc-plugins/registry.ts +180 -0
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +234 -0
- package/src/extensibility/gjc-plugins/schema.ts +250 -20
- package/src/extensibility/gjc-plugins/session-validation.ts +147 -0
- package/src/extensibility/gjc-plugins/types.ts +199 -3
- package/src/extensibility/gjc-plugins/validation.ts +80 -0
- package/src/extensibility/skills.ts +15 -0
- package/src/gjc-runtime/launch-tmux.ts +61 -7
- package/src/gjc-runtime/psmux-detect.ts +239 -0
- package/src/gjc-runtime/team-runtime.ts +56 -23
- package/src/gjc-runtime/tmux-common.ts +30 -3
- package/src/gjc-runtime/tmux-sessions.ts +51 -1
- package/src/gjc-runtime/ultragoal-guard.ts +25 -8
- package/src/gjc-runtime/ultragoal-runtime.ts +75 -15
- package/src/hooks/skill-state.ts +57 -0
- package/src/internal-urls/docs-index.generated.ts +12 -8
- package/src/main.ts +14 -3
- package/src/modes/bridge/bridge-mode.ts +11 -0
- package/src/modes/components/custom-editor.ts +2 -0
- package/src/modes/components/footer.ts +2 -3
- package/src/modes/components/hook-editor.ts +1 -1
- package/src/modes/components/hook-selector.ts +67 -43
- package/src/modes/components/model-selector.ts +56 -11
- package/src/modes/components/status-line/git-utils.ts +25 -0
- package/src/modes/components/status-line.ts +10 -11
- package/src/modes/components/welcome.ts +2 -3
- package/src/modes/controllers/extension-ui-controller.ts +0 -27
- package/src/modes/controllers/selector-controller.ts +53 -11
- package/src/modes/interactive-mode.ts +4 -1
- package/src/modes/shared/agent-wire/scopes.ts +1 -1
- package/src/modes/theme/defaults/gruvbox-dark.json +99 -0
- package/src/modes/theme/defaults/index.ts +2 -0
- package/src/modes/utils/hotkeys-markdown.ts +1 -1
- package/src/notifications/html-format.ts +38 -0
- package/src/notifications/index.ts +242 -12
- package/src/notifications/lifecycle-commands.ts +228 -0
- package/src/notifications/lifecycle-control-runtime.ts +400 -0
- package/src/notifications/lifecycle-orchestrator.ts +358 -0
- package/src/notifications/operator-runtime.ts +171 -0
- package/src/notifications/rate-limit-pool.ts +19 -0
- package/src/notifications/recent-activity.ts +132 -0
- package/src/notifications/telegram-daemon.ts +778 -257
- package/src/notifications/telegram-reference.ts +25 -7
- package/src/notifications/topic-registry.ts +23 -9
- package/src/prompts/agents/executor.md +2 -2
- package/src/runtime-mcp/transports/stdio.ts +38 -4
- package/src/runtime-mcp/types.ts +7 -0
- package/src/sdk.ts +157 -10
- package/src/session/agent-session.ts +166 -74
- package/src/session/blob-store.ts +196 -8
- package/src/session/session-manager.ts +678 -7
- package/src/slash-commands/builtin-registry.ts +23 -3
- package/src/slash-commands/helpers/fast-status-report.ts +13 -3
- package/src/slash-commands/helpers/parse.ts +2 -1
- package/src/system-prompt.ts +9 -0
- package/src/task/executor.ts +31 -7
- package/src/task/index.ts +2 -0
- package/src/tools/ask.ts +5 -1
- package/src/tools/bash.ts +9 -0
- package/src/tools/composer-bash-policy.ts +96 -0
- package/src/tools/fetch.ts +18 -2
- package/src/tools/index.ts +3 -1
- package/src/utils/changelog.ts +8 -0
- package/src/web/insane/url-guard.ts +18 -14
- package/src/web/scrapers/types.ts +143 -45
- package/src/web/scrapers/utils.ts +70 -19
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Paired-chat /session_* command grammar (G009).
|
|
3
|
+
*
|
|
4
|
+
* Pure parser + shared target validator for the Telegram session-lifecycle
|
|
5
|
+
* commands. The daemon parses an inbound paired-chat message here, then attaches
|
|
6
|
+
* transport identity (chatId/updateId/token/requestId) and routes the resulting
|
|
7
|
+
* frame to the orchestrator. Keeping this pure makes the grammar, the MVP
|
|
8
|
+
* prompt-rejection, and target validation unit-testable without the daemon.
|
|
9
|
+
*
|
|
10
|
+
* MVP scope: an initial prompt (`-- <prompt>`) is REJECTED with usage text — no
|
|
11
|
+
* prompt text ever enters a frame, audit, log, or response until daemon-owned
|
|
12
|
+
* 0600 prompt refs are designed.
|
|
13
|
+
*/
|
|
14
|
+
import type { SessionCloseTarget, SessionCreateTarget, SessionLifecycleResponse, SessionResumeTarget } from "./index";
|
|
15
|
+
|
|
16
|
+
export type LifecycleCommandVerb = "session_create" | "session_close" | "session_resume";
|
|
17
|
+
|
|
18
|
+
/** A parsed, validated lifecycle command (transport identity added by caller). */
|
|
19
|
+
export type ParsedLifecycleCommand =
|
|
20
|
+
| { kind: "create"; target: SessionCreateTarget }
|
|
21
|
+
| { kind: "close"; target: SessionCloseTarget }
|
|
22
|
+
| { kind: "resume"; target: SessionResumeTarget }
|
|
23
|
+
| { kind: "recent"; which: "create" | "resume" | "all" }
|
|
24
|
+
| { kind: "usage"; message: string }
|
|
25
|
+
| { kind: "reject"; reason: "invalid_target" | "prompt_unsupported"; message: string }
|
|
26
|
+
| { kind: "none" };
|
|
27
|
+
|
|
28
|
+
const USAGE = [
|
|
29
|
+
"Session commands:",
|
|
30
|
+
"/session_create path <dir>",
|
|
31
|
+
"/session_create worktree <repo> <branch>",
|
|
32
|
+
"/session_create dir <newdir>",
|
|
33
|
+
"/session_close <sessionId>",
|
|
34
|
+
"/session_resume <sessionId|prefix>",
|
|
35
|
+
"/session_recent [create|resume]",
|
|
36
|
+
].join("\n");
|
|
37
|
+
|
|
38
|
+
/** True when the text begins a /session_* command (cheap pre-gate). */
|
|
39
|
+
export function isLifecycleCommandText(text: string | undefined): boolean {
|
|
40
|
+
if (!text) return false;
|
|
41
|
+
return /^\/session_(create|close|resume|recent)\b/.test(text.trim());
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Parse a paired-chat message into a lifecycle command. Returns `none` for
|
|
46
|
+
* non-lifecycle text, `usage`/`reject` for malformed input (no side effect), or
|
|
47
|
+
* a validated `create`/`close`/`resume`/`recent` intent.
|
|
48
|
+
*
|
|
49
|
+
* The caller MUST have already enforced paired-chat authorization; this function
|
|
50
|
+
* performs grammar + target validation only.
|
|
51
|
+
*/
|
|
52
|
+
export function parseLifecycleCommand(text: string | undefined): ParsedLifecycleCommand {
|
|
53
|
+
if (!isLifecycleCommandText(text)) return { kind: "none" };
|
|
54
|
+
const raw = (text ?? "").trim();
|
|
55
|
+
|
|
56
|
+
// MVP: reject any initial-prompt separator outright (no prompt handling yet).
|
|
57
|
+
if (/\s--(\s|$)/.test(raw)) {
|
|
58
|
+
return {
|
|
59
|
+
kind: "reject",
|
|
60
|
+
reason: "prompt_unsupported",
|
|
61
|
+
message: `Initial prompts (\`-- <prompt>\`) are not supported yet. Create the session, then send a normal message in its thread.\n\n${USAGE}`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const [command, ...args] = raw.split(/\s+/);
|
|
66
|
+
|
|
67
|
+
if (command === "/session_recent") {
|
|
68
|
+
const which = args[0];
|
|
69
|
+
if (which === undefined || which === "create" || which === "resume") {
|
|
70
|
+
return { kind: "recent", which: which ?? "all" };
|
|
71
|
+
}
|
|
72
|
+
return { kind: "usage", message: USAGE };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (command === "/session_close") {
|
|
76
|
+
if (args.length !== 1) return { kind: "usage", message: USAGE };
|
|
77
|
+
const sessionId = args[0]!;
|
|
78
|
+
if (!isSafeIdentifier(sessionId)) {
|
|
79
|
+
return { kind: "reject", reason: "invalid_target", message: `Invalid session id.\n\n${USAGE}` };
|
|
80
|
+
}
|
|
81
|
+
return { kind: "close", target: { sessionId } };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (command === "/session_resume") {
|
|
85
|
+
if (args.length !== 1) return { kind: "usage", message: USAGE };
|
|
86
|
+
const idOrPrefix = args[0]!;
|
|
87
|
+
if (!isSafeIdentifier(idOrPrefix)) {
|
|
88
|
+
return { kind: "reject", reason: "invalid_target", message: `Invalid session id/prefix.\n\n${USAGE}` };
|
|
89
|
+
}
|
|
90
|
+
return { kind: "resume", target: { sessionIdOrPrefix: idOrPrefix } };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// /session_create <kind> ...
|
|
94
|
+
const kind = args[0];
|
|
95
|
+
if (kind === "path") {
|
|
96
|
+
if (args.length !== 2) return { kind: "usage", message: USAGE };
|
|
97
|
+
const p = args[1]!;
|
|
98
|
+
if (!isSafePath(p)) return { kind: "reject", reason: "invalid_target", message: `Invalid path.\n\n${USAGE}` };
|
|
99
|
+
return { kind: "create", target: { kind: "existing_path", path: p } };
|
|
100
|
+
}
|
|
101
|
+
if (kind === "dir") {
|
|
102
|
+
if (args.length !== 2) return { kind: "usage", message: USAGE };
|
|
103
|
+
const p = args[1]!;
|
|
104
|
+
if (!isSafePath(p)) return { kind: "reject", reason: "invalid_target", message: `Invalid dir.\n\n${USAGE}` };
|
|
105
|
+
return { kind: "create", target: { kind: "plain_dir", path: p } };
|
|
106
|
+
}
|
|
107
|
+
if (kind === "worktree") {
|
|
108
|
+
if (args.length !== 3) return { kind: "usage", message: USAGE };
|
|
109
|
+
const repo = args[1]!;
|
|
110
|
+
const branch = args[2]!;
|
|
111
|
+
if (!isSafePath(repo))
|
|
112
|
+
return { kind: "reject", reason: "invalid_target", message: `Invalid repo path.\n\n${USAGE}` };
|
|
113
|
+
if (!isSafeBranch(branch)) {
|
|
114
|
+
return { kind: "reject", reason: "invalid_target", message: `Invalid branch name.\n\n${USAGE}` };
|
|
115
|
+
}
|
|
116
|
+
return { kind: "create", target: { kind: "worktree", repo, branch } };
|
|
117
|
+
}
|
|
118
|
+
return { kind: "usage", message: USAGE };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The canonical usage text (exported for the daemon's help replies). */
|
|
122
|
+
export function lifecycleUsage(): string {
|
|
123
|
+
return USAGE;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Shared target validator reused at the policy/effect boundary (after paired-chat
|
|
128
|
+
* auth, before any side effect). Returns null when valid, or an `invalid_target`
|
|
129
|
+
* reason. The orchestrator remains authoritative; this is a defensive pre-check
|
|
130
|
+
* the parser and any other entry point share.
|
|
131
|
+
*/
|
|
132
|
+
export function validateLifecycleTarget(
|
|
133
|
+
verb: LifecycleCommandVerb,
|
|
134
|
+
target: SessionCreateTarget | SessionCloseTarget | SessionResumeTarget,
|
|
135
|
+
): { ok: true } | { ok: false; reason: "invalid_target"; message: string } {
|
|
136
|
+
const bad = (message: string) => ({ ok: false as const, reason: "invalid_target" as const, message });
|
|
137
|
+
if (verb === "session_create") {
|
|
138
|
+
const t = target as SessionCreateTarget;
|
|
139
|
+
if (t.kind === "existing_path" || t.kind === "plain_dir") {
|
|
140
|
+
return isSafePath(t.path) ? { ok: true } : bad("invalid path");
|
|
141
|
+
}
|
|
142
|
+
if (t.kind === "worktree") {
|
|
143
|
+
if (!isSafePath(t.repo)) return bad("invalid repo path");
|
|
144
|
+
return isSafeBranch(t.branch) ? { ok: true } : bad("invalid branch");
|
|
145
|
+
}
|
|
146
|
+
return bad("unknown create target");
|
|
147
|
+
}
|
|
148
|
+
if (verb === "session_close") {
|
|
149
|
+
const t = target as SessionCloseTarget;
|
|
150
|
+
return isSafeIdentifier(t.sessionId) ? { ok: true } : bad("invalid session id");
|
|
151
|
+
}
|
|
152
|
+
const t = target as SessionResumeTarget;
|
|
153
|
+
return isSafeIdentifier(t.sessionIdOrPrefix) ? { ok: true } : bad("invalid session id/prefix");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// --- Safety primitives (defensive; the full-trust paired chat is accepted, but
|
|
157
|
+
// we still reject obviously malformed/injection-shaped inputs early). ---
|
|
158
|
+
|
|
159
|
+
function isSafeIdentifier(value: string): boolean {
|
|
160
|
+
return /^[A-Za-z0-9._-]{1,128}$/.test(value);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function isSafePath(value: string): boolean {
|
|
164
|
+
// Reject empty, shell-metacharacter, or newline-bearing paths. Absolute or
|
|
165
|
+
// relative are both allowed (full-trust chat), but not injection shapes.
|
|
166
|
+
if (value.length === 0 || value.length > 4096) return false;
|
|
167
|
+
if (/[\n\r\0]/.test(value)) return false;
|
|
168
|
+
return !/[;&|`$(){}<>*?!\\"']/.test(value);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function isSafeBranch(value: string): boolean {
|
|
172
|
+
// Defense-in-depth: also reject leading-hyphen names so a branch can never be
|
|
173
|
+
// mistaken for a CLI flag downstream.
|
|
174
|
+
return /^[A-Za-z0-9._/-]{1,255}$/.test(value) && !value.includes("..") && !value.startsWith("-");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Map a lifecycle response/error to a user-facing Telegram message (G010).
|
|
179
|
+
*
|
|
180
|
+
* Only derives text from sessionId, mode, reason, a safe message, and candidate
|
|
181
|
+
* {sessionId,path} — never a token or prompt. Each error reason gets tailored,
|
|
182
|
+
* actionable copy; an "in progress" pending response is surfaced distinctly.
|
|
183
|
+
*/
|
|
184
|
+
export function formatLifecycleOutcome(r: SessionLifecycleResponse): string {
|
|
185
|
+
switch (r.type) {
|
|
186
|
+
case "session_create_response":
|
|
187
|
+
return `\u{1f680} Launching session ${r.sessionId} in tmux. It will appear once ready \u2014 check /session_recent.`;
|
|
188
|
+
case "session_close_response":
|
|
189
|
+
return `\u2705 Closed session ${r.sessionId} (history preserved \u2014 you can resume it later).`;
|
|
190
|
+
case "session_resume_response":
|
|
191
|
+
return r.mode === "reattached"
|
|
192
|
+
? `\u2705 Reattached to live session ${r.sessionId}.`
|
|
193
|
+
: `\u{1f680} Cold-restarting session ${r.sessionId} from saved history in tmux \u2014 check /session_recent.`;
|
|
194
|
+
case "session_lifecycle_error":
|
|
195
|
+
break;
|
|
196
|
+
default:
|
|
197
|
+
return "Unknown lifecycle response.";
|
|
198
|
+
}
|
|
199
|
+
if (r.reason === "ambiguous_target" && r.candidates?.length) {
|
|
200
|
+
const list = r.candidates.map(c => `\u2022 ${c.sessionId}${c.path ? ` (${c.path})` : ""}`).join("\n");
|
|
201
|
+
return `\u2753 Multiple sessions match \u2014 reply with the exact id:\n${list}`;
|
|
202
|
+
}
|
|
203
|
+
switch (r.reason) {
|
|
204
|
+
case "unauthorized":
|
|
205
|
+
return "\u26d4 Not authorized for session lifecycle commands.";
|
|
206
|
+
case "rate_limited":
|
|
207
|
+
return "\u23f3 Too many create requests \u2014 please wait a bit and try again.";
|
|
208
|
+
case "duplicate_conflict":
|
|
209
|
+
return "\u26a0\ufe0f That command id was already used for a different request; send a fresh command.";
|
|
210
|
+
case "invalid_target":
|
|
211
|
+
return `\u26a0\ufe0f Invalid target. ${r.message}`;
|
|
212
|
+
case "spawn_failed":
|
|
213
|
+
return "\u26a0\ufe0f The session failed to start. Nothing was left running.";
|
|
214
|
+
case "discovery_timeout":
|
|
215
|
+
case "readiness_timeout":
|
|
216
|
+
return "\u23f3 The session did not become ready in time. It may still be starting \u2014 check /session_recent.";
|
|
217
|
+
case "close_refused":
|
|
218
|
+
return "\u26a0\ufe0f Close refused: that session is not GJC-managed or did not match.";
|
|
219
|
+
case "not_found":
|
|
220
|
+
return "\u2753 No matching session was found.";
|
|
221
|
+
case "terminal_uncertain":
|
|
222
|
+
return /in progress/i.test(r.message)
|
|
223
|
+
? "\u23f3 That request is already in progress \u2014 hold on."
|
|
224
|
+
: "\u26a0\ufe0f Outcome uncertain. Check /session_recent before retrying so you don't double-spawn.";
|
|
225
|
+
default:
|
|
226
|
+
return `\u26a0\ufe0f ${r.reason}: ${r.message}`;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
@@ -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
|
+
};
|