jeo-code 0.12.12 → 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.
Files changed (63) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.ja.md +33 -9
  3. package/README.ko.md +33 -9
  4. package/README.md +33 -9
  5. package/README.zh.md +33 -9
  6. package/package.json +4 -2
  7. package/src/agent/browser-tool.ts +9 -1
  8. package/src/agent/config-schema.ts +8 -0
  9. package/src/agent/context-files.ts +19 -0
  10. package/src/agent/engine.ts +25 -6
  11. package/src/agent/eval-tool.ts +6 -0
  12. package/src/agent/goal-verifier.ts +30 -6
  13. package/src/agent/hooks.ts +52 -4
  14. package/src/agent/job-registry.ts +110 -18
  15. package/src/agent/job-tool.ts +43 -9
  16. package/src/agent/loop-guards.ts +17 -2
  17. package/src/agent/memory.ts +151 -112
  18. package/src/agent/monitor-registry.ts +142 -30
  19. package/src/agent/monitor-tool.ts +34 -6
  20. package/src/agent/notify/discord-daemon.ts +135 -42
  21. package/src/agent/notify/session-endpoint.ts +27 -5
  22. package/src/agent/sandbox/assets.d.ts +4 -0
  23. package/src/agent/sandbox/authority.ts +295 -0
  24. package/src/agent/sandbox/bundle.ts +37 -0
  25. package/src/agent/sandbox/control.ts +49 -0
  26. package/src/agent/sandbox/docker.ts +283 -0
  27. package/src/agent/sandbox/hands-source.text +14548 -0
  28. package/src/agent/sandbox/hands-worker.ts +168 -0
  29. package/src/agent/sandbox/policy.ts +155 -0
  30. package/src/agent/sandbox/rpc.ts +90 -0
  31. package/src/agent/sandbox/supervisor.ts +210 -0
  32. package/src/agent/sandbox/types.ts +77 -0
  33. package/src/agent/shell-lease.ts +190 -0
  34. package/src/agent/state.ts +167 -0
  35. package/src/agent/subagents.ts +4 -3
  36. package/src/agent/task-tool.ts +8 -0
  37. package/src/agent/todo-tool.ts +30 -1
  38. package/src/agent/tool-schemas.ts +13 -0
  39. package/src/ai/model-discovery.ts +12 -0
  40. package/src/ai/providers/anthropic.ts +27 -2
  41. package/src/ai/providers/antigravity.ts +10 -2
  42. package/src/ai/providers/gemini.ts +20 -16
  43. package/src/ai/providers/ollama.ts +14 -0
  44. package/src/ai/providers/openai-responses.ts +22 -5
  45. package/src/ai/providers/openai.ts +23 -40
  46. package/src/cli/runner.ts +3 -0
  47. package/src/commands/approve.ts +37 -13
  48. package/src/commands/launch/flags.ts +2 -1
  49. package/src/commands/launch/jobs-slash.ts +1 -1
  50. package/src/commands/launch.ts +261 -35
  51. package/src/commands/messenger.ts +105 -39
  52. package/src/commands/notify.ts +2 -2
  53. package/src/commands/team.ts +44 -12
  54. package/src/commands/ultragoal.ts +6 -16
  55. package/src/messenger/activity.ts +2 -1
  56. package/src/messenger/config.ts +51 -9
  57. package/src/messenger/discord-setup.ts +122 -20
  58. package/src/messenger/discord.ts +39 -52
  59. package/src/messenger/gateway.ts +33 -10
  60. package/src/messenger/model-selection.ts +101 -0
  61. package/src/messenger/session-pool.ts +189 -35
  62. package/src/messenger/types.ts +3 -2
  63. 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
- return `- ${record.id} [${record.status.toUpperCase()}] ${mode} ${elapsed(record)} · ${truncate(record.command, 100)}`;
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
- return async (args: Record<string, any>, cwd: string): Promise<ToolResult> => {
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.running().map(record => record.id);
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 targets = ids.length ? ids : registry.running().map(record => record.id);
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
- return { success: true, output: `Cancelled ${records.length} monitor(s):\n${records.map(rowLine).join("\n")}` };
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 Map<string, string>();
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
- if (typeof channel.owner_id !== "string" || !this.allowed.has(channel.owner_id)) throw new Error("Configured thread must have an allowed human owner");
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 remoteRequest(session: DiscordSession, text: string, reqId: string, controlGeneration: number): Promise<RemoteResult> {
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
- if (channelId !== this.config.channelId && !this.threads.has(channelId)) {
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
- const progress = reaction("⏳");
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
- const ok = await this.handleCommand(message, text, destination);
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 result = await this.remoteRequest(session, body || "Please inspect the attached files.", reqId, controlGeneration);
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 {
@@ -0,0 +1,4 @@
1
+ declare module "*.text" {
2
+ const source: string;
3
+ export default source;
4
+ }