jeo-code 0.12.13 → 0.12.15
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 +42 -0
- package/README.ja.md +32 -8
- package/README.ko.md +32 -8
- package/README.md +32 -8
- package/README.zh.md +32 -8
- 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 +295 -0
- package/src/agent/sandbox/bundle.ts +37 -0
- package/src/agent/sandbox/control.ts +49 -0
- package/src/agent/sandbox/docker.ts +283 -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 +155 -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 +261 -35
- package/src/commands/messenger.ts +105 -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 +51 -9
- package/src/messenger/discord-setup.ts +122 -20
- package/src/messenger/discord.ts +37 -43
- package/src/messenger/gateway.ts +33 -10
- package/src/messenger/model-selection.ts +101 -0
- package/src/messenger/session-pool.ts +189 -35
- package/src/messenger/types.ts +3 -2
- package/src/skills/catalog.ts +207 -0
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
import type { ToolHandler } from "./engine";
|
|
7
7
|
import type { ToolResult } from "./tools";
|
|
8
8
|
import type { MonitorRecord, MonitorRegistry } from "./monitor-registry";
|
|
9
|
+
import { settlesWithin } from "./shell-lease";
|
|
10
|
+
|
|
11
|
+
/** Upper bound on pipe-linger, escape detection and notice-queue drain after a cancel. */
|
|
12
|
+
const CANCEL_SETTLE_MS = 3000;
|
|
9
13
|
|
|
10
14
|
/** One-line protocol description appended to the launch system prompt. */
|
|
11
15
|
export const MONITOR_TOOL_PROTOCOL_LINE =
|
|
@@ -26,7 +30,8 @@ function elapsed(record: MonitorRecord): string {
|
|
|
26
30
|
|
|
27
31
|
function rowLine(record: MonitorRecord): string {
|
|
28
32
|
const mode = record.persistent ? "persistent" : "one-shot";
|
|
29
|
-
|
|
33
|
+
const exit = record.exitCode != null ? ` (exit code ${record.exitCode})` : "";
|
|
34
|
+
return `- ${record.id} [${record.status.toUpperCase()}]${exit} ${mode} ${elapsed(record)} · ${truncate(record.command, 100)}`;
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
function idsOf(args: Record<string, any>): string[] {
|
|
@@ -36,7 +41,7 @@ function idsOf(args: Record<string, any>): string[] {
|
|
|
36
41
|
}
|
|
37
42
|
|
|
38
43
|
export function createMonitorTool(registry: MonitorRegistry): ToolHandler {
|
|
39
|
-
|
|
44
|
+
const handle: ToolHandler = async (args: Record<string, any>, cwd: string): Promise<ToolResult> => {
|
|
40
45
|
const action = String(args.action ?? "list").trim().toLowerCase();
|
|
41
46
|
const ids = idsOf(args);
|
|
42
47
|
|
|
@@ -77,24 +82,47 @@ export function createMonitorTool(registry: MonitorRegistry): ToolHandler {
|
|
|
77
82
|
}
|
|
78
83
|
|
|
79
84
|
if (action === "await") {
|
|
80
|
-
const targets = ids.length ? ids : registry.
|
|
85
|
+
const targets = ids.length ? ids : registry.unobserved().map(record => record.id);
|
|
81
86
|
if (targets.length === 0) return { success: true, output: "No running monitors to await." };
|
|
87
|
+
const unknown = targets.filter(id => !registry.get(id));
|
|
88
|
+
if (unknown.length > 0) return { success: false, output: "", error: `No monitor matches ${unknown.join(", ")}.` };
|
|
82
89
|
const timeoutMs = typeof args.timeoutMs === "number" && args.timeoutMs > 0 ? args.timeoutMs : undefined;
|
|
83
90
|
const records = await registry.awaitIds(targets, timeoutMs);
|
|
84
91
|
const stillRunning = records.filter(record => record.status === "running").length;
|
|
85
92
|
const head = stillRunning > 0
|
|
86
93
|
? `Awaited ${records.length} monitor(s); ${stillRunning} still running after the ${timeoutMs}ms timeout — await again or cancel.`
|
|
87
94
|
: `Awaited ${records.length} monitor(s); all settled.`;
|
|
88
|
-
return { success: stillRunning === 0, output: `${head}\n\n${records.map(rowLine).join("\n")}` };
|
|
95
|
+
return { success: stillRunning === 0 && records.every(record => record.status !== "failed" && (record.status !== "exited" || record.exitCode === 0)), output: `${head}\n\n${records.map(rowLine).join("\n")}` };
|
|
89
96
|
}
|
|
90
97
|
|
|
91
98
|
if (action === "cancel") {
|
|
92
|
-
const
|
|
99
|
+
const cancellable = new Set(registry.cancellable().map(record => record.id));
|
|
100
|
+
const targets = ids.length ? [...new Set(ids)] : [...cancellable];
|
|
93
101
|
if (targets.length === 0) return { success: true, output: "No running monitors to cancel." };
|
|
102
|
+
const unknown = targets.filter(id => !registry.get(id));
|
|
103
|
+
if (unknown.length > 0) return { success: false, output: "", error: `No monitor matches ${unknown.join(", ")}.` };
|
|
104
|
+
const attempted = targets.filter(id => cancellable.has(id));
|
|
94
105
|
const records = registry.cancel(targets);
|
|
95
|
-
|
|
106
|
+
// A delivered SIGKILL is only verified once the output pipes close; settlement can still
|
|
107
|
+
// downgrade `killed` to `failed` (a holder that left the group), so wait before judging.
|
|
108
|
+
const settled = attempted.length === 0 || await settlesWithin(registry.awaitIds(attempted), CANCEL_SETTLE_MS);
|
|
109
|
+
const failed = records.filter(record => cancellable.has(record.id) && (!settled || record.status !== "killed")).length;
|
|
110
|
+
const head = attempted.length ? `Cancelled ${attempted.length - failed} monitor(s); ${failed} cancellation(s) failed; ${records.length - attempted.length} already settled.` : "No running monitors to cancel; requested monitors are already settled.";
|
|
111
|
+
return { success: failed === 0, output: `${head}\n${records.map(rowLine).join("\n")}`, mutationMayHaveOccurred: attempted.length > 0 };
|
|
96
112
|
}
|
|
97
113
|
|
|
98
114
|
return { success: false, output: "", error: `Unknown monitor action '${action}'. Use start | list | tail | await | cancel.` };
|
|
99
115
|
};
|
|
116
|
+
return async (args, cwd) => {
|
|
117
|
+
const action = String(args.action ?? "list").trim().toLowerCase();
|
|
118
|
+
const ids = idsOf(args);
|
|
119
|
+
const targets = action === "list" ? registry.list().map(r => r.id)
|
|
120
|
+
: action === "await" ? (ids.length ? ids : registry.unobserved().map(r => r.id))
|
|
121
|
+
: action === "tail" ? (ids.length ? ids : registry.running().map(r => r.id))
|
|
122
|
+
: action === "cancel" ? (ids.length ? ids : registry.cancellable().map(r => r.id))
|
|
123
|
+
: [];
|
|
124
|
+
const result = await handle(args, cwd);
|
|
125
|
+
if (!result.error && registry.consumeMutations(targets)) result.mutationMayHaveOccurred = true;
|
|
126
|
+
return result;
|
|
127
|
+
};
|
|
100
128
|
}
|
|
@@ -57,19 +57,23 @@ function gatewayUrl(raw: string): string {
|
|
|
57
57
|
return url.href;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
const HELP = "Discord controls:\n/sessions\n/subagents\n/send <unique-session> <text>\n/steer <unique-session> <subagent-id> <text>\n/cancel <unique-session> <subagent-id>\nReply to a session notification to send that session text. Other slash commands are not executed.";
|
|
60
|
+
const HELP = "Discord controls:\n/sessions\n/subagents\n/send <unique-session> <text>\n/steer <unique-session> <subagent-id> <text>\n/cancel <unique-session> <subagent-id>\n/send <unique-session> /sandbox status\n/send <unique-session> /sandbox approve <requestId>\n/send <unique-session> /sandbox deny <requestId>\n/send <unique-session> /sandbox revoke\nReply to a session notification to send that session text or a sandbox access control. Other slash commands are not executed.";
|
|
61
61
|
|
|
62
62
|
/** One provider adapter inside the existing daemon; local commands use SessionNotifyEndpoint only. */
|
|
63
63
|
export class DiscordDaemon {
|
|
64
64
|
readonly sessions = new Map<string, DiscordSession>();
|
|
65
65
|
private readonly api: NonNullable<DiscordDaemonDependencies["api"]>;
|
|
66
66
|
private readonly timers = new Set<Timer>();
|
|
67
|
-
private readonly pending = new Map<string, { sessionId: string; finish: (ok: boolean) => void }>();
|
|
67
|
+
private readonly pending = new Map<string, { sessionId: string; finish: (ok: boolean, output?: string) => void }>();
|
|
68
68
|
private readonly remote = new Map<string, { sessionId: string; acknowledged: boolean; finish: (result: RemoteResult) => void }>();
|
|
69
69
|
private readonly staging = new Map<AbortController, string>();
|
|
70
|
+
private readonly promptTails = new Map<string, Promise<void>>();
|
|
71
|
+
private queuedPrompts = 0;
|
|
72
|
+
private readonly preparationTails = new Map<string, Promise<void>>();
|
|
73
|
+
private readonly preparations = new Set<() => void>();
|
|
70
74
|
private readonly replies = new Map<string, { sessionId: string; at: number }>();
|
|
71
75
|
private readonly seen = new Set<string>();
|
|
72
|
-
private readonly threads = new
|
|
76
|
+
private readonly threads = new Set<string>();
|
|
73
77
|
private readonly relayDestinations = new Map<string, string>();
|
|
74
78
|
private readonly relayVersions = new Map<string, number>();
|
|
75
79
|
private channelType = -1;
|
|
@@ -135,8 +139,7 @@ export class DiscordDaemon {
|
|
|
135
139
|
if (![0, 1, 5, 10, 11, 12].includes(channel.type)) throw new Error("Unsupported Discord channel type");
|
|
136
140
|
this.channelType = channel.type;
|
|
137
141
|
if ([10, 11, 12].includes(channel.type)) {
|
|
138
|
-
|
|
139
|
-
this.threads.set(channel.id, channel.owner_id);
|
|
142
|
+
this.threads.add(channel.id);
|
|
140
143
|
}
|
|
141
144
|
this.intents = channel.type === 1 ? 1 << 12 : (1 << 9) | (1 << 15);
|
|
142
145
|
this.gatewayInfo = await this.api.getGatewayBot();
|
|
@@ -156,6 +159,8 @@ export class DiscordDaemon {
|
|
|
156
159
|
|
|
157
160
|
stop(): void {
|
|
158
161
|
this.stopped = true;
|
|
162
|
+
for (const release of this.preparations) release();
|
|
163
|
+
this.preparationTails.clear();
|
|
159
164
|
for (const controller of this.staging.keys()) controller.abort();
|
|
160
165
|
for (const timer of this.timers) (this.deps.clearTimeout ?? clearTimeout)(timer);
|
|
161
166
|
this.timers.clear();
|
|
@@ -352,7 +357,7 @@ export class DiscordDaemon {
|
|
|
352
357
|
private onSessionFrame(session: DiscordSession, frame: Record<string, unknown>): void {
|
|
353
358
|
if (frame.type === "ack" && typeof frame.reqId === "string" && typeof frame.ok === "boolean") {
|
|
354
359
|
const request = this.pending.get(frame.reqId);
|
|
355
|
-
if (request?.sessionId === session.sessionId) request.finish(frame.ok);
|
|
360
|
+
if (request?.sessionId === session.sessionId) request.finish(frame.ok, typeof frame.output === "string" ? frame.output : undefined);
|
|
356
361
|
return;
|
|
357
362
|
}
|
|
358
363
|
if (frame.sessionId !== session.sessionId) return;
|
|
@@ -453,7 +458,18 @@ export class DiscordDaemon {
|
|
|
453
458
|
return promise;
|
|
454
459
|
}
|
|
455
460
|
|
|
456
|
-
private
|
|
461
|
+
private sandboxControl(session: DiscordSession, text: string, userId: string): Promise<{ ok: boolean; output: string }> {
|
|
462
|
+
if (!session.controlEnabled || this.pending.size >= 128) return Promise.resolve({ ok: false, output: "Discord sandbox control is disabled or unavailable." });
|
|
463
|
+
const reqId = crypto.randomUUID();
|
|
464
|
+
const { promise, resolve } = Promise.withResolvers<{ ok: boolean; output: string }>();
|
|
465
|
+
const timer = this.later(() => finish(false, "Sandbox control timed out; outcome is unconfirmed."), 30_000);
|
|
466
|
+
const finish = (ok: boolean, output = "Sandbox control was not acknowledged.") => { this.clear(timer); this.pending.delete(reqId); resolve({ ok, output }); };
|
|
467
|
+
this.pending.set(reqId, { sessionId: session.sessionId, finish });
|
|
468
|
+
if (!this.send(session, { type: "sandbox_control", sessionId: session.sessionId, reqId, text, actor: { origin: "discord", sessionKey: session.sessionId, userId }, controlGeneration: session.controlGeneration })) finish(false);
|
|
469
|
+
return promise;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
private remoteRequest(session: DiscordSession, text: string, reqId: string, controlGeneration: number, userId: string): Promise<RemoteResult> {
|
|
457
473
|
if (this.remote.size >= 128 || this.pending.size >= 128) return Promise.resolve({ ok: false });
|
|
458
474
|
const { promise, resolve } = Promise.withResolvers<RemoteResult>();
|
|
459
475
|
const cancel = () => {
|
|
@@ -471,7 +487,7 @@ export class DiscordDaemon {
|
|
|
471
487
|
const remote = { sessionId: session.sessionId, acknowledged: false, finish };
|
|
472
488
|
this.remote.set(reqId, remote);
|
|
473
489
|
this.pending.set(reqId, { sessionId: session.sessionId, finish: ok => { this.clear(ackTimer); this.pending.delete(reqId); if (ok) remote.acknowledged = true; else finish({ ok: false }); } });
|
|
474
|
-
if (!this.send(session, { type: "remote_request", sessionId: session.sessionId, reqId, text, staged: true, controlGeneration })) finish({ ok: false });
|
|
490
|
+
if (!this.send(session, { type: "remote_request", sessionId: session.sessionId, reqId, text, staged: true, controlGeneration, actor: { origin: "discord", sessionKey: session.sessionId, userId } })) finish({ ok: false });
|
|
475
491
|
return promise;
|
|
476
492
|
}
|
|
477
493
|
|
|
@@ -482,48 +498,99 @@ export class DiscordDaemon {
|
|
|
482
498
|
message.author.bot === true || (message.author.bot !== undefined && message.author.bot !== false) ||
|
|
483
499
|
"webhook_id" in message || ![0, 19].includes(message.type as number) ||
|
|
484
500
|
typeof message.content !== "string" || (!message.content.trim() && !(Array.isArray(message.attachments) && message.attachments.length)) || message.content.length > 4_000 || this.seen.has(message.id)) return;
|
|
485
|
-
this.seen.add(message.id);
|
|
486
|
-
if (this.seen.size > 2048) this.seen.delete(this.seen.values().next().value!);
|
|
487
501
|
const channelId = message.channel_id;
|
|
488
|
-
// Preserve the validated scalar values across the channel lookup await. TypeScript
|
|
489
|
-
// intentionally does not retain narrowing through an await on a mutable object.
|
|
490
|
-
const authorId = message.author.id;
|
|
491
502
|
const messageId = message.id;
|
|
492
|
-
|
|
493
|
-
const channel = await this.api.getChannel(channelId).catch(() => undefined);
|
|
494
|
-
if (!channel || ![10, 11, 12].includes(channel.type) || channel.parent_id !== this.config.channelId || channel.owner_id !== authorId) return;
|
|
495
|
-
this.threads.set(channelId, authorId);
|
|
496
|
-
}
|
|
497
|
-
if ((channelId !== this.config.channelId || [10, 11, 12].includes(this.channelType)) && this.threads.get(channelId) !== authorId) return;
|
|
498
|
-
let text = message.content.trim();
|
|
499
|
-
for (const mention of [`<@${this.botId}>`, `<@!${this.botId}>`]) {
|
|
500
|
-
if (text === mention || text.startsWith(`${mention} `) || text.startsWith(`${mention}\n`)) {
|
|
501
|
-
text = text.slice(mention.length).trimStart();
|
|
502
|
-
break;
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
if (!text && !(Array.isArray(message.attachments) && message.attachments.length)) return;
|
|
506
|
-
let destination = channelId;
|
|
507
|
-
try {
|
|
508
|
-
if (channelId === this.config.channelId && [0, 5].includes(this.channelType)) {
|
|
509
|
-
destination = (await this.api.createThread(channelId, messageId, "jeo reply")).id;
|
|
510
|
-
this.threads.set(destination, authorId);
|
|
511
|
-
}
|
|
512
|
-
} catch (error) {
|
|
513
|
-
if (error instanceof DiscordApiError && error.status === 401) this.fail("Discord authentication denied");
|
|
514
|
-
else { this.deps.onError?.("Discord thread creation failed"); void this.emit("Unable to create a reply thread; check bot permissions.", undefined, channelId); }
|
|
515
|
-
return;
|
|
516
|
-
}
|
|
503
|
+
const content = message.content;
|
|
517
504
|
const reaction = async (emoji: string, remove = false) => {
|
|
505
|
+
if (this.stopped) return;
|
|
518
506
|
try { await (remove ? this.api.removeReaction(channelId, messageId, emoji) : this.api.addReaction(channelId, messageId, emoji)); }
|
|
519
507
|
catch (error) {
|
|
520
508
|
if (error instanceof DiscordApiError && error.status === 401) this.fail("Discord authentication denied");
|
|
521
509
|
else this.deps.onError?.("Discord reaction failed");
|
|
522
510
|
}
|
|
523
511
|
};
|
|
524
|
-
|
|
512
|
+
if (this.preparations.size >= 128) {
|
|
513
|
+
this.deps.onError?.("Discord message preparation queue full");
|
|
514
|
+
await reaction("❌");
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
this.seen.add(messageId);
|
|
518
|
+
if (this.seen.size > 2048) this.seen.delete(this.seen.values().next().value!);
|
|
519
|
+
// Reserve arrival order before metadata/thread creation can yield. Senders sharing
|
|
520
|
+
// a Discord channel share this admission lane, not the lifetime of a model turn.
|
|
521
|
+
const key = JSON.stringify([this.botId, typeof message.guild_id === "string" ? message.guild_id : "", channelId]);
|
|
522
|
+
const previous = this.preparationTails.get(key) ?? Promise.resolve();
|
|
523
|
+
const ready = Promise.withResolvers<void>();
|
|
524
|
+
const cancelled = Promise.withResolvers<void>();
|
|
525
|
+
const tail = previous.then(() => ready.promise);
|
|
526
|
+
let createdThreadKey: string | undefined;
|
|
527
|
+
const release = () => {
|
|
528
|
+
if (!this.preparations.delete(release)) return;
|
|
529
|
+
ready.resolve();
|
|
530
|
+
cancelled.resolve();
|
|
531
|
+
if (this.preparationTails.get(key) === tail) this.preparationTails.delete(key);
|
|
532
|
+
if (createdThreadKey && this.preparationTails.get(createdThreadKey) === tail) this.preparationTails.delete(createdThreadKey);
|
|
533
|
+
};
|
|
534
|
+
this.preparationTails.set(key, tail);
|
|
535
|
+
this.preparations.add(release);
|
|
536
|
+
let command: Promise<boolean>;
|
|
537
|
+
let progress: Promise<void>;
|
|
525
538
|
try {
|
|
526
|
-
|
|
539
|
+
await Promise.race([previous, cancelled.promise]);
|
|
540
|
+
if (this.stopped) return;
|
|
541
|
+
if (channelId !== this.config.channelId && !this.threads.has(channelId)) {
|
|
542
|
+
const channel = await Promise.race([
|
|
543
|
+
this.api.getChannel(channelId).catch((error: unknown) => {
|
|
544
|
+
if (this.stopped) return undefined;
|
|
545
|
+
this.seen.delete(messageId);
|
|
546
|
+
if (error instanceof DiscordApiError && error.status === 401) this.fail("Discord authentication denied");
|
|
547
|
+
else {
|
|
548
|
+
this.deps.onError?.("Discord channel metadata lookup failed");
|
|
549
|
+
void reaction("❌");
|
|
550
|
+
}
|
|
551
|
+
return undefined;
|
|
552
|
+
}),
|
|
553
|
+
cancelled.promise.then(() => undefined),
|
|
554
|
+
]);
|
|
555
|
+
if (this.stopped || !channel || ![10, 11, 12].includes(channel.type) || channel.parent_id !== this.config.channelId) return;
|
|
556
|
+
this.threads.add(channelId);
|
|
557
|
+
}
|
|
558
|
+
let text = content.trim();
|
|
559
|
+
for (const mention of [`<@${this.botId}>`, `<@!${this.botId}>`]) {
|
|
560
|
+
if (text === mention || text.startsWith(`${mention} `) || text.startsWith(`${mention}\n`)) {
|
|
561
|
+
text = text.slice(mention.length).trimStart();
|
|
562
|
+
break;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
if (!text && !(Array.isArray(message.attachments) && message.attachments.length)) return;
|
|
566
|
+
let destination = channelId;
|
|
567
|
+
try {
|
|
568
|
+
if (channelId === this.config.channelId && [0, 5].includes(this.channelType)) {
|
|
569
|
+
createdThreadKey = JSON.stringify([this.botId, typeof message.guild_id === "string" ? message.guild_id : "", messageId]);
|
|
570
|
+
this.preparationTails.set(createdThreadKey, tail);
|
|
571
|
+
const thread = await Promise.race([
|
|
572
|
+
this.api.createThread(channelId, messageId, "jeo reply"),
|
|
573
|
+
cancelled.promise.then(() => undefined),
|
|
574
|
+
]);
|
|
575
|
+
if (this.stopped || !thread) return;
|
|
576
|
+
destination = thread.id;
|
|
577
|
+
this.threads.add(destination);
|
|
578
|
+
}
|
|
579
|
+
} catch (error) {
|
|
580
|
+
if (this.stopped) return;
|
|
581
|
+
if (error instanceof DiscordApiError && error.status === 401) this.fail("Discord authentication denied");
|
|
582
|
+
else { this.deps.onError?.("Discord thread creation failed"); void this.emit("Unable to create a reply thread; check bot permissions.", undefined, channelId); }
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
progress = reaction("⏳");
|
|
586
|
+
// handleCommand installs a normal prompt's canonical session tail before its
|
|
587
|
+
// first await. Release admission now so sandbox controls can bypass that tail.
|
|
588
|
+
command = this.handleCommand(message, text, destination);
|
|
589
|
+
} finally {
|
|
590
|
+
release();
|
|
591
|
+
}
|
|
592
|
+
try {
|
|
593
|
+
const ok = await command;
|
|
527
594
|
void progress.then(() => reaction("⏳", true)).then(() => reaction(ok ? "✅" : "❌"));
|
|
528
595
|
} catch {
|
|
529
596
|
void progress.then(() => reaction("⏳", true)).then(() => reaction("❌"));
|
|
@@ -534,6 +601,26 @@ export class DiscordDaemon {
|
|
|
534
601
|
private async handleCommand(message: Record<string, unknown>, text: string, destination: string): Promise<boolean> {
|
|
535
602
|
const emit = (content: string, sessionId?: string) => this.emit(content, sessionId, destination);
|
|
536
603
|
const sendPrompt = async (session: DiscordSession, body: string): Promise<boolean> => {
|
|
604
|
+
if (/^\/sandbox(?:\s|$)/.test(body)) {
|
|
605
|
+
const author = object(message.author) && typeof message.author.id === "string" ? message.author.id : "";
|
|
606
|
+
if (!this.allowed.has(author)) return false;
|
|
607
|
+
const result = await this.sandboxControl(session, body, author);
|
|
608
|
+
await emit(result.output, session.sessionId);
|
|
609
|
+
return result.ok;
|
|
610
|
+
}
|
|
611
|
+
if (this.queuedPrompts >= 128) { await emit("Session request queue is full.", session.sessionId); return false; }
|
|
612
|
+
const generation = session.controlGeneration;
|
|
613
|
+
const previous = this.promptTails.get(session.sessionId) ?? Promise.resolve();
|
|
614
|
+
const ready = Promise.withResolvers<void>();
|
|
615
|
+
const tail = previous.then(() => ready.promise);
|
|
616
|
+
this.promptTails.set(session.sessionId, tail);
|
|
617
|
+
this.queuedPrompts++;
|
|
618
|
+
await previous;
|
|
619
|
+
try {
|
|
620
|
+
if (this.stopped || this.sessions.get(session.sessionId) !== session || session.controlGeneration !== generation) {
|
|
621
|
+
await emit("Queued request cancelled because session control changed.", session.sessionId);
|
|
622
|
+
return false;
|
|
623
|
+
}
|
|
537
624
|
if (/^[\/!$]/.test(body) || body.includes("\u0003")) {
|
|
538
625
|
await emit("Remote commands are disabled; send ordinary prompt text.");
|
|
539
626
|
return false;
|
|
@@ -556,7 +643,8 @@ export class DiscordDaemon {
|
|
|
556
643
|
output = await createDiscordArtifactStage(session.sessionId, reqId);
|
|
557
644
|
if (Array.isArray(message.attachments) && message.attachments.length) input = await (this.deps.stageAttachments ?? stageDiscordAttachments)(message.attachments, { directory: path.join(output.requestDirectory, "input"), signal: controller.signal });
|
|
558
645
|
if (controller.signal.aborted || this.sessions.get(session.sessionId) !== session || !session.controlEnabled || session.controlGeneration !== controlGeneration) return false;
|
|
559
|
-
const
|
|
646
|
+
const author = object(message.author) && typeof message.author.id === "string" ? message.author.id : "";
|
|
647
|
+
const result = await this.remoteRequest(session, body || "Please inspect the attached files.", reqId, controlGeneration, author);
|
|
560
648
|
if (!result.ok || result.text === undefined) {
|
|
561
649
|
await emit("Session request failed, was rejected, interrupted, or timed out.", session.sessionId);
|
|
562
650
|
return false;
|
|
@@ -586,6 +674,11 @@ export class DiscordDaemon {
|
|
|
586
674
|
await output?.cleanup().catch(() => this.deps.onError?.("Discord output cleanup failed"));
|
|
587
675
|
this.staging.delete(controller);
|
|
588
676
|
}
|
|
677
|
+
} finally {
|
|
678
|
+
this.queuedPrompts--;
|
|
679
|
+
ready.resolve();
|
|
680
|
+
if (this.promptTails.get(session.sessionId) === tail) this.promptTails.delete(session.sessionId);
|
|
681
|
+
}
|
|
589
682
|
};
|
|
590
683
|
if (text === "/sessions" || text === "/subagents") {
|
|
591
684
|
const sessions = [...this.sessions.values()].filter(s => s.verified && s.ws.readyState === 1);
|
|
@@ -607,7 +700,7 @@ export class DiscordDaemon {
|
|
|
607
700
|
const delivered = await emit(ok ? "Session acknowledged command." : "Command not acknowledged (unavailable, rejected or timed out).", session.sessionId);
|
|
608
701
|
return ok && delivered;
|
|
609
702
|
}
|
|
610
|
-
if (text.startsWith("/") || text.startsWith("!")) { await emit(HELP); return false; }
|
|
703
|
+
if ((text.startsWith("/") && !/^\/sandbox(?:\s|$)/.test(text)) || text.startsWith("!")) { await emit(HELP); return false; }
|
|
611
704
|
|
|
612
705
|
const reference = object(message.message_reference) ? message.message_reference : undefined;
|
|
613
706
|
const reply = reference && typeof reference.message_id === "string" && (reference.channel_id === undefined || reference.channel_id === message.channel_id)
|
|
@@ -46,6 +46,7 @@ import { readGlobalConfig } from "../state";
|
|
|
46
46
|
import { notifySessionEndpointPath, notifySessionsDir } from "./paths";
|
|
47
47
|
import { resolveDiscordArtifactStage, type DiscordArtifactPaths } from "./discord-artifacts";
|
|
48
48
|
import type { SubagentRecord, SubagentRegistry } from "../subagent-registry";
|
|
49
|
+
import type { SandboxActor } from "../sandbox/authority";
|
|
49
50
|
|
|
50
51
|
const SNAPSHOT_POLL_MS = 1_200;
|
|
51
52
|
const MAX_INBOUND_BYTES = 64 * 1024;
|
|
@@ -64,6 +65,7 @@ export interface SessionIdentity {
|
|
|
64
65
|
export interface RemoteUserMessage {
|
|
65
66
|
text: string;
|
|
66
67
|
imagePaths?: string[];
|
|
68
|
+
actor?: SandboxActor;
|
|
67
69
|
}
|
|
68
70
|
|
|
69
71
|
/** A parsed `/verbose`, `/lean`, `/verbosity`, or `/redact` in-thread command. */
|
|
@@ -94,7 +96,8 @@ export class SessionNotifyEndpoint {
|
|
|
94
96
|
|
|
95
97
|
/** Registered by launch.ts; absent or rejected handlers return a failed ACK. */
|
|
96
98
|
onUserMessage: ((msg: RemoteUserMessage) => void | boolean | Promise<void | boolean>) | undefined;
|
|
97
|
-
onRemoteRequest: ((text: string, finish: (text?: string, artifactsAllowed?: boolean) => void, signal: AbortSignal, stage?: DiscordArtifactPaths) => boolean) | undefined;
|
|
99
|
+
onRemoteRequest: ((text: string, finish: (text?: string, artifactsAllowed?: boolean) => void, signal: AbortSignal, stage?: DiscordArtifactPaths, actor?: SandboxActor) => boolean) | undefined;
|
|
100
|
+
onSandboxControl: ((text: string, actor: SandboxActor) => Promise<{ handled: boolean; output: string }>) | undefined;
|
|
98
101
|
onRegistryMutation: (() => void) | undefined;
|
|
99
102
|
onConfigCommand: ((cmd: RemoteConfigCommand) => void) | undefined;
|
|
100
103
|
|
|
@@ -247,11 +250,30 @@ export class SessionNotifyEndpoint {
|
|
|
247
250
|
}
|
|
248
251
|
if (!msg || typeof msg !== "object" || Array.isArray(msg)) return;
|
|
249
252
|
if (msg.type !== "remote_request" && msg.sessionId !== undefined && msg.sessionId !== this.sessionId) return;
|
|
253
|
+
const provenance = msg.actor;
|
|
254
|
+
const actor: SandboxActor | undefined = provenance && typeof provenance === "object" && !Array.isArray(provenance) &&
|
|
255
|
+
(provenance as Record<string, unknown>).origin === "discord" && (provenance as Record<string, unknown>).sessionKey === this.sessionId &&
|
|
256
|
+
typeof (provenance as Record<string, unknown>).userId === "string" && !!(provenance as Record<string, unknown>).userId
|
|
257
|
+
? { origin: "discord", sessionKey: this.sessionId, userId: (provenance as Record<string, unknown>).userId as string } : undefined;
|
|
258
|
+
if (msg.type === "sandbox_control") {
|
|
259
|
+
let ok = false;
|
|
260
|
+
let output = "UNAUTHORIZED: authenticated Discord sandbox control required";
|
|
261
|
+
if (this.discordControlEnabled && msg.sessionId === this.sessionId && msg.controlGeneration === this.discordControlGeneration && actor &&
|
|
262
|
+
typeof msg.reqId === "string" && msg.reqId.length > 0 && msg.reqId.length <= 256 && typeof msg.text === "string" && this.onSandboxControl) {
|
|
263
|
+
try {
|
|
264
|
+
const result = await this.onSandboxControl(msg.text, actor);
|
|
265
|
+
ok = result.handled;
|
|
266
|
+
output = result.output;
|
|
267
|
+
} catch (error) { output = error instanceof Error ? error.message : "Sandbox control failed"; }
|
|
268
|
+
}
|
|
269
|
+
try { ws.send(JSON.stringify({ type: "ack", reqId: msg.reqId, ok, output })); } catch {}
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
250
272
|
if (msg.type === "remote_request") {
|
|
251
273
|
const reqId = msg.reqId;
|
|
252
274
|
let ok = false;
|
|
253
|
-
const onlyExpectedFields = Object.keys(msg).every(key => ["type", "sessionId", "reqId", "text", "staged", "controlGeneration"].includes(key));
|
|
254
|
-
if (onlyExpectedFields && this.discordControlEnabled && msg.controlGeneration === this.discordControlGeneration && msg.sessionId === this.sessionId && typeof reqId === "string" && reqId.length > 0 && reqId.length <= 256 &&
|
|
275
|
+
const onlyExpectedFields = Object.keys(msg).every(key => ["type", "sessionId", "reqId", "text", "staged", "controlGeneration", "actor"].includes(key));
|
|
276
|
+
if (onlyExpectedFields && actor && this.discordControlEnabled && msg.controlGeneration === this.discordControlGeneration && msg.sessionId === this.sessionId && typeof reqId === "string" && reqId.length > 0 && reqId.length <= 256 &&
|
|
255
277
|
typeof msg.text === "string" && !!msg.text.trim() && !msg.text.includes("\u0003") && !/^[\/!$]/.test(msg.text.trimStart()) && (msg.staged === undefined || msg.staged === true) && this.onRemoteRequest && !this.remoteControllers.has(ws)) {
|
|
256
278
|
let finished = false;
|
|
257
279
|
const controlGeneration = this.discordControlGeneration;
|
|
@@ -270,7 +292,7 @@ export class SessionNotifyEndpoint {
|
|
|
270
292
|
try {
|
|
271
293
|
if (msg.staged === true) stage = await resolveDiscordArtifactStage(this.sessionId, reqId);
|
|
272
294
|
if (this.discordControlEnabled && controlGeneration === this.discordControlGeneration && !controller.signal.aborted && this.sockets.has(ws)) {
|
|
273
|
-
ok = this.onRemoteRequest(msg.text, finish, controller.signal, stage);
|
|
295
|
+
ok = this.onRemoteRequest(msg.text, finish, controller.signal, stage, actor);
|
|
274
296
|
}
|
|
275
297
|
} catch {}
|
|
276
298
|
if (!ok && this.remoteControllers.get(ws)?.controller === controller) this.remoteControllers.delete(ws);
|
|
@@ -323,7 +345,7 @@ export class SessionNotifyEndpoint {
|
|
|
323
345
|
if (typeof msg.text === "string" && !msg.text.trimStart().startsWith("/") && !msg.text.trimStart().startsWith("!") && validPaths &&
|
|
324
346
|
(msg.text.trim() || (msg.imagePaths as string[] | undefined)?.length) && this.onUserMessage) {
|
|
325
347
|
try {
|
|
326
|
-
ok = (await this.onUserMessage({ text: msg.text, imagePaths: msg.imagePaths as string[] | undefined })) !== false;
|
|
348
|
+
ok = (await this.onUserMessage({ text: msg.text, imagePaths: msg.imagePaths as string[] | undefined, ...(actor ? { actor } : {}) })) !== false;
|
|
327
349
|
} catch {}
|
|
328
350
|
}
|
|
329
351
|
try {
|