jeo-code 0.12.18 → 0.12.20
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 +19 -1
- package/README.ja.md +2 -2
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/README.zh.md +2 -2
- package/package.json +1 -1
- package/src/agent/sandbox/hands-source.text +1 -1
- package/src/cli/runner.ts +1 -1
- package/src/commands/messenger.ts +27 -7
- package/src/commands/update.ts +30 -1
- package/src/messenger/discord-setup.ts +42 -72
- package/src/messenger/gateway.ts +13 -4
- package/src/messenger/launcher.ts +68 -0
- package/src/messenger/runtime.ts +255 -0
- package/src/messenger/service.ts +40 -2
- package/src/messenger/session-pool.ts +34 -3
- package/src/util/slack.ts +40 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import pkg from "../../package.json";
|
|
4
|
+
import { notifyDir } from "../agent/notify/paths";
|
|
5
|
+
import { loadMessengerConfig } from "./config";
|
|
6
|
+
import { currentJeoCommand, messengerExecutablePath, messengerLauncherContent, MESSENGER_RUN_SCRIPT_POSIX, MESSENGER_RUN_SCRIPT_WINDOWS, writePrivateFile } from "./launcher";
|
|
7
|
+
import { messengerServicePlan, restartMessengerService } from "./service";
|
|
8
|
+
import { isEnoent, isRecord } from "./types";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A messenger profile runs its gateway and workers from one of two runtimes:
|
|
12
|
+
*
|
|
13
|
+
* - `managed`: a private copy of the published `jeo-code` package pinned to one version under
|
|
14
|
+
* `<profile>/runtime/<version>` (jeopi's `runtime-<version>` layout). `jeo messenger setup` and
|
|
15
|
+
* `jeo update` provision it, rewrite the launcher and restart the service, so the gateway never
|
|
16
|
+
* depends on a mutable global install or a source checkout that can move away underneath it.
|
|
17
|
+
* - `external`: an explicit argv (a source checkout or compiled binary). Updates leave it alone.
|
|
18
|
+
*/
|
|
19
|
+
export type MessengerRuntime =
|
|
20
|
+
| { kind: "managed"; version: string }
|
|
21
|
+
| { kind: "external"; command: string[] };
|
|
22
|
+
|
|
23
|
+
export interface MessengerProfileRecord {
|
|
24
|
+
configPath: string;
|
|
25
|
+
runScript: string;
|
|
26
|
+
runtime: MessengerRuntime;
|
|
27
|
+
/** Service label when a user service was installed for the profile. */
|
|
28
|
+
service?: string;
|
|
29
|
+
updatedAt: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const MESSENGER_PACKAGE = "jeo-code";
|
|
33
|
+
export const MESSENGER_RUNTIME_DIR = "runtime";
|
|
34
|
+
export const MESSENGER_PROFILES_FILE = "messenger-profiles.json";
|
|
35
|
+
const VERSION = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/;
|
|
36
|
+
|
|
37
|
+
/** `<prefix>/node_modules/jeo-code/src/cli.ts` is a package install (global or a managed runtime); anything else is a checkout or binary. */
|
|
38
|
+
export function messengerRuntimePlan(entry = Bun.main, execPath = process.execPath, version = pkg.version): MessengerRuntime {
|
|
39
|
+
const command = currentJeoCommand(entry, execPath);
|
|
40
|
+
const parts = command.length === 2 ? command[1]!.replaceAll("\\", "/").split("/") : [];
|
|
41
|
+
const installed = parts.length >= 4 && parts.at(-1) === "cli.ts" && parts.at(-2) === "src" && parts.at(-3) === MESSENGER_PACKAGE && parts.at(-4) === "node_modules";
|
|
42
|
+
return installed && VERSION.test(version) ? { kind: "managed", version } : { kind: "external", command };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function messengerRuntimeRoot(profileDir: string, version: string): string {
|
|
46
|
+
if (!VERSION.test(version)) throw new Error(`Invalid messenger runtime version: ${version}`);
|
|
47
|
+
return path.join(profileDir, MESSENGER_RUNTIME_DIR, version);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The argv a launcher uses for `runtime`; pure, so callers can validate it before provisioning. */
|
|
51
|
+
export function messengerRuntimeCommand(profileDir: string, runtime: MessengerRuntime, execPath = process.execPath): string[] {
|
|
52
|
+
if (runtime.kind === "external") return runtime.command;
|
|
53
|
+
return [execPath, path.join(messengerRuntimeRoot(profileDir, runtime.version), "node_modules", MESSENGER_PACKAGE, "src", "cli.ts")];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function installedRuntimeVersion(root: string): Promise<string | undefined> {
|
|
57
|
+
try {
|
|
58
|
+
const manifest: unknown = await Bun.file(path.join(root, "node_modules", MESSENGER_PACKAGE, "package.json")).json();
|
|
59
|
+
if (!isRecord(manifest) || typeof manifest.version !== "string") return undefined;
|
|
60
|
+
await fs.access(path.join(root, "node_modules", MESSENGER_PACKAGE, "src", "cli.ts"));
|
|
61
|
+
return manifest.version;
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (isEnoent(error) || error instanceof SyntaxError) return undefined;
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** `bun install` the pinned package; `--force` recovers a registry manifest bun cached before a fresh publish. */
|
|
69
|
+
async function installWithBun(root: string, execPath: string): Promise<void> {
|
|
70
|
+
let failure = "";
|
|
71
|
+
for (const extra of [[], ["--force"]]) {
|
|
72
|
+
const proc = Bun.spawn([execPath, "install", "--no-progress", "--no-summary", ...extra], { cwd: root, stdout: "pipe", stderr: "pipe", stdin: "ignore" });
|
|
73
|
+
const [stderr, code] = await Promise.all([new Response(proc.stderr).text(), proc.exited]);
|
|
74
|
+
if (code === 0) return;
|
|
75
|
+
failure = stderr.trim().split("\n").slice(-3).join(" ").trim() || `bun install exited with code ${code}`;
|
|
76
|
+
}
|
|
77
|
+
throw new Error(failure);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface MessengerRuntimeDeps {
|
|
81
|
+
/** Install `jeo-code@version` into `root` (its `package.json` is already written). Defaults to `bun install`. */
|
|
82
|
+
install?: (root: string, version: string) => Promise<void>;
|
|
83
|
+
execPath?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Idempotent: an intact runtime for `version` is reused; a partial one is replaced. Failure leaves no runtime behind. */
|
|
87
|
+
export async function provisionMessengerRuntime(profileDir: string, version: string, deps: MessengerRuntimeDeps = {}): Promise<string[]> {
|
|
88
|
+
const root = messengerRuntimeRoot(profileDir, version);
|
|
89
|
+
const execPath = deps.execPath ?? process.execPath;
|
|
90
|
+
const command = messengerRuntimeCommand(profileDir, { kind: "managed", version }, execPath);
|
|
91
|
+
if ((await installedRuntimeVersion(root)) === version) return command;
|
|
92
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
93
|
+
await fs.mkdir(root, { recursive: true, mode: 0o700 });
|
|
94
|
+
await Bun.write(path.join(root, "package.json"), `${JSON.stringify({ name: "jeo-messenger-runtime", private: true, dependencies: { [MESSENGER_PACKAGE]: version } }, null, 2)}\n`);
|
|
95
|
+
try {
|
|
96
|
+
await (deps.install ?? (dir => installWithBun(dir, execPath)))(root, version);
|
|
97
|
+
const installed = await installedRuntimeVersion(root);
|
|
98
|
+
if (installed !== version) throw new Error(`the runtime contains ${MESSENGER_PACKAGE}@${installed ?? "nothing"}`);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
await fs.rm(root, { recursive: true, force: true }).catch(() => undefined);
|
|
101
|
+
throw new Error(`Could not install the messenger runtime ${MESSENGER_PACKAGE}@${version}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
102
|
+
}
|
|
103
|
+
return command;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Remove runtime versions other than `keep` (the active one and its predecessor, for rollback). Returns removed versions. */
|
|
107
|
+
export async function pruneMessengerRuntimes(profileDir: string, keep: string[]): Promise<string[]> {
|
|
108
|
+
const directory = path.join(profileDir, MESSENGER_RUNTIME_DIR);
|
|
109
|
+
let entries: string[];
|
|
110
|
+
try { entries = await fs.readdir(directory); }
|
|
111
|
+
catch (error) { if (isEnoent(error)) return []; throw error; }
|
|
112
|
+
const removed: string[] = [];
|
|
113
|
+
for (const entry of entries) {
|
|
114
|
+
if (keep.includes(entry) || !VERSION.test(entry)) continue;
|
|
115
|
+
await fs.rm(path.join(directory, entry), { recursive: true, force: true });
|
|
116
|
+
removed.push(entry);
|
|
117
|
+
}
|
|
118
|
+
return removed;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function messengerProfilesPath(): string {
|
|
122
|
+
return path.join(notifyDir(), MESSENGER_PROFILES_FILE);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function parseRuntime(value: unknown): MessengerRuntime | undefined {
|
|
126
|
+
if (!isRecord(value)) return undefined;
|
|
127
|
+
if (value.kind === "managed" && typeof value.version === "string" && VERSION.test(value.version)) return { kind: "managed", version: value.version };
|
|
128
|
+
if (value.kind === "external" && Array.isArray(value.command) && value.command.length && value.command.every(item => typeof item === "string" && item)) {
|
|
129
|
+
return { kind: "external", command: value.command as string[] };
|
|
130
|
+
}
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Records whose profile no longer exists are dropped on read, so a deleted profile never fails a later refresh. */
|
|
135
|
+
export async function readMessengerProfiles(file = messengerProfilesPath()): Promise<MessengerProfileRecord[]> {
|
|
136
|
+
let raw: unknown;
|
|
137
|
+
try { raw = await Bun.file(file).json(); }
|
|
138
|
+
catch (error) { if (isEnoent(error) || error instanceof SyntaxError) return []; throw error; }
|
|
139
|
+
if (!Array.isArray(raw)) return [];
|
|
140
|
+
const records: MessengerProfileRecord[] = [];
|
|
141
|
+
for (const item of raw) {
|
|
142
|
+
if (!isRecord(item) || typeof item.configPath !== "string" || typeof item.runScript !== "string" || !path.isAbsolute(item.configPath)) continue;
|
|
143
|
+
const runtime = parseRuntime(item.runtime);
|
|
144
|
+
if (!runtime || !(await fs.stat(item.configPath).then(stat => stat.isFile(), () => false))) continue;
|
|
145
|
+
records.push({
|
|
146
|
+
configPath: item.configPath,
|
|
147
|
+
runScript: item.runScript,
|
|
148
|
+
runtime,
|
|
149
|
+
...(typeof item.service === "string" && item.service ? { service: item.service } : {}),
|
|
150
|
+
updatedAt: typeof item.updatedAt === "string" ? item.updatedAt : new Date(0).toISOString(),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
return records;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function registerMessengerProfile(record: MessengerProfileRecord, file = messengerProfilesPath()): Promise<void> {
|
|
157
|
+
const records = (await readMessengerProfiles(file)).filter(item => item.configPath !== record.configPath);
|
|
158
|
+
records.push(record);
|
|
159
|
+
await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
160
|
+
await writePrivateFile(file, `${JSON.stringify(records, null, 2)}\n`, 0o600);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface MessengerRefreshDeps {
|
|
164
|
+
/** The runtime the running jeo offers; defaults to `messengerRuntimePlan()`. */
|
|
165
|
+
runtime?: MessengerRuntime;
|
|
166
|
+
runtimeDeps?: MessengerRuntimeDeps;
|
|
167
|
+
restartService?: typeof restartMessengerService;
|
|
168
|
+
profilesPath?: string;
|
|
169
|
+
env?: Record<string, string | undefined>;
|
|
170
|
+
platform?: NodeJS.Platform;
|
|
171
|
+
pathDelimiter?: string;
|
|
172
|
+
execPath?: string;
|
|
173
|
+
now?: () => Date;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface MessengerRefreshResult {
|
|
177
|
+
configPath: string;
|
|
178
|
+
runtime: MessengerRuntime;
|
|
179
|
+
service: "restarted" | "not installed";
|
|
180
|
+
removedRuntimes: string[];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Point one profile's launcher at the runtime the running jeo offers, restart its installed service and prune
|
|
185
|
+
* stale runtimes. A managed runtime is provisioned before the launcher changes, so a failed install leaves the
|
|
186
|
+
* previous launcher and service untouched.
|
|
187
|
+
*/
|
|
188
|
+
export async function refreshMessengerProfile(configPath: string, deps: MessengerRefreshDeps = {}): Promise<MessengerRefreshResult> {
|
|
189
|
+
const platform = deps.platform ?? process.platform;
|
|
190
|
+
const execPath = deps.execPath ?? process.execPath;
|
|
191
|
+
const runtime = deps.runtime ?? messengerRuntimePlan();
|
|
192
|
+
const resolvedConfig = path.resolve(configPath);
|
|
193
|
+
const directory = path.dirname(resolvedConfig);
|
|
194
|
+
const config = await loadMessengerConfig(resolvedConfig);
|
|
195
|
+
const tokenEnv = config.platforms.discord?.tokenEnv ?? "JEO_DISCORD_BOT_TOKEN";
|
|
196
|
+
const configName = path.basename(resolvedConfig);
|
|
197
|
+
const envPath = messengerExecutablePath(execPath, (deps.env ?? process.env).PATH, deps.pathDelimiter ?? path.delimiter, platform);
|
|
198
|
+
// Validate launcher inputs for the planned command before provisioning anything.
|
|
199
|
+
messengerLauncherContent(messengerRuntimeCommand(directory, runtime, execPath), configName, tokenEnv, envPath, platform);
|
|
200
|
+
const previous = (await readMessengerProfiles(deps.profilesPath)).find(record => record.configPath === resolvedConfig);
|
|
201
|
+
const command = runtime.kind === "managed" ? await provisionMessengerRuntime(directory, runtime.version, { execPath, ...deps.runtimeDeps }) : runtime.command;
|
|
202
|
+
const runScript = path.join(directory, platform === "win32" ? MESSENGER_RUN_SCRIPT_WINDOWS : MESSENGER_RUN_SCRIPT_POSIX);
|
|
203
|
+
await writePrivateFile(runScript, messengerLauncherContent(command, configName, tokenEnv, envPath, platform), 0o700);
|
|
204
|
+
const plan = messengerServicePlan(directory, runScript);
|
|
205
|
+
const service = await (deps.restartService ?? restartMessengerService)(plan, platform);
|
|
206
|
+
const keep = [runtime, previous?.runtime].flatMap(item => (item?.kind === "managed" ? [item.version] : []));
|
|
207
|
+
const removedRuntimes = runtime.kind === "managed" ? await pruneMessengerRuntimes(directory, keep) : [];
|
|
208
|
+
await registerMessengerProfile({
|
|
209
|
+
configPath: resolvedConfig,
|
|
210
|
+
runScript,
|
|
211
|
+
runtime,
|
|
212
|
+
...(service === "restarted" ? { service: plan.label } : previous?.service ? { service: previous.service } : {}),
|
|
213
|
+
updatedAt: (deps.now ?? (() => new Date()))().toISOString(),
|
|
214
|
+
}, deps.profilesPath);
|
|
215
|
+
return { configPath: resolvedConfig, runtime, service, removedRuntimes };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* `jeo messenger update`: refresh the named profiles, or every registered managed one. Without explicit
|
|
220
|
+
* paths, a profile on an external runtime (a checkout or binary the operator chose) is reported and left
|
|
221
|
+
* alone; naming it with `--config` re-pins it to the running jeo. Failures are reported per profile.
|
|
222
|
+
*/
|
|
223
|
+
export async function refreshMessengerProfiles(
|
|
224
|
+
configPaths: string[] | undefined,
|
|
225
|
+
say: (line: string) => void,
|
|
226
|
+
deps: MessengerRefreshDeps = {},
|
|
227
|
+
): Promise<{ refreshed: number; failed: number; skipped: number }> {
|
|
228
|
+
const explicit = Boolean(configPaths?.length);
|
|
229
|
+
const records = explicit ? [] : await readMessengerProfiles(deps.profilesPath);
|
|
230
|
+
const targets = explicit ? configPaths!.map(file => path.resolve(file)) : records.filter(record => record.runtime.kind === "managed").map(record => record.configPath);
|
|
231
|
+
let skipped = 0;
|
|
232
|
+
for (const record of records) {
|
|
233
|
+
if (record.runtime.kind !== "external") continue;
|
|
234
|
+
skipped++;
|
|
235
|
+
say(`${record.configPath}: launcher runs ${record.runtime.command.join(" ")} (external); left unchanged. Pass --config to re-pin it.`);
|
|
236
|
+
}
|
|
237
|
+
if (!targets.length && !skipped) {
|
|
238
|
+
say("No messenger profiles are registered; run `jeo messenger setup --config <file>` first.");
|
|
239
|
+
return { refreshed: 0, failed: 0, skipped: 0 };
|
|
240
|
+
}
|
|
241
|
+
let refreshed = 0;
|
|
242
|
+
let failed = 0;
|
|
243
|
+
for (const configPath of targets) {
|
|
244
|
+
try {
|
|
245
|
+
const result = await refreshMessengerProfile(configPath, deps);
|
|
246
|
+
refreshed++;
|
|
247
|
+
const where = result.runtime.kind === "managed" ? `${MESSENGER_PACKAGE}@${result.runtime.version} (managed runtime)` : `${result.runtime.command.join(" ")} (external; \`jeo update\` leaves it unchanged)`;
|
|
248
|
+
say(`${configPath}: launcher runs ${where}; service ${result.service}${result.removedRuntimes.length ? `; removed runtimes ${result.removedRuntimes.join(", ")}` : ""}.`);
|
|
249
|
+
} catch (error) {
|
|
250
|
+
failed++;
|
|
251
|
+
say(`${configPath}: not refreshed — ${error instanceof Error ? error.message : String(error)}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return { refreshed, failed, skipped };
|
|
255
|
+
}
|
package/src/messenger/service.ts
CHANGED
|
@@ -72,6 +72,44 @@ WantedBy=default.target
|
|
|
72
72
|
`;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/** Where the user service definition lives on this platform, or undefined where services are unsupported. */
|
|
76
|
+
export function messengerServiceFile(plan: MessengerServicePlan, platform: NodeJS.Platform = process.platform, home: string = os.homedir()): string | undefined {
|
|
77
|
+
if (platform === "darwin") return path.join(home, "Library", "LaunchAgents", `${plan.label}.plist`);
|
|
78
|
+
if (platform === "linux") return path.join(home, ".config", "systemd", "user", `${plan.label}.service`);
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Restart an installed service so it picks up a rewritten launcher. Returns `"not installed"` when no
|
|
84
|
+
* definition exists; a definition that cannot be restarted is an error, never a silent stale service.
|
|
85
|
+
*/
|
|
86
|
+
export async function restartMessengerService(
|
|
87
|
+
plan: MessengerServicePlan,
|
|
88
|
+
platform: NodeJS.Platform = process.platform,
|
|
89
|
+
home: string = os.homedir(),
|
|
90
|
+
): Promise<"restarted" | "not installed"> {
|
|
91
|
+
const file = messengerServiceFile(plan, platform, home);
|
|
92
|
+
if (!file || !(await fs.stat(file).then(stat => stat.isFile(), () => false))) return "not installed";
|
|
93
|
+
if (platform === "darwin") {
|
|
94
|
+
const domain = `gui/${process.getuid?.() ?? 0}`;
|
|
95
|
+
// kickstart -k restarts a loaded job; a definition that is on disk but unloaded is bootstrapped instead.
|
|
96
|
+
if ((await $`launchctl kickstart -k ${domain}/${plan.label}`.quiet().nothrow()).exitCode !== 0) {
|
|
97
|
+
if ((await $`launchctl bootstrap ${domain} ${file}`.quiet().nothrow()).exitCode !== 0) throw new Error(`launchctl could not restart ${plan.label}`);
|
|
98
|
+
}
|
|
99
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
100
|
+
const status = await $`launchctl print ${domain}/${plan.label}`.quiet().nothrow();
|
|
101
|
+
if (status.exitCode === 0 && /\bstate = running\b/.test(status.text())) return "restarted";
|
|
102
|
+
await Bun.sleep(500);
|
|
103
|
+
}
|
|
104
|
+
throw new Error(`launchd job ${plan.label} did not reach the running state; see ${plan.logFile}`);
|
|
105
|
+
}
|
|
106
|
+
const unit = `${plan.label}.service`;
|
|
107
|
+
if ((await $`systemctl --user restart ${unit}`.quiet().nothrow()).exitCode !== 0) throw new Error(`systemctl --user could not restart ${unit}`);
|
|
108
|
+
const active = await $`systemctl --user is-active ${unit}`.quiet().nothrow();
|
|
109
|
+
if (active.text().trim() !== "active") throw new Error(`${unit} is not active; see ${plan.logFile}`);
|
|
110
|
+
return "restarted";
|
|
111
|
+
}
|
|
112
|
+
|
|
75
113
|
/**
|
|
76
114
|
* Install (or replace) and start the service, then confirm the platform reports it running.
|
|
77
115
|
* Returns a one-line description of where it was installed.
|
|
@@ -82,7 +120,7 @@ export async function installMessengerService(
|
|
|
82
120
|
home: string = os.homedir(),
|
|
83
121
|
): Promise<string> {
|
|
84
122
|
if (platform === "darwin") {
|
|
85
|
-
const file =
|
|
123
|
+
const file = messengerServiceFile(plan, platform, home)!;
|
|
86
124
|
await Bun.write(file, launchdPlist(plan));
|
|
87
125
|
const domain = `gui/${process.getuid?.() ?? 0}`;
|
|
88
126
|
await $`launchctl bootout ${domain} ${file}`.quiet().nothrow();
|
|
@@ -103,7 +141,7 @@ export async function installMessengerService(
|
|
|
103
141
|
}
|
|
104
142
|
if (platform === "linux") {
|
|
105
143
|
const unit = `${plan.label}.service`;
|
|
106
|
-
const file =
|
|
144
|
+
const file = messengerServiceFile(plan, platform, home)!;
|
|
107
145
|
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
108
146
|
await Bun.write(file, systemdUnit(plan));
|
|
109
147
|
const reload = await $`systemctl --user daemon-reload`.quiet().nothrow();
|
|
@@ -32,18 +32,39 @@ interface Worker {
|
|
|
32
32
|
cleanupError?: Error;
|
|
33
33
|
idleTimer?: ReturnType<typeof setTimeout>;
|
|
34
34
|
exitTimer?: ReturnType<typeof setTimeout>;
|
|
35
|
+
/** Diagnostic lines already forwarded for this worker. */
|
|
36
|
+
diagnostics: number;
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
/** Resident sessions the pool keeps, and the number of turns it runs at once. */
|
|
38
40
|
export const MESSENGER_SESSION_LIMIT = 16;
|
|
41
|
+
/** Operator-visible worker stderr is bounded per worker so a chatty worker cannot flood the gateway log. */
|
|
42
|
+
export const MESSENGER_WORKER_DIAGNOSTIC_LINES = 20;
|
|
43
|
+
const MAX_DIAGNOSTIC_LINE_LENGTH = 300;
|
|
39
44
|
|
|
40
|
-
export
|
|
45
|
+
export interface MessengerSessionPoolOptions {
|
|
46
|
+
requireSandboxCleanup?: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Receives bounded, non-protocol worker stderr lines and abnormal exits for the operator log. Worker
|
|
49
|
+
* environments carry no platform tokens, and the conversation key is a route digest, never message text.
|
|
50
|
+
*/
|
|
51
|
+
onDiagnostic?: (key: string, line: string) => void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** One printable, bounded line: control characters collapse to spaces. */
|
|
55
|
+
function diagnosticLine(text: string): string {
|
|
56
|
+
const line = text.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").trim();
|
|
57
|
+
return line.length > MAX_DIAGNOSTIC_LINE_LENGTH ? `${line.slice(0, MAX_DIAGNOSTIC_LINE_LENGTH - 1)}…` : line;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function createMessengerSessionPool(spawn: typeof Bun.spawn = Bun.spawn, limit = MESSENGER_SESSION_LIMIT, idleTimeoutMs = 5 * 60_000, shutdownTimeoutMs = 5_000, options: MessengerSessionPoolOptions = {}) {
|
|
41
61
|
const workers = new Map<string, Worker>();
|
|
42
62
|
const allWorkers = new Map<Worker, string>();
|
|
43
63
|
const exits = new Map<Promise<number>, Worker>();
|
|
44
64
|
const retiring = new Map<string, Promise<number>>();
|
|
45
65
|
const keyTails = new Map<string, Promise<void>>();
|
|
46
66
|
const requireSandboxCleanup = options.requireSandboxCleanup === true;
|
|
67
|
+
const onDiagnostic = options.onDiagnostic;
|
|
47
68
|
const incomplete = (detail: string) => new Error(`REVOKE_INCOMPLETE: ${detail}`);
|
|
48
69
|
|
|
49
70
|
let closed = false;
|
|
@@ -191,7 +212,17 @@ export function createMessengerSessionPool(spawn: typeof Bun.spawn = Bun.spawn,
|
|
|
191
212
|
const spawnOptions = { cwd, env: workerEnv, stdin: "pipe", stdout: "pipe", stderr: "pipe", ...(cleanupNonce ? { ipc() {}, serialization: "json" } : {}) };
|
|
192
213
|
const child = spawn(command, spawnOptions as never) as unknown as WorkerProcess;
|
|
193
214
|
const cleanup = Promise.withResolvers<number>();
|
|
194
|
-
const worker: Worker = { child, exit: cleanupNonce ? cleanup.promise : child.exited, cleanupNonce, policyKey, lastUsed: ++tick, dead: false };
|
|
215
|
+
const worker: Worker = { child, exit: cleanupNonce ? cleanup.promise : child.exited, cleanupNonce, policyKey, lastUsed: ++tick, dead: false, diagnostics: 0 };
|
|
216
|
+
const diagnose = (text: string): void => {
|
|
217
|
+
if (!onDiagnostic || worker.diagnostics > MESSENGER_WORKER_DIAGNOSTIC_LINES) return;
|
|
218
|
+
const line = diagnosticLine(text);
|
|
219
|
+
if (!line) return;
|
|
220
|
+
worker.diagnostics++;
|
|
221
|
+
try { onDiagnostic(key, worker.diagnostics > MESSENGER_WORKER_DIAGNOSTIC_LINES ? "further worker output suppressed" : line); }
|
|
222
|
+
catch { /* The operator log never affects the turn. */ }
|
|
223
|
+
};
|
|
224
|
+
// An exit during a turn, or a non-zero exit, explains the user-visible failure; a clean idle exit is routine.
|
|
225
|
+
void child.exited.then(code => { if (code !== 0 || worker.pending) diagnose(`worker exited with code ${code}${worker.pending ? " during a turn" : ""}`); }, () => {});
|
|
195
226
|
exits.set(worker.exit, worker);
|
|
196
227
|
void worker.exit.then(() => {
|
|
197
228
|
exits.delete(worker.exit);
|
|
@@ -227,7 +258,7 @@ export function createMessengerSessionPool(spawn: typeof Bun.spawn = Bun.spawn,
|
|
|
227
258
|
if (activityText) worker.pending?.report?.(activityText);
|
|
228
259
|
} else if (line === "JEO_MESSENGER_STEP") {
|
|
229
260
|
worker.pending?.report?.("Working on your request");
|
|
230
|
-
}
|
|
261
|
+
} else diagnose(line);
|
|
231
262
|
}).catch(() => retire(key, worker));
|
|
232
263
|
if (cleanupNonce) {
|
|
233
264
|
void Promise.all([child.exited, stderrDone, stdoutDone]).then(([code]) => {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slack-style direct-request detection (ops/specs/slack-autopilot.md).
|
|
3
|
+
*
|
|
4
|
+
* Goal: free-form natural-language input ("fix the build error") routes to
|
|
5
|
+
* the interactive agent, while command invocations (`joc status`), flags,
|
|
6
|
+
* and single-token typos still reach the normal dispatcher so command
|
|
7
|
+
* resolution and "did you mean" suggestions keep working.
|
|
8
|
+
*
|
|
9
|
+
* Pure, domain-agnostic helpers only (src/util convention).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Command-shaped token: `status`, `evolve-core`, `skills:list`, … */
|
|
13
|
+
const COMMAND_SHAPED = /^[a-z][a-z0-9:_-]*$/i;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Slack command-prefix check: `/deploy` and `!ops` style messages are
|
|
17
|
+
* explicit commands, never direct requests.
|
|
18
|
+
*/
|
|
19
|
+
export function isSlackDM(text: string): boolean {
|
|
20
|
+
return !text.startsWith("/") && !text.startsWith("!");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* True when the argv tokens look like a free-form request rather than a
|
|
25
|
+
* CLI command invocation.
|
|
26
|
+
*
|
|
27
|
+
* - empty / flags-only input → false (dispatcher owns defaults)
|
|
28
|
+
* - `/cmd` or `!cmd` prefix → false (explicit command style)
|
|
29
|
+
* - single command-shaped token → false (could be a typo of a real
|
|
30
|
+
* command; the dispatcher must keep its suggestion path)
|
|
31
|
+
* - anything else (multi-word sentences, CJK text, punctuation) → true
|
|
32
|
+
*/
|
|
33
|
+
export function isDirectRequest(tokens: readonly string[]): boolean {
|
|
34
|
+
const words = tokens.filter(t => !t.startsWith("-"));
|
|
35
|
+
if (words.length === 0) return false;
|
|
36
|
+
const first = words[0];
|
|
37
|
+
if (!isSlackDM(first)) return false;
|
|
38
|
+
if (words.length === 1 && COMMAND_SHAPED.test(first)) return false;
|
|
39
|
+
return true;
|
|
40
|
+
}
|