jeo-code 0.12.17 → 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 +26 -1
- package/README.ja.md +3 -3
- package/README.ko.md +3 -3
- package/README.md +3 -3
- package/README.zh.md +3 -3
- package/package.json +1 -1
- package/src/agent/sandbox/hands-source.text +1 -1
- package/src/agent/session-model.ts +84 -0
- package/src/cli/runner.ts +1 -1
- package/src/commands/launch/session-slash.ts +0 -2
- package/src/commands/launch.ts +8 -5
- 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
package/src/commands/launch.ts
CHANGED
|
@@ -66,6 +66,7 @@ import type { SandboxConfig, SandboxSupervisor } from "../agent/sandbox/types";
|
|
|
66
66
|
import { runAgySessionTurn } from "./launch/agy-turn";
|
|
67
67
|
import { listProviderModels } from "../ai/model-discovery";
|
|
68
68
|
import { rememberModelPatch, recentModelsForDisplay } from "../agent/model-recency";
|
|
69
|
+
import { resolveStartupDefaultModel } from "../agent/session-model";
|
|
69
70
|
import { describeModel, describeAllProviders, describeProvider, resolveProvider, thinkingMaxTokens, resolveMaxOutputTokens, thinkingToReasoningEffort, discoverModels, flattenModels, resolveSelection, catalogMetadata, catalogByProvider, liveProviderCatalogModels, resolveRoleModel, CODEX_MODELS, qualifyModelId, modelServableWithConfig, isLocalProviderReachable } from "../ai";
|
|
70
71
|
import { rehydrateLiveModels, writeModelCache } from "../ai/model-cache";
|
|
71
72
|
import type { ProviderModelsResult, PickEntry, ProviderName, ModelRole, ThinkLevel } from "../ai";
|
|
@@ -544,7 +545,6 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
|
|
|
544
545
|
}
|
|
545
546
|
} catch {}
|
|
546
547
|
const cfg = await readGlobalConfig();
|
|
547
|
-
const defaultModel = cfg.defaultModel;
|
|
548
548
|
let initialSessionModel =
|
|
549
549
|
flags.model ??
|
|
550
550
|
(flags.modelRole ? resolveRoleModel(flags.modelRole, cfg) : flags.provider ? providerDefaultModel(flags.provider) : undefined);
|
|
@@ -564,6 +564,11 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
|
|
|
564
564
|
return;
|
|
565
565
|
}
|
|
566
566
|
}
|
|
567
|
+
// A session always opens on the configured default model, or, when the stored login cannot
|
|
568
|
+
// serve it, on the first authenticated model (session-local; the saved default is untouched).
|
|
569
|
+
// An explicit --model/--model-role/--provider pin takes precedence and skips this.
|
|
570
|
+
const startupModel = initialSessionModel === undefined ? await resolveStartupDefaultModel(cfg) : { model: cfg.defaultModel, notice: undefined };
|
|
571
|
+
const defaultModel = startupModel.model;
|
|
567
572
|
|
|
568
573
|
if (flags.tmux) {
|
|
569
574
|
if (!process.env.TMUX && jeoEnv("TMUX_LAUNCHED") !== "1") {
|
|
@@ -987,7 +992,6 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
|
|
|
987
992
|
const { header, messages } = await loadSession(id, cwd);
|
|
988
993
|
for (const m of messages) history.push(m);
|
|
989
994
|
sessionId = id;
|
|
990
|
-
if (!initialSessionModel && header.model) sessionModel = header.model;
|
|
991
995
|
const modelNote = sessionModel ? ` · model ${sessionModel}` : "";
|
|
992
996
|
console.log(`Resumed session ${id} (${messages.length} messages).${modelNote}`);
|
|
993
997
|
// Reproduce the prior screen exactly (gjc/gajae-code parity): a bare
|
|
@@ -1049,9 +1053,6 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
|
|
|
1049
1053
|
const { header, messages } = await loadSession(id, cwd);
|
|
1050
1054
|
for (const m of messages) history.push(m);
|
|
1051
1055
|
sessionId = id;
|
|
1052
|
-
// Restore the model this session was last using unless the CLI explicitly
|
|
1053
|
-
// pinned one (flags.model/role/provider → initialSessionModel wins).
|
|
1054
|
-
if (!initialSessionModel && header.model) sessionModel = header.model;
|
|
1055
1056
|
// Restore the unsent input-box draft (gajae-code parity) — see
|
|
1056
1057
|
// startupDraft's own doc comment for why this path can't feed
|
|
1057
1058
|
// queuedPromptInput.partial directly yet.
|
|
@@ -2497,6 +2498,7 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
|
|
|
2497
2498
|
};
|
|
2498
2499
|
|
|
2499
2500
|
if (isOneShot) {
|
|
2501
|
+
if (startupModel.notice) console.error(startupModel.notice);
|
|
2500
2502
|
// A one-shot run has ~17 exit points below. The session notify endpoint (a
|
|
2501
2503
|
// listening socket + a discovery file the Telegram daemon polls) is created for
|
|
2502
2504
|
// the WHOLE launch lifetime up at `startSessionNotifyEndpoint`, but its teardown
|
|
@@ -2843,6 +2845,7 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
|
|
|
2843
2845
|
if (sweepable) await playWelcomeSweep(welcomeData, { cycles: sweepCycles });
|
|
2844
2846
|
else console.log(renderWelcome(welcomeData).join("\n"));
|
|
2845
2847
|
}
|
|
2848
|
+
if (startupModel.notice) console.log(chalk.yellow(startupModel.notice));
|
|
2846
2849
|
|
|
2847
2850
|
|
|
2848
2851
|
|
|
@@ -6,6 +6,7 @@ import { Writable } from "node:stream";
|
|
|
6
6
|
import { loadMessengerConfig, parseMessengerSandbox, validateMessengerEnvironment, validateMessengerStorage } from "../messenger/config";
|
|
7
7
|
import { createDiscordAdapter } from "../messenger/discord";
|
|
8
8
|
import { setupDiscordMessenger } from "../messenger/discord-setup";
|
|
9
|
+
import { refreshMessengerProfiles } from "../messenger/runtime";
|
|
9
10
|
import { messengerArtifactPrompt } from "../messenger/artifacts";
|
|
10
11
|
import { createMessengerGateway, messengerSessionRoute } from "../messenger/gateway";
|
|
11
12
|
import { createMessengerSessionPool } from "../messenger/session-pool";
|
|
@@ -61,11 +62,12 @@ async function sendSandboxControl(config: MessengerConfig, id: string, userId: s
|
|
|
61
62
|
return promise;
|
|
62
63
|
}
|
|
63
64
|
|
|
65
|
+
type MessengerAction = "setup" | "check" | "run" | "update";
|
|
64
66
|
type Flags = { config?: string; channel: string[]; user: string[]; tools?: string; artifacts: boolean; service: boolean; noDm: boolean };
|
|
65
67
|
|
|
66
|
-
function parse(args: string[]): { action:
|
|
68
|
+
function parse(args: string[]): { action: MessengerAction; flags: Flags } {
|
|
67
69
|
const action = args.shift();
|
|
68
|
-
if (action !== "setup" && action !== "check" && action !== "run") throw new Error("Usage: jeo messenger setup|check|run --config <file>");
|
|
70
|
+
if (action !== "setup" && action !== "check" && action !== "run" && action !== "update") throw new Error("Usage: jeo messenger setup|check|run --config <file> | jeo messenger update [--config <file>]");
|
|
69
71
|
const flags: Flags = { channel: [], user: [], artifacts: false, service: false, noDm: false };
|
|
70
72
|
for (let index = 0; index < args.length; index++) {
|
|
71
73
|
const arg = args[index]!;
|
|
@@ -80,7 +82,7 @@ function parse(args: string[]): { action: "setup" | "check" | "run"; flags: Flag
|
|
|
80
82
|
else flags[arg === "--channel" ? "channel" : "user"].push(...value.split(",").filter(Boolean));
|
|
81
83
|
} else throw new Error(`Unknown messenger option: ${arg}`);
|
|
82
84
|
}
|
|
83
|
-
if (!flags.config) throw new Error("Messenger requires --config <file>");
|
|
85
|
+
if (!flags.config && action !== "update") throw new Error("Messenger requires --config <file>");
|
|
84
86
|
return { action, flags };
|
|
85
87
|
}
|
|
86
88
|
|
|
@@ -113,11 +115,20 @@ function routeUuid(config: MessengerConfig, message: Parameters<MessengerReply>[
|
|
|
113
115
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
114
116
|
}
|
|
115
117
|
|
|
118
|
+
export interface MessengerCliReplyOptions {
|
|
119
|
+
/** Operator log line sink (gateway stderr); receives bounded worker diagnostics, never message text or tokens. */
|
|
120
|
+
log?: (line: string) => void;
|
|
121
|
+
}
|
|
122
|
+
|
|
116
123
|
/** Run each conversation in its own reusable agent process. */
|
|
117
|
-
export function createMessengerCliReply(config: MessengerConfig, spawn: typeof Bun.spawn = Bun.spawn, selectModel: typeof selectDiscordModel = selectDiscordModel): MessengerReply & { close(): Promise<void>; sandboxControl: NonNullable<MessengerGatewayOptions["sandboxControl"]> } {
|
|
124
|
+
export function createMessengerCliReply(config: MessengerConfig, spawn: typeof Bun.spawn = Bun.spawn, selectModel: typeof selectDiscordModel = selectDiscordModel, options: MessengerCliReplyOptions = {}): MessengerReply & { close(): Promise<void>; sandboxControl: NonNullable<MessengerGatewayOptions["sandboxControl"]> } {
|
|
118
125
|
const sandbox = config.sandbox !== undefined || config.platforms.discord ? parseMessengerSandbox(config.sandbox) : undefined;
|
|
119
126
|
// Launch may inherit a global sandbox even when this platform has no local override.
|
|
120
|
-
const
|
|
127
|
+
const log = options.log;
|
|
128
|
+
const pool = createMessengerSessionPool(spawn, undefined, undefined, undefined, {
|
|
129
|
+
requireSandboxCleanup: true,
|
|
130
|
+
...(log ? { onDiagnostic: (key: string, line: string) => log(`worker ${key.slice(0, 8)}: ${line}`) } : {}),
|
|
131
|
+
});
|
|
121
132
|
const reply: MessengerReply = async (message, signal, onActivity) => {
|
|
122
133
|
if (signal.aborted) throw new Error("Messenger request aborted");
|
|
123
134
|
await validateMessengerStorage(config);
|
|
@@ -208,6 +219,12 @@ export async function runMessengerCommand(args: string[]): Promise<void> {
|
|
|
208
219
|
} finally { process.off("SIGINT", stop); process.off("SIGTERM", stop); io.close(); }
|
|
209
220
|
return;
|
|
210
221
|
}
|
|
222
|
+
if (action === "update") {
|
|
223
|
+
// Re-pin every registered profile (or the named one) to the jeo running this command, then restart its service.
|
|
224
|
+
const { failed } = await refreshMessengerProfiles(flags.config ? [flags.config] : undefined, line => process.stdout.write(`${line}\n`));
|
|
225
|
+
if (failed) process.exitCode = 1;
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
211
228
|
let config: MessengerConfig;
|
|
212
229
|
try { config = await loadMessengerConfig(flags.config!); validateMessengerEnvironment(config); }
|
|
213
230
|
catch (error) { throw new Error(error instanceof Error ? error.message : "Messenger configuration or required environment is invalid"); }
|
|
@@ -221,7 +238,10 @@ export async function runMessengerCommand(args: string[]): Promise<void> {
|
|
|
221
238
|
const controller = new AbortController();
|
|
222
239
|
const stop = () => controller.abort();
|
|
223
240
|
process.once("SIGINT", stop); process.once("SIGTERM", stop);
|
|
224
|
-
const
|
|
225
|
-
|
|
241
|
+
const log = (line: string): void => { process.stderr.write(`[jeo messenger ${new Date().toISOString()}] ${line}\n`); };
|
|
242
|
+
const reply = createMessengerCliReply(config, undefined, undefined, { log });
|
|
243
|
+
const onRequestError: NonNullable<MessengerGatewayOptions["onRequestError"]> = (message, error, stage) =>
|
|
244
|
+
log(`${message.platform} request failed at ${stage} (conversation ${routeUuid(config, message).slice(0, 8)}): ${error instanceof Error ? error.message : String(error)}`);
|
|
245
|
+
try { process.stdout.write(`Starting messenger: ${platforms.join(", ")}. Press Ctrl+C to stop.\n`); await createMessengerGateway(config, adapters, reply, { concurrency: 4, sandboxControl: reply.sandboxControl, onRequestError }).run(controller.signal); }
|
|
226
246
|
finally { controller.abort(); await reply.close(); process.off("SIGINT", stop); process.off("SIGTERM", stop); }
|
|
227
247
|
}
|
package/src/commands/update.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
1
2
|
import pkg from "../../package.json";
|
|
3
|
+
import { messengerProfilesPath } from "../messenger/runtime";
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* Compares two semver-ish version strings.
|
|
@@ -38,6 +40,13 @@ export interface UpdateDeps {
|
|
|
38
40
|
install: (version?: string) => Promise<{ success: boolean; stdout?: string; stderr?: string }>;
|
|
39
41
|
/** Display release notes after a successful self-update (best-effort, no-op in tests). */
|
|
40
42
|
showWhatsNew?: () => void;
|
|
43
|
+
/**
|
|
44
|
+
* Re-pin registered messenger profiles to the freshly installed jeo and restart their services
|
|
45
|
+
* (`jeo messenger update` of the NEW binary). Returns null when no profile is registered, else whether
|
|
46
|
+
* every profile was refreshed. `quiet` keeps the child's output off stdout for `--json`. Optional in
|
|
47
|
+
* tests; failures never fail the update.
|
|
48
|
+
*/
|
|
49
|
+
refreshMessengers?: (quiet: boolean) => boolean | null;
|
|
41
50
|
/** Version of the `jeo` actually on PATH, read after install to catch a silent no-op
|
|
42
51
|
* (installed but PATH still points at a different/older binary). Optional in tests. */
|
|
43
52
|
activeVersion?: () => string | null;
|
|
@@ -129,6 +138,20 @@ export const defaultDeps: UpdateDeps = {
|
|
|
129
138
|
} catch {
|
|
130
139
|
// Notes are a courtesy; a spawn failure must never fail the update.
|
|
131
140
|
}
|
|
141
|
+
},
|
|
142
|
+
refreshMessengers: quiet => {
|
|
143
|
+
// Only profiles registered by `jeo messenger setup` have runtimes to move; everyone else sees nothing.
|
|
144
|
+
if (!existsSync(messengerProfilesPath())) return null;
|
|
145
|
+
try {
|
|
146
|
+
if (!quiet) console.log("Refreshing messenger runtimes...");
|
|
147
|
+
// The NEW binary knows its own launcher format and version, so it performs the refresh.
|
|
148
|
+
const proc = Bun.spawnSync(["jeo", "messenger", "update"], quiet ? { stdout: "ignore", stderr: "ignore" } : { stdout: "inherit", stderr: "inherit" });
|
|
149
|
+
if (!proc.success && !quiet) console.error("Messenger runtimes were not all refreshed; run 'jeo messenger update' to retry.");
|
|
150
|
+
return proc.success;
|
|
151
|
+
} catch {
|
|
152
|
+
if (!quiet) console.error("Messenger runtimes were not refreshed; run 'jeo messenger update' to retry.");
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
132
155
|
}
|
|
133
156
|
};
|
|
134
157
|
|
|
@@ -310,11 +333,13 @@ export async function runUpdateCommandWith(args: string[], deps: UpdateDeps): Pr
|
|
|
310
333
|
const active = deps.activeVersion?.();
|
|
311
334
|
const stale = !!(active && latest && compareVersions(active, latest) < 0);
|
|
312
335
|
if (hasJson) {
|
|
336
|
+
const messengersRefreshed = stale ? null : (deps.refreshMessengers?.(true) ?? null);
|
|
313
337
|
console.log(JSON.stringify({
|
|
314
338
|
current,
|
|
315
339
|
latest,
|
|
316
340
|
upToDate: false,
|
|
317
341
|
installed: !stale,
|
|
342
|
+
...(messengersRefreshed === null ? {} : { messengersRefreshed }),
|
|
318
343
|
...(stale ? { activeVersion: active, error: "active jeo still reports an older version" } : {}),
|
|
319
344
|
}));
|
|
320
345
|
if (stale) process.exitCode = 1;
|
|
@@ -327,6 +352,7 @@ export async function runUpdateCommandWith(args: string[], deps: UpdateDeps): Pr
|
|
|
327
352
|
process.exitCode = 1;
|
|
328
353
|
} else {
|
|
329
354
|
console.log(`Successfully installed jeo-code@${latest}`);
|
|
355
|
+
deps.refreshMessengers?.(false);
|
|
330
356
|
deps.showWhatsNew?.();
|
|
331
357
|
}
|
|
332
358
|
} else {
|
|
@@ -337,15 +363,18 @@ export async function runUpdateCommandWith(args: string[], deps: UpdateDeps): Pr
|
|
|
337
363
|
const recovered = deps.activeVersion?.();
|
|
338
364
|
if (recovered && latest && compareVersions(recovered, latest) >= 0) {
|
|
339
365
|
if (hasJson) {
|
|
366
|
+
const messengersRefreshed = deps.refreshMessengers?.(true) ?? null;
|
|
340
367
|
console.log(JSON.stringify({
|
|
341
368
|
current,
|
|
342
369
|
latest,
|
|
343
370
|
upToDate: false,
|
|
344
371
|
installed: true,
|
|
345
|
-
recovered: true
|
|
372
|
+
recovered: true,
|
|
373
|
+
...(messengersRefreshed === null ? {} : { messengersRefreshed })
|
|
346
374
|
}));
|
|
347
375
|
} else {
|
|
348
376
|
console.log(`Successfully installed jeo-code@${latest} (package manager reported an error, but the runtime verified clean).`);
|
|
377
|
+
deps.refreshMessengers?.(false);
|
|
349
378
|
deps.showWhatsNew?.();
|
|
350
379
|
}
|
|
351
380
|
} else {
|
|
@@ -4,19 +4,19 @@ import * as path from "node:path";
|
|
|
4
4
|
import { DEFAULT_MESSENGER_TOOLS, loadMessengerConfig, messengerProfilePrivatePaths, parseMessengerSandbox, resolveMessengerToken, validateMessengerEnvironment } from "./config";
|
|
5
5
|
import { DISCORD_API, DISCORD_GATEWAY_INTENTS, discordGatewayUrl } from "./discord";
|
|
6
6
|
import { MessengerHttpError, messengerFetchJson } from "./http";
|
|
7
|
-
import { installMessengerService, messengerServicePlan } from "./service";
|
|
7
|
+
import { installMessengerService, messengerServicePlan, restartMessengerService } from "./service";
|
|
8
|
+
import { MESSENGER_RUN_SCRIPT_POSIX, MESSENGER_RUN_SCRIPT_WINDOWS, MESSENGER_TOKEN_FILE, messengerExecutablePath, messengerLauncherContent, writePrivateFile } from "./launcher";
|
|
9
|
+
import { MESSENGER_PACKAGE, messengerRuntimeCommand, messengerRuntimePlan, messengerRuntimeRoot, provisionMessengerRuntime, pruneMessengerRuntimes, readMessengerProfiles, registerMessengerProfile, type MessengerRuntime, type MessengerRuntimeDeps } from "./runtime";
|
|
8
10
|
import { isEnoent, isRecord } from "./types";
|
|
9
11
|
import { jeoHomeDir, notifyDir, notifySessionsDir } from "../agent/notify/paths";
|
|
10
12
|
import { createSandboxPolicy } from "../agent/sandbox/policy";
|
|
11
13
|
import type { SandboxConfig } from "../agent/sandbox/types";
|
|
12
14
|
|
|
13
15
|
export const DISCORD_TOKEN_ENV = "JEO_DISCORD_BOT_TOKEN";
|
|
14
|
-
const TOKEN_FILE =
|
|
16
|
+
const TOKEN_FILE = MESSENGER_TOKEN_FILE;
|
|
15
17
|
/** Fresh-setup workspace: a jeo-owned directory in the user's home, outside every sandbox-private root. */
|
|
16
18
|
const WORKSPACE_DIR = "jeo-discord-workspace";
|
|
17
19
|
const WORKSPACE_MARKER = ".jeo-workspace";
|
|
18
|
-
const RUN_SCRIPT_POSIX = "run-discord.sh";
|
|
19
|
-
const RUN_SCRIPT_WINDOWS = "run-discord.cmd";
|
|
20
20
|
const MESSAGE_CONTENT = 1 << 18;
|
|
21
21
|
const MESSAGE_CONTENT_LIMITED = 1 << 19;
|
|
22
22
|
/** The only application flags a bot token may change: the limited presence, member, and message intents. */
|
|
@@ -87,9 +87,15 @@ export interface DiscordSetupDeps {
|
|
|
87
87
|
WebSocket?: typeof WebSocket;
|
|
88
88
|
env?: Record<string, string | undefined>;
|
|
89
89
|
sleep?: (ms: number) => Promise<void>;
|
|
90
|
-
/** argv that starts this jeo build;
|
|
90
|
+
/** argv that starts this jeo build; an explicit value is an external runtime that `jeo update` leaves alone. */
|
|
91
91
|
jeoCommand?: string[];
|
|
92
|
+
/** Runtime the running jeo offers; defaults to `messengerRuntimePlan()`. Ignored when `jeoCommand` is set. */
|
|
93
|
+
runtime?: MessengerRuntime;
|
|
94
|
+
runtimeDeps?: MessengerRuntimeDeps;
|
|
92
95
|
installService?: typeof installMessengerService;
|
|
96
|
+
restartService?: typeof restartMessengerService;
|
|
97
|
+
/** Registry of messenger profiles `jeo update` refreshes; defaults to the private notify directory. */
|
|
98
|
+
profilesPath?: string;
|
|
93
99
|
/** Cancels invite polling and the remaining setup work (the CLI wires Ctrl+C here). */
|
|
94
100
|
signal?: AbortSignal;
|
|
95
101
|
/** Injectable platform/path separator for cross-platform release tests. */
|
|
@@ -105,6 +111,7 @@ export interface DiscordSetupResult {
|
|
|
105
111
|
allowedUserIds: string[];
|
|
106
112
|
allowedChannelIds: string[];
|
|
107
113
|
runScript: string;
|
|
114
|
+
runtime: MessengerRuntime;
|
|
108
115
|
service?: string;
|
|
109
116
|
}
|
|
110
117
|
|
|
@@ -469,51 +476,8 @@ export async function probeDiscordGateway(
|
|
|
469
476
|
}
|
|
470
477
|
}
|
|
471
478
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
const quote = (value: string): string => `'${value.replace(/'/g, `'\\''`)}'`;
|
|
475
|
-
return `#!/bin/sh
|
|
476
|
-
# Generated by \`jeo messenger setup\`: starts this profile's Discord gateway.
|
|
477
|
-
set -eu
|
|
478
|
-
dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
479
|
-
token_file="$dir/${TOKEN_FILE}"
|
|
480
|
-
case "$(ls -ln "$token_file" | cut -c5-10)" in
|
|
481
|
-
------) ;;
|
|
482
|
-
*) echo "${TOKEN_FILE} must be readable by its owner only (chmod 600)" >&2; exit 1 ;;
|
|
483
|
-
esac
|
|
484
|
-
PATH=${quote(envPath)}
|
|
485
|
-
export PATH
|
|
486
|
-
${tokenEnv}=$(cat "$token_file")
|
|
487
|
-
export ${tokenEnv}
|
|
488
|
-
exec ${command.map(quote).join(" ")} messenger run --config "$dir/${configName}"
|
|
489
|
-
`;
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
/** The Windows launcher inherits PATH at launch; the token remains in the private sidecar. */
|
|
493
|
-
export function discordRunCommand(command: string[], configName: string, tokenEnv: string): string {
|
|
494
|
-
if (!/^[A-Za-z_]/u.test(tokenEnv) || /[^A-Za-z0-9_]/u.test(tokenEnv)) throw new Error("discord.tokenEnv must name an environment variable");
|
|
495
|
-
const literal = (value: string, label: string): string => {
|
|
496
|
-
if (/["\r\n]/.test(value)) throw new Error(`Windows launcher ${label} must not contain quotes or newlines`);
|
|
497
|
-
if (/[^\x00-\x7f]/.test(value)) throw new Error(`Windows launcher ${label} must contain only ASCII characters; use ASCII paths`);
|
|
498
|
-
return value.replace(/%/g, "%%");
|
|
499
|
-
};
|
|
500
|
-
const quote = (value: string): string => `"${literal(value, "command")}"`;
|
|
501
|
-
return `@echo off\r\nsetlocal DisableDelayedExpansion\r\nset /p "${tokenEnv}="<"%~dp0${TOKEN_FILE}"\r\n${command.map(quote).join(" ")} messenger run --config "%~dp0${literal(configName, "config name")}"\r\n`;
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
/** Stage `content` beside `file`, run `validate` on the staged copy, and only then replace `file`; failure leaves `file` untouched. */
|
|
505
|
-
async function writePrivate(file: string, content: string, mode: number, validate?: (staged: string) => Promise<void>): Promise<void> {
|
|
506
|
-
const temporary = `${file}.${process.pid}.tmp`;
|
|
507
|
-
try {
|
|
508
|
-
await Bun.write(temporary, content);
|
|
509
|
-
await fs.chmod(temporary, mode);
|
|
510
|
-
await validate?.(temporary);
|
|
511
|
-
await fs.rename(temporary, file);
|
|
512
|
-
} catch (error) {
|
|
513
|
-
await fs.unlink(temporary).catch(() => undefined);
|
|
514
|
-
throw error;
|
|
515
|
-
}
|
|
516
|
-
}
|
|
479
|
+
export { currentJeoCommand, discordRunCommand, discordRunScript, messengerExecutablePath } from "./launcher";
|
|
480
|
+
const writePrivate = writePrivateFile;
|
|
517
481
|
|
|
518
482
|
async function readExistingConfig(configPath: string): Promise<Record<string, unknown> | undefined> {
|
|
519
483
|
let text: string;
|
|
@@ -587,16 +551,6 @@ async function createDefaultWorkspace(admit: (workspace: string) => Promise<void
|
|
|
587
551
|
return workspace;
|
|
588
552
|
}
|
|
589
553
|
|
|
590
|
-
export function currentJeoCommand(entry = Bun.main, execPath = process.execPath): string[] {
|
|
591
|
-
return entry === execPath || entry.startsWith("/$bunfs/") || entry.replaceAll("\\", "/").startsWith("B:/~BUN/") ? [execPath] : [execPath, entry];
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
export function messengerExecutablePath(execPath: string, inheritedPath: string | undefined, delimiter: string, platform: NodeJS.Platform): string {
|
|
595
|
-
const fallbackPath = platform === "win32" ? "" : "/usr/bin:/bin";
|
|
596
|
-
const runtimeDirectory = platform === "win32" ? path.win32.dirname(execPath) : path.dirname(execPath);
|
|
597
|
-
return [...new Set([runtimeDirectory, ...(inheritedPath ?? fallbackPath).split(delimiter).filter(Boolean)])].join(delimiter);
|
|
598
|
-
}
|
|
599
|
-
|
|
600
554
|
/** Create or repair a Discord messenger profile with as few manual steps as Discord allows. */
|
|
601
555
|
export async function setupDiscordMessenger(
|
|
602
556
|
options: DiscordSetupOptions,
|
|
@@ -612,10 +566,11 @@ export async function setupDiscordMessenger(
|
|
|
612
566
|
if (options.service && platform !== "darwin" && platform !== "linux") {
|
|
613
567
|
throw new Error("Background services are supported only on macOS and Linux");
|
|
614
568
|
}
|
|
615
|
-
const runScript = path.join(directory, platform === "win32" ?
|
|
569
|
+
const runScript = path.join(directory, platform === "win32" ? MESSENGER_RUN_SCRIPT_WINDOWS : MESSENGER_RUN_SCRIPT_POSIX);
|
|
616
570
|
const delimiter = deps.pathDelimiter ?? path.delimiter;
|
|
617
571
|
const envPath = messengerExecutablePath(process.execPath, env.PATH, delimiter, platform);
|
|
618
|
-
const
|
|
572
|
+
const runtime: MessengerRuntime = deps.jeoCommand ? { kind: "external", command: deps.jeoCommand } : (deps.runtime ?? messengerRuntimePlan());
|
|
573
|
+
const command = messengerRuntimeCommand(directory, runtime, process.execPath);
|
|
619
574
|
const existing = await readExistingConfig(configPath);
|
|
620
575
|
const sandbox = existing?.sandbox !== undefined ? parseMessengerSandbox(existing.sandbox) : parseMessengerSandbox({
|
|
621
576
|
image: await io.ask("Existing local Linux sandbox image (no image will be pulled): "),
|
|
@@ -629,7 +584,7 @@ export async function setupDiscordMessenger(
|
|
|
629
584
|
delete existing.independentSessions;
|
|
630
585
|
io.say("Removed obsolete independentSessions setting. Discord threads now keep one persistent session.");
|
|
631
586
|
}
|
|
632
|
-
|
|
587
|
+
messengerLauncherContent(command, path.basename(configPath), DISCORD_TOKEN_ENV, envPath, platform);
|
|
633
588
|
|
|
634
589
|
const token = await resolveToken(env, tokenPath, io);
|
|
635
590
|
throwIfAborted(deps.signal);
|
|
@@ -670,7 +625,7 @@ export async function setupDiscordMessenger(
|
|
|
670
625
|
throwIfAborted(deps.signal);
|
|
671
626
|
const allowedChannelIds = [...new Set([...channelIds, ...(dmChannel ? [dmChannel] : [])])];
|
|
672
627
|
const tokenEnv = str(previous.tokenEnv) ?? DISCORD_TOKEN_ENV;
|
|
673
|
-
|
|
628
|
+
messengerLauncherContent(command, path.basename(configPath), tokenEnv, envPath, platform);
|
|
674
629
|
|
|
675
630
|
const discord: Record<string, unknown> = { ...previous, tokenEnv, allowedUserIds: userIds, allowedChannelIds };
|
|
676
631
|
if (options.artifacts) discord.artifactDir = "./artifacts";
|
|
@@ -708,25 +663,39 @@ export async function setupDiscordMessenger(
|
|
|
708
663
|
}
|
|
709
664
|
});
|
|
710
665
|
throwIfAborted(deps.signal);
|
|
711
|
-
await
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
);
|
|
666
|
+
const registered = (await readMessengerProfiles(deps.profilesPath)).find(record => record.configPath === configPath);
|
|
667
|
+
if (runtime.kind === "managed") {
|
|
668
|
+
io.say(`Installing the messenger runtime ${MESSENGER_PACKAGE}@${runtime.version} under ${messengerRuntimeRoot(directory, runtime.version)}…`);
|
|
669
|
+
await provisionMessengerRuntime(directory, runtime.version, deps.runtimeDeps);
|
|
670
|
+
throwIfAborted(deps.signal);
|
|
671
|
+
}
|
|
672
|
+
await writePrivate(runScript, messengerLauncherContent(command, path.basename(configPath), tokenEnv, envPath, platform), 0o700);
|
|
718
673
|
throwIfAborted(deps.signal);
|
|
674
|
+
io.say(runtime.kind === "managed"
|
|
675
|
+
? `The launcher runs the pinned runtime ${MESSENGER_PACKAGE}@${runtime.version}; \`jeo update\` installs new versions here and restarts the service.`
|
|
676
|
+
: `The launcher runs ${command.join(" ")}; \`jeo update\` leaves it unchanged. Run setup from an installed jeo to pin a managed runtime.`);
|
|
719
677
|
|
|
720
678
|
await probeDiscordGateway(api, token, deps.WebSocket ?? WebSocket, deps.signal);
|
|
721
679
|
throwIfAborted(deps.signal);
|
|
722
680
|
io.say("Discord Gateway accepted the bot session and intents.");
|
|
723
681
|
|
|
682
|
+
const plan = messengerServicePlan(directory, runScript);
|
|
724
683
|
let service: string | undefined;
|
|
684
|
+
let serviceLabel = registered?.service;
|
|
725
685
|
if (options.service) {
|
|
726
|
-
service = await (deps.installService ?? installMessengerService)(
|
|
686
|
+
service = await (deps.installService ?? installMessengerService)(plan);
|
|
687
|
+
serviceLabel = plan.label;
|
|
727
688
|
throwIfAborted(deps.signal);
|
|
728
689
|
io.say(`Gateway running as ${service}`);
|
|
690
|
+
} else if ((await (deps.restartService ?? restartMessengerService)(plan, platform)) === "restarted") {
|
|
691
|
+
// A rewritten launcher only takes effect on the next start; an installed service restarts now rather than running stale code.
|
|
692
|
+
serviceLabel = plan.label;
|
|
693
|
+
io.say(`Restarted ${plan.label} on the new launcher.`);
|
|
729
694
|
}
|
|
695
|
+
throwIfAborted(deps.signal);
|
|
696
|
+
const removedRuntimes = await pruneMessengerRuntimes(directory, [runtime, registered?.runtime].flatMap(item => (item?.kind === "managed" ? [item.version] : [])));
|
|
697
|
+
if (removedRuntimes.length) io.say(`Removed stale messenger runtimes: ${removedRuntimes.join(", ")}.`);
|
|
698
|
+
await registerMessengerProfile({ configPath, runScript, runtime, ...(serviceLabel ? { service: serviceLabel } : {}), updatedAt: new Date().toISOString() }, deps.profilesPath);
|
|
730
699
|
if (dmChannel) {
|
|
731
700
|
try {
|
|
732
701
|
await api("POST", `/channels/${dmChannel}/messages`, { content: DM_TEST_MESSAGE });
|
|
@@ -747,6 +716,7 @@ export async function setupDiscordMessenger(
|
|
|
747
716
|
allowedUserIds: userIds,
|
|
748
717
|
allowedChannelIds,
|
|
749
718
|
runScript,
|
|
719
|
+
runtime,
|
|
750
720
|
...(service ? { service } : {}),
|
|
751
721
|
};
|
|
752
722
|
}
|
package/src/messenger/gateway.ts
CHANGED
|
@@ -275,6 +275,11 @@ export interface MessengerGatewayOptions {
|
|
|
275
275
|
concurrency?: number;
|
|
276
276
|
/** Authenticated human controls bypass model queues, including busy sessions. */
|
|
277
277
|
sandboxControl?: (message: MessengerMessage, signal: AbortSignal) => Promise<{ handled: boolean; output: string }>;
|
|
278
|
+
/**
|
|
279
|
+
* Operator log for a request that failed before or during delivery. Users only ever see the generic failure
|
|
280
|
+
* text, so this is the one place the cause is observable; it receives the conversation, never message text.
|
|
281
|
+
*/
|
|
282
|
+
onRequestError?: (message: MessengerMessage, error: unknown, stage: "prepare" | "reply" | "deliver") => void;
|
|
278
283
|
}
|
|
279
284
|
|
|
280
285
|
export function createMessengerGateway(
|
|
@@ -293,6 +298,9 @@ export function createMessengerGateway(
|
|
|
293
298
|
}
|
|
294
299
|
const seen = new Set<string>();
|
|
295
300
|
const queue: PendingMessage[] = [];
|
|
301
|
+
const reportError = (message: MessengerMessage, error: unknown, stage: "prepare" | "reply" | "deliver"): void => {
|
|
302
|
+
try { options.onRequestError?.(message, error, stage); } catch { /* Operator logging never affects the request. */ }
|
|
303
|
+
};
|
|
296
304
|
const activeKeys = new Set<string>();
|
|
297
305
|
const turns = new Set<Promise<void>>();
|
|
298
306
|
/** Orders admission (sandbox control, capacity check, destination preparation) per raw lane. */
|
|
@@ -363,6 +371,7 @@ export function createMessengerGateway(
|
|
|
363
371
|
return;
|
|
364
372
|
}
|
|
365
373
|
if (signal.aborted) return;
|
|
374
|
+
reportError(message, error, "reply");
|
|
366
375
|
text = error instanceof DiscordModelSelectionError
|
|
367
376
|
? "Discord could not verify a model. Ask the operator to configure an explicit Discord model, check provider credentials and live model discovery, then restart the messenger service."
|
|
368
377
|
: "The request could not be completed. Please try again.";
|
|
@@ -372,9 +381,9 @@ export function createMessengerGateway(
|
|
|
372
381
|
await adapter.send(message, text, signal);
|
|
373
382
|
}
|
|
374
383
|
if (!signal.aborted) status(failed ? "failure" : "success");
|
|
375
|
-
} catch {
|
|
384
|
+
} catch (error) {
|
|
376
385
|
failed = true;
|
|
377
|
-
if (!signal.aborted) status("failure");
|
|
386
|
+
if (!signal.aborted) { status("failure"); reportError(message, error, "deliver"); }
|
|
378
387
|
} finally {
|
|
379
388
|
await activity.close(failed ? "failure" : signal.aborted ? "cancelled" : "success");
|
|
380
389
|
resolve();
|
|
@@ -441,8 +450,8 @@ export function createMessengerGateway(
|
|
|
441
450
|
try {
|
|
442
451
|
destination = (await adapter.prepare?.(message, effectiveSignal)) ?? message;
|
|
443
452
|
key = messengerConversationKey(destination);
|
|
444
|
-
} catch {
|
|
445
|
-
if (!effectiveSignal.aborted) status("failure");
|
|
453
|
+
} catch (error) {
|
|
454
|
+
if (!effectiveSignal.aborted) { status("failure"); reportError(message, error, "prepare"); }
|
|
446
455
|
return;
|
|
447
456
|
} finally {
|
|
448
457
|
preparing--;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const MESSENGER_TOKEN_FILE = "discord-token";
|
|
5
|
+
export const MESSENGER_RUN_SCRIPT_POSIX = "run-discord.sh";
|
|
6
|
+
export const MESSENGER_RUN_SCRIPT_WINDOWS = "run-discord.cmd";
|
|
7
|
+
|
|
8
|
+
/** argv that starts the running jeo build: the compiled binary alone, or the runtime plus its entry script. */
|
|
9
|
+
export function currentJeoCommand(entry = Bun.main, execPath = process.execPath): string[] {
|
|
10
|
+
return entry === execPath || entry.startsWith("/$bunfs/") || entry.replaceAll("\\", "/").startsWith("B:/~BUN/") ? [execPath] : [execPath, entry];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function messengerExecutablePath(execPath: string, inheritedPath: string | undefined, delimiter: string, platform: NodeJS.Platform): string {
|
|
14
|
+
const fallbackPath = platform === "win32" ? "" : "/usr/bin:/bin";
|
|
15
|
+
const runtimeDirectory = platform === "win32" ? path.win32.dirname(execPath) : path.dirname(execPath);
|
|
16
|
+
return [...new Set([runtimeDirectory, ...(inheritedPath ?? fallbackPath).split(delimiter).filter(Boolean)])].join(delimiter);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The token is read from the 0600 file at launch, so it never lands in this script or a service definition. */
|
|
20
|
+
export function discordRunScript(command: string[], configName: string, tokenEnv: string, envPath: string): string {
|
|
21
|
+
const quote = (value: string): string => `'${value.replace(/'/g, `'\\''`)}'`;
|
|
22
|
+
return `#!/bin/sh
|
|
23
|
+
# Generated by \`jeo messenger setup\`: starts this profile's Discord gateway.
|
|
24
|
+
set -eu
|
|
25
|
+
dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
26
|
+
token_file="$dir/${MESSENGER_TOKEN_FILE}"
|
|
27
|
+
case "$(ls -ln "$token_file" | cut -c5-10)" in
|
|
28
|
+
------) ;;
|
|
29
|
+
*) echo "${MESSENGER_TOKEN_FILE} must be readable by its owner only (chmod 600)" >&2; exit 1 ;;
|
|
30
|
+
esac
|
|
31
|
+
PATH=${quote(envPath)}
|
|
32
|
+
export PATH
|
|
33
|
+
${tokenEnv}=$(cat "$token_file")
|
|
34
|
+
export ${tokenEnv}
|
|
35
|
+
exec ${command.map(quote).join(" ")} messenger run --config "$dir/${configName}"
|
|
36
|
+
`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The Windows launcher inherits PATH at launch; the token remains in the private sidecar. */
|
|
40
|
+
export function discordRunCommand(command: string[], configName: string, tokenEnv: string): string {
|
|
41
|
+
if (!/^[A-Za-z_]/u.test(tokenEnv) || /[^A-Za-z0-9_]/u.test(tokenEnv)) throw new Error("discord.tokenEnv must name an environment variable");
|
|
42
|
+
const literal = (value: string, label: string): string => {
|
|
43
|
+
if (/["\r\n]/.test(value)) throw new Error(`Windows launcher ${label} must not contain quotes or newlines`);
|
|
44
|
+
if (/[^\x00-\x7f]/.test(value)) throw new Error(`Windows launcher ${label} must contain only ASCII characters; use ASCII paths`);
|
|
45
|
+
return value.replace(/%/g, "%%");
|
|
46
|
+
};
|
|
47
|
+
const quote = (value: string): string => `"${literal(value, "command")}"`;
|
|
48
|
+
return `@echo off\r\nsetlocal DisableDelayedExpansion\r\nset /p "${tokenEnv}="<"%~dp0${MESSENGER_TOKEN_FILE}"\r\n${command.map(quote).join(" ")} messenger run --config "%~dp0${literal(configName, "config name")}"\r\n`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Launcher text for `platform`; Windows input validation happens here, so callers can check before any side effect. */
|
|
52
|
+
export function messengerLauncherContent(command: string[], configName: string, tokenEnv: string, envPath: string, platform: NodeJS.Platform): string {
|
|
53
|
+
return platform === "win32" ? discordRunCommand(command, configName, tokenEnv) : discordRunScript(command, configName, tokenEnv, envPath);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Stage `content` beside `file`, run `validate` on the staged copy, and only then replace `file`; failure leaves `file` untouched. */
|
|
57
|
+
export async function writePrivateFile(file: string, content: string, mode: number, validate?: (staged: string) => Promise<void>): Promise<void> {
|
|
58
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
59
|
+
try {
|
|
60
|
+
await Bun.write(temporary, content);
|
|
61
|
+
await fs.chmod(temporary, mode);
|
|
62
|
+
await validate?.(temporary);
|
|
63
|
+
await fs.rename(temporary, file);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
await fs.unlink(temporary).catch(() => undefined);
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|