jeo-code 0.12.13 → 0.12.16
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 +55 -1
- package/README.ja.md +33 -9
- package/README.ko.md +33 -9
- package/README.md +33 -9
- package/README.zh.md +33 -9
- package/package.json +4 -2
- package/src/agent/browser-tool.ts +9 -1
- package/src/agent/config-schema.ts +8 -0
- package/src/agent/context-files.ts +19 -0
- package/src/agent/engine.ts +25 -6
- package/src/agent/eval-tool.ts +6 -0
- package/src/agent/goal-verifier.ts +30 -6
- package/src/agent/hooks.ts +52 -4
- package/src/agent/job-registry.ts +110 -18
- package/src/agent/job-tool.ts +43 -9
- package/src/agent/loop-guards.ts +17 -2
- package/src/agent/memory.ts +151 -112
- package/src/agent/monitor-registry.ts +142 -30
- package/src/agent/monitor-tool.ts +34 -6
- package/src/agent/notify/discord-daemon.ts +135 -42
- package/src/agent/notify/session-endpoint.ts +27 -5
- package/src/agent/sandbox/assets.d.ts +4 -0
- package/src/agent/sandbox/authority.ts +358 -0
- package/src/agent/sandbox/bundle.ts +40 -0
- package/src/agent/sandbox/control.ts +49 -0
- package/src/agent/sandbox/docker.ts +293 -0
- package/src/agent/sandbox/hands-source.text +14548 -0
- package/src/agent/sandbox/hands-worker.ts +168 -0
- package/src/agent/sandbox/policy.ts +172 -0
- package/src/agent/sandbox/rpc.ts +90 -0
- package/src/agent/sandbox/supervisor.ts +210 -0
- package/src/agent/sandbox/types.ts +77 -0
- package/src/agent/shell-lease.ts +190 -0
- package/src/agent/state.ts +167 -0
- package/src/agent/subagents.ts +4 -3
- package/src/agent/task-tool.ts +8 -0
- package/src/agent/todo-tool.ts +30 -1
- package/src/agent/tool-schemas.ts +13 -0
- package/src/ai/model-discovery.ts +12 -0
- package/src/ai/providers/anthropic.ts +27 -2
- package/src/ai/providers/antigravity.ts +10 -2
- package/src/ai/providers/gemini.ts +20 -16
- package/src/ai/providers/ollama.ts +14 -0
- package/src/ai/providers/openai-responses.ts +22 -5
- package/src/ai/providers/openai.ts +23 -40
- package/src/cli/runner.ts +3 -0
- package/src/commands/approve.ts +37 -13
- package/src/commands/launch/flags.ts +2 -1
- package/src/commands/launch/jobs-slash.ts +1 -1
- package/src/commands/launch.ts +275 -35
- package/src/commands/messenger.ts +107 -39
- package/src/commands/notify.ts +2 -2
- package/src/commands/team.ts +44 -12
- package/src/commands/ultragoal.ts +6 -16
- package/src/messenger/activity.ts +2 -1
- package/src/messenger/config.ts +78 -10
- package/src/messenger/discord-setup.ts +216 -34
- package/src/messenger/discord.ts +37 -43
- package/src/messenger/gateway.ts +85 -37
- package/src/messenger/model-selection.ts +101 -0
- package/src/messenger/session-pool.ts +189 -35
- package/src/messenger/types.ts +5 -2
- package/src/skills/catalog.ts +207 -0
- package/src/util/retry.ts +12 -0
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as os from "node:os";
|
|
2
3
|
import * as path from "node:path";
|
|
3
|
-
import { loadMessengerConfig, resolveMessengerToken, validateMessengerEnvironment } from "./config";
|
|
4
|
+
import { DEFAULT_MESSENGER_TOOLS, loadMessengerConfig, messengerProfilePrivatePaths, parseMessengerSandbox, resolveMessengerToken, validateMessengerEnvironment } from "./config";
|
|
4
5
|
import { DISCORD_API, DISCORD_GATEWAY_INTENTS, discordGatewayUrl } from "./discord";
|
|
5
6
|
import { MessengerHttpError, messengerFetchJson } from "./http";
|
|
6
7
|
import { installMessengerService, messengerServicePlan } from "./service";
|
|
7
8
|
import { isEnoent, isRecord } from "./types";
|
|
9
|
+
import { jeoHomeDir, notifyDir, notifySessionsDir } from "../agent/notify/paths";
|
|
10
|
+
import { createSandboxPolicy } from "../agent/sandbox/policy";
|
|
11
|
+
import type { SandboxConfig } from "../agent/sandbox/types";
|
|
8
12
|
|
|
9
13
|
export const DISCORD_TOKEN_ENV = "JEO_DISCORD_BOT_TOKEN";
|
|
10
14
|
const TOKEN_FILE = "discord-token";
|
|
11
|
-
|
|
15
|
+
/** Fresh-setup workspace: a jeo-owned directory in the user's home, outside every sandbox-private root. */
|
|
16
|
+
const WORKSPACE_DIR = "jeo-discord-workspace";
|
|
17
|
+
const WORKSPACE_MARKER = ".jeo-workspace";
|
|
18
|
+
const RUN_SCRIPT_POSIX = "run-discord.sh";
|
|
19
|
+
const RUN_SCRIPT_WINDOWS = "run-discord.cmd";
|
|
12
20
|
const MESSAGE_CONTENT = 1 << 18;
|
|
13
21
|
const MESSAGE_CONTENT_LIMITED = 1 << 19;
|
|
14
22
|
/** The only application flags a bot token may change: the limited presence, member, and message intents. */
|
|
@@ -32,6 +40,28 @@ const INVITE_POLL_MS = 3_000;
|
|
|
32
40
|
const GATEWAY_PROBE_MS = 20_000;
|
|
33
41
|
const DM_TEST_MESSAGE = "jeo messenger is set up for this bot. Reply here to test the connection.";
|
|
34
42
|
|
|
43
|
+
function throwIfAborted(signal?: AbortSignal): void {
|
|
44
|
+
if (signal?.aborted) throw new Error("Discord setup cancelled");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function interruptibleSleep(sleep: (ms: number) => Promise<void>, ms: number, signal?: AbortSignal): Promise<void> {
|
|
48
|
+
if (!signal) return sleep(ms);
|
|
49
|
+
if (signal.aborted) throw new Error("Discord setup cancelled");
|
|
50
|
+
let cancel: (() => void) | undefined;
|
|
51
|
+
try {
|
|
52
|
+
await Promise.race([
|
|
53
|
+
sleep(ms),
|
|
54
|
+
new Promise<never>((_resolve, reject) => {
|
|
55
|
+
cancel = () => reject(new Error("Discord setup cancelled"));
|
|
56
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
57
|
+
if (signal.aborted) cancel();
|
|
58
|
+
}),
|
|
59
|
+
]);
|
|
60
|
+
} finally {
|
|
61
|
+
if (cancel) signal.removeEventListener("abort", cancel);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
35
65
|
export interface DiscordSetupIO {
|
|
36
66
|
readonly interactive: boolean;
|
|
37
67
|
say(line: string): void;
|
|
@@ -60,6 +90,11 @@ export interface DiscordSetupDeps {
|
|
|
60
90
|
/** argv that starts this jeo build; defaults to the running runtime and entry. */
|
|
61
91
|
jeoCommand?: string[];
|
|
62
92
|
installService?: typeof installMessengerService;
|
|
93
|
+
/** Cancels invite polling and the remaining setup work (the CLI wires Ctrl+C here). */
|
|
94
|
+
signal?: AbortSignal;
|
|
95
|
+
/** Injectable platform/path separator for cross-platform release tests. */
|
|
96
|
+
platform?: NodeJS.Platform;
|
|
97
|
+
pathDelimiter?: string;
|
|
63
98
|
}
|
|
64
99
|
|
|
65
100
|
export interface DiscordSetupResult {
|
|
@@ -89,18 +124,23 @@ export interface RequestChannel {
|
|
|
89
124
|
|
|
90
125
|
type DiscordApiFn = (method: string, route: string, body?: unknown) => Promise<unknown>;
|
|
91
126
|
|
|
92
|
-
function discordApi(token: string, fetchImpl: typeof fetch): DiscordApiFn {
|
|
93
|
-
return (method, route, body) =>
|
|
94
|
-
|
|
127
|
+
function discordApi(token: string, fetchImpl: typeof fetch, signal?: AbortSignal): DiscordApiFn {
|
|
128
|
+
return async (method, route, body) => {
|
|
129
|
+
throwIfAborted(signal);
|
|
130
|
+
const result = await messengerFetchJson(
|
|
95
131
|
`${DISCORD_API}${route}`,
|
|
96
132
|
{
|
|
97
133
|
method,
|
|
134
|
+
signal,
|
|
98
135
|
headers: { Authorization: `Bot ${token}`, "Content-Type": "application/json" },
|
|
99
136
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
100
137
|
},
|
|
101
138
|
undefined,
|
|
102
139
|
fetchImpl,
|
|
103
140
|
);
|
|
141
|
+
throwIfAborted(signal);
|
|
142
|
+
return result;
|
|
143
|
+
};
|
|
104
144
|
}
|
|
105
145
|
|
|
106
146
|
function str(value: unknown): string | undefined {
|
|
@@ -201,15 +241,21 @@ export async function waitForDiscordGuilds(
|
|
|
201
241
|
io: DiscordSetupIO,
|
|
202
242
|
sleep: (ms: number) => Promise<void>,
|
|
203
243
|
waitMs: number,
|
|
244
|
+
signal?: AbortSignal,
|
|
204
245
|
): Promise<Record<string, unknown>[]> {
|
|
246
|
+
throwIfAborted(signal);
|
|
205
247
|
let guilds = records(await api("GET", "/users/@me/guilds"));
|
|
248
|
+
throwIfAborted(signal);
|
|
206
249
|
if (guilds.length) return guilds;
|
|
207
250
|
if (!io.interactive) throw new Error(`Invite the bot to a server, then rerun: ${inviteUrl}`);
|
|
208
251
|
io.say(`Invite the bot to your server (choose the server and authorize):\n ${inviteUrl}`);
|
|
209
252
|
io.say("Waiting for the bot to join…");
|
|
210
253
|
for (const deadline = Date.now() + waitMs; Date.now() < deadline; ) {
|
|
211
|
-
|
|
254
|
+
throwIfAborted(signal);
|
|
255
|
+
await interruptibleSleep(sleep, Math.min(INVITE_POLL_MS, Math.max(0, deadline - Date.now())), signal);
|
|
256
|
+
throwIfAborted(signal);
|
|
212
257
|
guilds = records(await api("GET", "/users/@me/guilds"));
|
|
258
|
+
throwIfAborted(signal);
|
|
213
259
|
if (guilds.length) return guilds;
|
|
214
260
|
}
|
|
215
261
|
throw new Error(`The bot has not joined a server yet; invite it, then rerun: ${inviteUrl}`);
|
|
@@ -363,8 +409,11 @@ export async function probeDiscordGateway(
|
|
|
363
409
|
api: DiscordApiFn,
|
|
364
410
|
token: string,
|
|
365
411
|
WebSocketImpl: typeof WebSocket,
|
|
412
|
+
signal?: AbortSignal,
|
|
366
413
|
): Promise<void> {
|
|
414
|
+
throwIfAborted(signal);
|
|
367
415
|
const discovery = await api("GET", "/gateway/bot");
|
|
416
|
+
throwIfAborted(signal);
|
|
368
417
|
const url = discordGatewayUrl(isRecord(discovery) ? discovery.url : undefined);
|
|
369
418
|
const result = Promise.withResolvers<void>();
|
|
370
419
|
const socket = new WebSocketImpl(url);
|
|
@@ -372,7 +421,11 @@ export async function probeDiscordGateway(
|
|
|
372
421
|
() => result.reject(new Error("Discord Gateway did not confirm the bot session in time")),
|
|
373
422
|
GATEWAY_PROBE_MS,
|
|
374
423
|
);
|
|
424
|
+
const cancel = () => result.reject(new Error("Discord setup cancelled"));
|
|
425
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
426
|
+
if (signal?.aborted) cancel();
|
|
375
427
|
socket.addEventListener("message", (event: MessageEvent) => {
|
|
428
|
+
if (signal?.aborted) return;
|
|
376
429
|
let packet: unknown;
|
|
377
430
|
try {
|
|
378
431
|
packet = typeof event.data === "string" ? JSON.parse(event.data) : undefined;
|
|
@@ -404,8 +457,10 @@ export async function probeDiscordGateway(
|
|
|
404
457
|
socket.addEventListener("error", () => result.reject(new Error("Discord Gateway connection failed")));
|
|
405
458
|
try {
|
|
406
459
|
await result.promise;
|
|
460
|
+
throwIfAborted(signal);
|
|
407
461
|
} finally {
|
|
408
462
|
clearTimeout(timer);
|
|
463
|
+
signal?.removeEventListener("abort", cancel);
|
|
409
464
|
try {
|
|
410
465
|
socket.close(1000);
|
|
411
466
|
} catch {
|
|
@@ -434,11 +489,30 @@ exec ${command.map(quote).join(" ")} messenger run --config "$dir/${configName}"
|
|
|
434
489
|
`;
|
|
435
490
|
}
|
|
436
491
|
|
|
437
|
-
|
|
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> {
|
|
438
506
|
const temporary = `${file}.${process.pid}.tmp`;
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
+
}
|
|
442
516
|
}
|
|
443
517
|
|
|
444
518
|
async function readExistingConfig(configPath: string): Promise<Record<string, unknown> | undefined> {
|
|
@@ -458,9 +532,69 @@ async function readExistingConfig(configPath: string): Promise<Record<string, un
|
|
|
458
532
|
throw new Error(`${configPath} exists but is not a JSON object; fix or move it before setup`);
|
|
459
533
|
}
|
|
460
534
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
535
|
+
/**
|
|
536
|
+
* The policy admission `messenger run` performs before starting the sandbox worker (commands/launch.ts), with the same
|
|
537
|
+
* private roots: the authority state directory, the session directory, the jeo home, the notify sessions directory and
|
|
538
|
+
* the profile paths from messengerProfilePrivatePaths, which also refuses a workspace that overlaps the profile. The authority state
|
|
539
|
+
* directory is a fresh random temp directory at launch, so a representative path under os.tmpdir() stands in for it.
|
|
540
|
+
* Throws on any refusal, so setup cannot succeed for a workspace that launch would reject.
|
|
541
|
+
*/
|
|
542
|
+
async function admitSandboxWorkspace(sandbox: SandboxConfig, profileDir: string, sessionDir: string, cwd: string): Promise<void> {
|
|
543
|
+
const profilePaths = await messengerProfilePrivatePaths(profileDir, cwd);
|
|
544
|
+
await createSandboxPolicy({
|
|
545
|
+
sessionKey: "setup",
|
|
546
|
+
revision: 0,
|
|
547
|
+
cwd,
|
|
548
|
+
config: sandbox,
|
|
549
|
+
privatePaths: [path.join(os.tmpdir(), "jeo-sandbox-authority-setup"), sessionDir, jeoHomeDir(), notifySessionsDir(), ...profilePaths],
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Create the default workspace (or reuse one that carries the marker an earlier setup run left), then prove `admit`
|
|
555
|
+
* accepts it. The marker is a re-run convention, not proof of ownership; reuse is still gated by `admit`, and an
|
|
556
|
+
* unmarked existing directory is never adopted. Failure cleanup is non-recursive and best-effort: it can only remove
|
|
557
|
+
* the marker this call wrote and the then-empty directory, so concurrently added data survives.
|
|
558
|
+
*/
|
|
559
|
+
async function createDefaultWorkspace(admit: (workspace: string) => Promise<void>): Promise<string> {
|
|
560
|
+
const workspace = path.join(os.homedir(), WORKSPACE_DIR);
|
|
561
|
+
const marker = path.join(workspace, WORKSPACE_MARKER);
|
|
562
|
+
let created = false;
|
|
563
|
+
let wroteMarker = false;
|
|
564
|
+
try {
|
|
565
|
+
await fs.mkdir(workspace, { mode: 0o700 });
|
|
566
|
+
created = true;
|
|
567
|
+
} catch (error) {
|
|
568
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
569
|
+
}
|
|
570
|
+
try {
|
|
571
|
+
if (created) {
|
|
572
|
+
await fs.writeFile(marker, "", { flag: "wx", mode: 0o600 });
|
|
573
|
+
wroteMarker = true;
|
|
574
|
+
} else {
|
|
575
|
+
const [stat, tag] = await Promise.all([fs.lstat(workspace), fs.lstat(marker).catch(() => undefined)]);
|
|
576
|
+
if (!stat.isDirectory() || !tag?.isFile()) {
|
|
577
|
+
throw new Error(`${workspace} already exists without the jeo setup marker`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
await admit(workspace);
|
|
581
|
+
} catch (error) {
|
|
582
|
+
if (wroteMarker) await fs.unlink(marker).catch(() => undefined);
|
|
583
|
+
// rmdir refuses a non-empty directory, so anything added meanwhile is preserved.
|
|
584
|
+
if (created) await fs.rmdir(workspace).catch(() => undefined);
|
|
585
|
+
throw new Error(`Default workspace ${workspace} is unusable (${error instanceof Error ? error.message : String(error)}); set "cwd" in the config to a directory outside private jeo and config roots`, { cause: error });
|
|
586
|
+
}
|
|
587
|
+
return workspace;
|
|
588
|
+
}
|
|
589
|
+
|
|
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);
|
|
464
598
|
}
|
|
465
599
|
|
|
466
600
|
/** Create or repair a Discord messenger profile with as few manual steps as Discord allows. */
|
|
@@ -473,20 +607,47 @@ export async function setupDiscordMessenger(
|
|
|
473
607
|
const configPath = path.resolve(options.configPath);
|
|
474
608
|
const directory = path.dirname(configPath);
|
|
475
609
|
const tokenPath = path.join(directory, TOKEN_FILE);
|
|
476
|
-
const
|
|
610
|
+
const platform = deps.platform ?? process.platform;
|
|
611
|
+
throwIfAborted(deps.signal);
|
|
612
|
+
if (options.service && platform !== "darwin" && platform !== "linux") {
|
|
613
|
+
throw new Error("Background services are supported only on macOS and Linux");
|
|
614
|
+
}
|
|
615
|
+
const runScript = path.join(directory, platform === "win32" ? RUN_SCRIPT_WINDOWS : RUN_SCRIPT_POSIX);
|
|
616
|
+
const delimiter = deps.pathDelimiter ?? path.delimiter;
|
|
617
|
+
const envPath = messengerExecutablePath(process.execPath, env.PATH, delimiter, platform);
|
|
618
|
+
const command = deps.jeoCommand ?? currentJeoCommand();
|
|
619
|
+
const existing = await readExistingConfig(configPath);
|
|
620
|
+
const sandbox = existing?.sandbox !== undefined ? parseMessengerSandbox(existing.sandbox) : parseMessengerSandbox({
|
|
621
|
+
image: await io.ask("Existing local Linux sandbox image (no image will be pulled): "),
|
|
622
|
+
limits: {
|
|
623
|
+
memoryBytes: Number(await io.ask("Sandbox memory limit in bytes: ")),
|
|
624
|
+
pids: Number(await io.ask("Sandbox process limit: ")),
|
|
625
|
+
cpus: Number(await io.ask("Sandbox CPU limit: ")),
|
|
626
|
+
},
|
|
627
|
+
});
|
|
628
|
+
if (existing && Object.hasOwn(existing, "independentSessions")) {
|
|
629
|
+
delete existing.independentSessions;
|
|
630
|
+
io.say("Removed obsolete independentSessions setting. Discord threads now keep one persistent session.");
|
|
631
|
+
}
|
|
632
|
+
if (platform === "win32") discordRunCommand(command, path.basename(configPath), DISCORD_TOKEN_ENV);
|
|
477
633
|
|
|
478
634
|
const token = await resolveToken(env, tokenPath, io);
|
|
479
|
-
|
|
635
|
+
throwIfAborted(deps.signal);
|
|
636
|
+
const api = discordApi(token, deps.fetch ?? fetch, deps.signal);
|
|
480
637
|
const bot = await inspectDiscordBot(api);
|
|
638
|
+
throwIfAborted(deps.signal);
|
|
481
639
|
io.say(`Bot ${bot.username} (${bot.id}), application ${bot.applicationId}`);
|
|
482
640
|
await ensureMessageContentIntent(api, bot, io);
|
|
641
|
+
throwIfAborted(deps.signal);
|
|
483
642
|
|
|
484
643
|
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
644
|
+
throwIfAborted(deps.signal);
|
|
485
645
|
await writePrivate(tokenPath, `${token}\n`, 0o600);
|
|
646
|
+
throwIfAborted(deps.signal);
|
|
486
647
|
|
|
487
648
|
const inviteUrl = discordInviteUrl(bot.applicationId);
|
|
488
|
-
const guilds = await waitForDiscordGuilds(api, inviteUrl, io, deps.sleep ?? Bun.sleep, options.inviteWaitMs ?? 300_000);
|
|
489
|
-
|
|
649
|
+
const guilds = await waitForDiscordGuilds(api, inviteUrl, io, deps.sleep ?? Bun.sleep, options.inviteWaitMs ?? 300_000, deps.signal);
|
|
650
|
+
throwIfAborted(deps.signal);
|
|
490
651
|
const platforms = isRecord(existing?.platforms) ? existing.platforms : {};
|
|
491
652
|
const previous = isRecord(platforms.discord) ? platforms.discord : {};
|
|
492
653
|
const previousList = (key: string): string[] | undefined => {
|
|
@@ -499,52 +660,71 @@ export async function setupDiscordMessenger(
|
|
|
499
660
|
let channelIds: string[];
|
|
500
661
|
if (options.channelIds?.length || !previousList("allowedChannelIds")) {
|
|
501
662
|
const channels = await selectDiscordChannels(api, bot, guilds, options.channelIds, inviteUrl, io);
|
|
663
|
+
throwIfAborted(deps.signal);
|
|
502
664
|
for (const channel of channels) io.say(`Request channel: #${channel.name} in ${channel.guildName}`);
|
|
503
665
|
channelIds = channels.map(channel => channel.id);
|
|
504
666
|
} else {
|
|
505
667
|
channelIds = previousList("allowedChannelIds") as string[];
|
|
506
668
|
}
|
|
507
669
|
const dmChannel = options.dmTest === false ? undefined : await openOwnerDm(api, userIds[0]!, io);
|
|
670
|
+
throwIfAborted(deps.signal);
|
|
508
671
|
const allowedChannelIds = [...new Set([...channelIds, ...(dmChannel ? [dmChannel] : [])])];
|
|
509
672
|
const tokenEnv = str(previous.tokenEnv) ?? DISCORD_TOKEN_ENV;
|
|
673
|
+
if (platform === "win32") discordRunCommand(command, path.basename(configPath), tokenEnv);
|
|
510
674
|
|
|
511
675
|
const discord: Record<string, unknown> = { ...previous, tokenEnv, allowedUserIds: userIds, allowedChannelIds };
|
|
512
676
|
if (options.artifacts) discord.artifactDir = "./artifacts";
|
|
513
|
-
const
|
|
677
|
+
const sessionDir = str(existing?.sessionDir) ?? path.join(notifyDir(), "messenger-sessions");
|
|
678
|
+
let cwd: unknown;
|
|
679
|
+
if (existing && Object.hasOwn(existing, "cwd")) {
|
|
680
|
+
// An explicit value is never replaced; an unusable one fails the loader below before the config is touched.
|
|
681
|
+
cwd = existing.cwd;
|
|
682
|
+
} else {
|
|
683
|
+
cwd = await createDefaultWorkspace(workspace => admitSandboxWorkspace(sandbox, directory, path.resolve(directory, sessionDir), workspace));
|
|
684
|
+
if (existing) io.say(`The existing config had no "cwd"; its sandbox workspace is now ${cwd}. No existing files were moved or deleted.`);
|
|
685
|
+
}
|
|
686
|
+
throwIfAborted(deps.signal);
|
|
514
687
|
const config = {
|
|
515
688
|
...existing,
|
|
516
|
-
cwd
|
|
517
|
-
sessionDir
|
|
518
|
-
|
|
519
|
-
|
|
689
|
+
cwd,
|
|
690
|
+
sessionDir,
|
|
691
|
+
sandbox,
|
|
692
|
+
toolNames: options.toolNames ?? (Array.isArray(existing?.toolNames) ? existing.toolNames : [...DEFAULT_MESSENGER_TOOLS]),
|
|
520
693
|
platforms: { ...platforms, discord },
|
|
521
694
|
};
|
|
522
695
|
for (const relative of [config.cwd, config.sessionDir, discord.artifactDir]) {
|
|
523
696
|
if (typeof relative === "string")
|
|
524
697
|
await fs.mkdir(path.resolve(directory, relative), { recursive: true, mode: 0o700 });
|
|
698
|
+
throwIfAborted(deps.signal);
|
|
525
699
|
}
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
700
|
+
// The loader `messenger run` uses, then the policy admission it performs, must accept the staged file before it replaces a working config.
|
|
701
|
+
await writePrivate(configPath, `${JSON.stringify(config, null, 2)}\n`, 0o600, async staged => {
|
|
702
|
+
const loaded = await loadMessengerConfig(staged);
|
|
703
|
+
validateMessengerEnvironment({ ...loaded, platforms: { discord: loaded.platforms.discord } }, { [tokenEnv]: token });
|
|
704
|
+
try {
|
|
705
|
+
await admitSandboxWorkspace(sandbox, loaded.profileDir ?? directory, loaded.sessionDir, loaded.cwd);
|
|
706
|
+
} catch (error) {
|
|
707
|
+
throw new Error(`Sandbox workspace ${loaded.cwd} would be refused at launch (${error instanceof Error ? error.message : String(error)}); set "cwd" in the config to a directory outside private jeo and config roots`, { cause: error });
|
|
708
|
+
}
|
|
709
|
+
});
|
|
710
|
+
throwIfAborted(deps.signal);
|
|
530
711
|
await writePrivate(
|
|
531
712
|
runScript,
|
|
532
|
-
|
|
713
|
+
platform === "win32"
|
|
714
|
+
? discordRunCommand(command, path.basename(configPath), tokenEnv)
|
|
715
|
+
: discordRunScript(command, path.basename(configPath), tokenEnv, envPath),
|
|
533
716
|
0o700,
|
|
534
717
|
);
|
|
535
|
-
|
|
536
|
-
const written = await loadMessengerConfig(configPath);
|
|
537
|
-
validateMessengerEnvironment(
|
|
538
|
-
{ ...written, platforms: { discord: written.platforms.discord } },
|
|
539
|
-
{ [tokenEnv]: token },
|
|
540
|
-
);
|
|
718
|
+
throwIfAborted(deps.signal);
|
|
541
719
|
|
|
542
|
-
await probeDiscordGateway(api, token, deps.WebSocket ?? WebSocket);
|
|
720
|
+
await probeDiscordGateway(api, token, deps.WebSocket ?? WebSocket, deps.signal);
|
|
721
|
+
throwIfAborted(deps.signal);
|
|
543
722
|
io.say("Discord Gateway accepted the bot session and intents.");
|
|
544
723
|
|
|
545
724
|
let service: string | undefined;
|
|
546
725
|
if (options.service) {
|
|
547
726
|
service = await (deps.installService ?? installMessengerService)(messengerServicePlan(directory, runScript));
|
|
727
|
+
throwIfAborted(deps.signal);
|
|
548
728
|
io.say(`Gateway running as ${service}`);
|
|
549
729
|
}
|
|
550
730
|
if (dmChannel) {
|
|
@@ -555,8 +735,10 @@ export async function setupDiscordMessenger(
|
|
|
555
735
|
io.say(
|
|
556
736
|
"Could not send the test DM (the owner may block DMs from server members); server requests still work.",
|
|
557
737
|
);
|
|
738
|
+
throwIfAborted(deps.signal);
|
|
558
739
|
}
|
|
559
740
|
}
|
|
741
|
+
throwIfAborted(deps.signal);
|
|
560
742
|
return {
|
|
561
743
|
configPath,
|
|
562
744
|
botName: bot.username,
|
package/src/messenger/discord.ts
CHANGED
|
@@ -335,14 +335,15 @@ export function createDiscordAdapter(
|
|
|
335
335
|
): MessengerAdapter {
|
|
336
336
|
const token = resolveMessengerToken(config.tokenEnv, env)?.trim();
|
|
337
337
|
const clock = deps.clock ?? defaultClock;
|
|
338
|
-
const createdThreads = new Map<string, { parentChannelId: string
|
|
339
|
-
const rememberThread = (threadId: string, parentChannelId: string
|
|
340
|
-
createdThreads.set(threadId, { parentChannelId
|
|
338
|
+
const createdThreads = new Map<string, { parentChannelId: string }>();
|
|
339
|
+
const rememberThread = (threadId: string, parentChannelId: string): void => {
|
|
340
|
+
createdThreads.set(threadId, { parentChannelId });
|
|
341
341
|
if (createdThreads.size > 10_000) {
|
|
342
342
|
const oldest = createdThreads.keys().next().value;
|
|
343
343
|
if (oldest !== undefined) createdThreads.delete(oldest);
|
|
344
344
|
}
|
|
345
345
|
};
|
|
346
|
+
const normalizationTails = new Map<string, Promise<void>>();
|
|
346
347
|
const activityMessages = new Map<string, string>();
|
|
347
348
|
const headers = (): Record<string, string> => {
|
|
348
349
|
if (!token) throw new DiscordConfigurationError("Discord bot token is missing");
|
|
@@ -386,57 +387,50 @@ export function createDiscordAdapter(
|
|
|
386
387
|
initialUrl = discordGatewayUrl(gateway.url);
|
|
387
388
|
}
|
|
388
389
|
const deliver = async (message: MessengerMessage): Promise<void> => {
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
390
|
+
const previous = normalizationTails.get(message.channelId) ?? Promise.resolve();
|
|
391
|
+
const ready = Promise.withResolvers<void>();
|
|
392
|
+
const tail = previous.then(() => ready.promise);
|
|
393
|
+
normalizationTails.set(message.channelId, tail);
|
|
394
|
+
const forward = (destination: MessengerMessage): Promise<void> => {
|
|
395
|
+
ready.resolve();
|
|
396
|
+
return onMessage(destination);
|
|
397
|
+
};
|
|
398
|
+
await previous;
|
|
399
|
+
try {
|
|
400
|
+
if (signal.aborted) return;
|
|
401
|
+
const thread = createdThreads.get(message.channelId);
|
|
402
|
+
if (thread && config.allowedUserIds.includes(message.senderId)) {
|
|
403
|
+
await forward({ ...message, threadId: message.channelId, parentChannelId: thread.parentChannelId });
|
|
404
|
+
} else if (!message.direct && config.allowedUserIds.includes(message.senderId)) {
|
|
403
405
|
const channel = await messengerFetchJson(
|
|
404
406
|
`${DISCORD_API}/channels/${encodeURIComponent(message.channelId)}`,
|
|
405
|
-
{ headers: headers() },
|
|
406
|
-
signal,
|
|
407
|
-
deps.fetch,
|
|
407
|
+
{ headers: headers() }, signal, deps.fetch,
|
|
408
408
|
);
|
|
409
|
-
if (
|
|
410
|
-
|
|
411
|
-
channel.
|
|
412
|
-
typeof channel.type === "number" &&
|
|
413
|
-
[10, 11, 12].includes(channel.type) &&
|
|
414
|
-
typeof channel.parent_id === "string" &&
|
|
415
|
-
channel.parent_id
|
|
416
|
-
) {
|
|
409
|
+
if (!isRecord(channel) || channel.id !== message.channelId) return;
|
|
410
|
+
if (typeof channel.type === "number" && [10, 11, 12].includes(channel.type)) {
|
|
411
|
+
if (typeof channel.parent_id !== "string" || !channel.parent_id) return;
|
|
417
412
|
if (!message.mentioned) {
|
|
418
413
|
const originalId = typeof channel.name === "string" ? /^Request (\d{1,20})$/.exec(channel.name)?.[1] : undefined;
|
|
419
414
|
if (channel.owner_id !== accountId || !originalId ||
|
|
420
|
-
(!config.allowedChannelIds.includes(channel.parent_id) && !config.allowedChannelIds.includes(message.channelId))) return;
|
|
415
|
+
(config.allowedChannelIds && !config.allowedChannelIds.includes(channel.parent_id) && !config.allowedChannelIds.includes(message.channelId))) return;
|
|
421
416
|
const original = await messengerFetchJson(
|
|
422
417
|
`${DISCORD_API}/channels/${encodeURIComponent(channel.parent_id)}/messages/${originalId}`,
|
|
423
418
|
{ headers: headers() }, signal, deps.fetch,
|
|
424
419
|
);
|
|
425
420
|
if (!isRecord(original) || original.id !== originalId || original.channel_id !== channel.parent_id ||
|
|
426
|
-
!isRecord(original.author) || original.author.id !==
|
|
427
|
-
rememberThread(message.channelId, channel.parent_id, message.senderId);
|
|
421
|
+
!isRecord(original.author) || typeof original.author.id !== "string" || original.author.bot === true || original.webhook_id) return;
|
|
428
422
|
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
});
|
|
423
|
+
rememberThread(message.channelId, channel.parent_id);
|
|
424
|
+
await forward({ ...message, threadId: message.channelId, parentChannelId: channel.parent_id });
|
|
425
|
+
} else {
|
|
426
|
+
await forward(message);
|
|
434
427
|
}
|
|
435
|
-
}
|
|
436
|
-
|
|
428
|
+
} else {
|
|
429
|
+
await forward(message);
|
|
437
430
|
}
|
|
438
|
-
}
|
|
439
|
-
|
|
431
|
+
} finally {
|
|
432
|
+
ready.resolve();
|
|
433
|
+
if (normalizationTails.get(message.channelId) === tail) normalizationTails.delete(message.channelId);
|
|
440
434
|
}
|
|
441
435
|
};
|
|
442
436
|
await connectGateway(
|
|
@@ -479,7 +473,7 @@ export function createDiscordAdapter(
|
|
|
479
473
|
async prepare(message, signal) {
|
|
480
474
|
if (message.direct) return message;
|
|
481
475
|
if (message.threadId) {
|
|
482
|
-
if (message.parentChannelId) rememberThread(message.threadId, message.parentChannelId
|
|
476
|
+
if (message.parentChannelId) rememberThread(message.threadId, message.parentChannelId);
|
|
483
477
|
return message;
|
|
484
478
|
}
|
|
485
479
|
const channel = await messengerFetchJson(
|
|
@@ -493,7 +487,7 @@ export function createDiscordAdapter(
|
|
|
493
487
|
if ([10, 11, 12].includes(channel.type)) {
|
|
494
488
|
if (typeof channel.parent_id !== "string" || !channel.parent_id)
|
|
495
489
|
throw new Error("Discord thread parent unavailable");
|
|
496
|
-
rememberThread(message.channelId, channel.parent_id
|
|
490
|
+
rememberThread(message.channelId, channel.parent_id);
|
|
497
491
|
return { ...message, threadId: message.channelId, parentChannelId: channel.parent_id };
|
|
498
492
|
}
|
|
499
493
|
if (channel.type !== 0 && channel.type !== 5) throw new Error("Discord channel cannot host a reply thread");
|
|
@@ -505,7 +499,7 @@ export function createDiscordAdapter(
|
|
|
505
499
|
);
|
|
506
500
|
if (!isRecord(thread) || typeof thread.id !== "string" || !thread.id)
|
|
507
501
|
throw new Error("Discord thread creation returned no ID");
|
|
508
|
-
rememberThread(thread.id, message.channelId
|
|
502
|
+
rememberThread(thread.id, message.channelId);
|
|
509
503
|
return { ...message, threadId: thread.id };
|
|
510
504
|
},
|
|
511
505
|
async status(message, state, signal) {
|