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
@@ -60,7 +60,7 @@ export async function runJobsSlash(
60
60
  return;
61
61
  }
62
62
 
63
- const result = await createJobTool(registry)(args, cwd);
63
+ const result = await createJobTool(registry, false)(args, cwd);
64
64
  if (result.output) logLines(result.output.split("\n"));
65
65
  if (!result.success && result.error) logLines([`! ${result.error}`]);
66
66
  }
@@ -2,7 +2,8 @@ import { createInterface } from "node:readline/promises";
2
2
  import { emitKeypressEvents } from "node:readline";
3
3
  import { StringDecoder } from "node:string_decoder";
4
4
  import { PassThrough } from "node:stream";
5
- import { runAgentLoop, DEFAULT_TOOLS, TOOL_PROTOCOL, WORKING_DISCIPLINE, KARPATHY_DISCIPLINE, OUTPUT_DISCIPLINE, VERIFICATION_DIRECTIVE, isSafetyFallbackBail, stripSafetyFallbackTag, type AgentLoopEvents } from "../agent/engine";
5
+ import { createHash } from "node:crypto";
6
+ import { runAgentLoop, DEFAULT_TOOLS, SANDBOX_MEDIATED_TOOLS, TOOL_PROTOCOL, WORKING_DISCIPLINE, KARPATHY_DISCIPLINE, OUTPUT_DISCIPLINE, VERIFICATION_DIRECTIVE, isSafetyFallbackBail, stripSafetyFallbackTag, type AgentLoopEvents } from "../agent/engine";
6
7
  import { computerSupervisor } from "../agent/computer-supervisor";
7
8
  import { executeComputerAction } from "./computer";
8
9
 
@@ -14,8 +15,9 @@ import { createTaskTool, taskToolProtocolLine, type TaskSubEvent } from "../agen
14
15
  import { createEvalTool, evalToolProtocolLine } from "../agent/eval-tool";
15
16
  import { createSubagentTool, SUBAGENT_TOOL_PROTOCOL_LINE } from "../agent/subagent-tool";
16
17
  import { SubagentRegistry } from "../agent/subagent-registry";
17
- import { stopSessionNotifyEndpoint, startSessionNotifyEndpoint, type SessionNotifyEndpoint } from "../agent/notify/session-endpoint";
18
+ import { SessionNotifyEndpoint, stopSessionNotifyEndpoint, startSessionNotifyEndpoint } from "../agent/notify/session-endpoint";
18
19
  import { parseInThreadConfigCommand } from "../agent/notify/config-commands";
20
+ import { jeoHomeDir, notifySessionsDir } from "../agent/notify/paths";
19
21
 
20
22
  import { createTodoTool, TODO_TOOL_PROTOCOL_LINE } from "../agent/todo-tool";
21
23
  import { createJobTool, JOB_TOOL_PROTOCOL_LINE } from "../agent/job-tool";
@@ -30,7 +32,7 @@ import { runDeepInterviewEngine, type DeepInterviewEngineOptions } from "./deep-
30
32
  import { runRalplanEngine, type RalplanEngineOptions } from "./ralplan";
31
33
  import { runTeamEngine, type TeamEngineOptions } from "./team";
32
34
  import { runUltragoalEngine, type UltragoalEngineOptions } from "./ultragoal";
33
- import { skillsPromptSection, loadSkills, buildSkillTask, workflowSkillsForPrompt, parseSkillInvocation, parseSkillChain, skillSlashAliases, looksLikeSkillEcho, skillInvocationCard, type SkillDoc, type SkillInvocation } from "../skills/catalog";
35
+ import { prepareSandboxSkillCatalog, updateSandboxSkillCatalogRoots, skillsPromptSection, loadSkills, buildSkillTask, workflowSkillsForPrompt, parseSkillInvocation, parseSkillChain, skillSlashAliases, looksLikeSkillEcho, skillInvocationCard, type SkillDoc, type SkillInvocation } from "../skills/catalog";
34
36
  import { formatForgeBox, scaleForgeWidth } from "../tui/components/forge";
35
37
  import { interactiveOAuthLogin } from "./auth";
36
38
  import { logoutOAuth, OAUTH_PROVIDERS, API_KEY_ONLY_PROVIDERS, setApiKey } from "../auth";
@@ -55,7 +57,12 @@ import { callLlm, type Message } from "../agent/loop";
55
57
  import { friendlyProviderError } from "../util/provider-error";
56
58
  import { readGlobalConfig, saveConfigPatch, resolveWikiRoot } from "../agent/state";
57
59
  import { AgyCliError, type AgyTurnResult } from "../agent/antigravity-cli";
58
- import { runPreToolHooks, runPostTurnHooks } from "../agent/hooks";
60
+ import { currentSandboxExecution, withSandboxExecution, runPreToolHooks, runPostTurnHooks } from "../agent/hooks";
61
+ import { createSandboxSupervisor } from "../agent/sandbox/supervisor";
62
+ import { createSandboxPolicy } from "../agent/sandbox/policy";
63
+ import { createGrantAuthority } from "../agent/sandbox/authority";
64
+ import { handleSandboxControl } from "../agent/sandbox/control";
65
+ import type { SandboxConfig, SandboxSupervisor } from "../agent/sandbox/types";
59
66
  import { runAgySessionTurn } from "./launch/agy-turn";
60
67
  import { listProviderModels } from "../ai/model-discovery";
61
68
  import { rememberModelPatch, recentModelsForDisplay } from "../agent/model-recency";
@@ -387,6 +394,102 @@ export function formatResumeHint(sessionId: string): string {
387
394
  }
388
395
 
389
396
  export async function runLaunchCommand(args: string[]): Promise<void> {
397
+ // Brain-only control secret: remove before any tool, catalog, or guest setup.
398
+ const shutdownNonce = process.env.JEO_MESSENGER_SHUTDOWN_NONCE;
399
+ delete process.env.JEO_MESSENGER_SHUTDOWN_NONCE;
400
+ let supervisor: SandboxSupervisor | undefined;
401
+ let stateDirectory: string | undefined;
402
+ let stopEndpoint: (() => Promise<void>) | undefined;
403
+ let endpointStartup: Promise<void> | undefined;
404
+ let closing: Promise<void> | undefined;
405
+ const acknowledge = async (gone: boolean): Promise<void> => {
406
+ if (!shutdownNonce) return;
407
+ const written = Promise.withResolvers<void>();
408
+ process.stderr.write(`JEO_MESSENGER_SHUTDOWN_ACK ${JSON.stringify({ nonce: shutdownNonce, gone })}\n`, error => error ? written.reject(error) : written.resolve());
409
+ await written.promise;
410
+ };
411
+ const close = (): Promise<void> => closing ??= (async () => {
412
+ for (;;) {
413
+ try {
414
+ await endpointStartup;
415
+ await stopEndpoint?.();
416
+ // No supervisor means startup never had authority to create an environment.
417
+ await supervisor?.close();
418
+ if (stateDirectory) fs.rmSync(stateDirectory, { recursive: true, force: true });
419
+ await acknowledge(true);
420
+ return;
421
+ } catch (error) {
422
+ console.error(`REVOKE_INCOMPLETE: ${error instanceof Error ? error.message : String(error)}`);
423
+ await acknowledge(false).catch(() => { /* A broken parent pipe must not destroy the cleanup owner. */ });
424
+ // Keep the sole cleanup owner and its private state alive until removal succeeds.
425
+ const retry = Promise.withResolvers<void>();
426
+ setTimeout(retry.resolve, 1_000);
427
+ await retry.promise;
428
+ }
429
+ }
430
+ })();
431
+ const handleTermination = (): void => { void close().then(() => process.exit(143)); };
432
+ process.on("SIGTERM", handleTermination);
433
+ // Private cross-platform control: the pool's IPC channel (not a signal, not stdin) carries the shutdown
434
+ // request. Installed before any await so no setup phase can miss it; accepts only the exact nonce message.
435
+ const handleControlMessage = (message: unknown): void => {
436
+ if (!shutdownNonce || typeof message !== "object" || message === null || Array.isArray(message)) return;
437
+ const keys = Object.keys(message);
438
+ if (keys.length !== 2 || !keys.includes("type") || !keys.includes("nonce")) return;
439
+ const control = message as { type?: unknown; nonce?: unknown };
440
+ if (control.type !== "jeo_messenger_shutdown" || control.nonce !== shutdownNonce) return;
441
+ handleTermination();
442
+ };
443
+ if (shutdownNonce) process.on("message", handleControlMessage);
444
+ const previousSessionDirectory = process.env.JEO_SESSION_DIR;
445
+ const runSession = (): Promise<void> => {
446
+ const ready = Promise.withResolvers<void>();
447
+ endpointStartup = ready.promise;
448
+ return runLaunchSession(args, cleanup => { stopEndpoint = cleanup; ready.resolve(); }).finally(ready.resolve);
449
+ };
450
+ try {
451
+ const flags = parseFlags(args, process.cwd());
452
+ const config = await readGlobalConfig();
453
+ if (closing) throw new Error("Messenger worker stopping");
454
+ const remoteConfig = process.env.JEO_MESSENGER_SANDBOX;
455
+ const sandboxConfig: SandboxConfig | undefined = remoteConfig === undefined ? config.sandbox : JSON.parse(remoteConfig);
456
+ if ((flags.literalPrompt || process.env.JEO_MESSENGER_SESSION_KEY) && !sandboxConfig) throw new Error("SANDBOX_REQUIRED: remote execution requires sandbox configuration");
457
+ if (!sandboxConfig) return await runSession();
458
+ const cwd = flags.worktree ? resolveWorktree(process.cwd(), flags.worktree) : process.cwd();
459
+ const sessionKey = process.env.JEO_MESSENGER_SESSION_KEY || crypto.randomUUID();
460
+ for (const legacy of [path.join(cwd, ".jeo", "sessions"), path.join(cwd, ".jeo", "messenger-sessions"), path.join(cwd, ".jeo", "memory", "raw")]) {
461
+ if (fs.existsSync(legacy)) throw new Error("SANDBOX_PRIVATE_STATE: legacy transcripts are inside the workspace; relocate them before granting workspace access");
462
+ }
463
+ stateDirectory = fs.mkdtempSync(path.join(tmpdir(), "jeo-sandbox-authority-"));
464
+ const sessionDirectory = previousSessionDirectory || path.join(jeoHomeDir(), "sandbox-sessions", createHash("sha256").update(path.resolve(cwd)).digest("hex"));
465
+ const privateRoots = [stateDirectory, sessionDirectory, jeoHomeDir(), notifySessionsDir()];
466
+ const policy = await createSandboxPolicy({ sessionKey, revision: 0, cwd, config: sandboxConfig, privatePaths: privateRoots });
467
+ if (closing) throw new Error("Messenger worker stopping");
468
+ const sessionSupervisor = createSandboxSupervisor({ policy });
469
+ supervisor = sessionSupervisor;
470
+ process.env.JEO_SESSION_DIR = sessionDirectory;
471
+ await prepareSandboxSkillCatalog(sessionSupervisor, cwd, policy.mounts.filter(mount => mount.mode === "rw").map(mount => mount.host), policy.mounts.map(mount => mount.host));
472
+ if (closing) throw new Error("Messenger worker stopping");
473
+ const replacePolicy = sessionSupervisor.replacePolicy;
474
+ sessionSupervisor.replacePolicy = async nextPolicy => {
475
+ updateSandboxSkillCatalogRoots(sessionSupervisor, nextPolicy.mounts.filter(mount => mount.mode === "rw").map(mount => mount.host), nextPolicy.mounts.map(mount => mount.host));
476
+ await replacePolicy(nextPolicy);
477
+ };
478
+ const allowedUserIds: string[] = remoteConfig === undefined ? config.notifications?.discord?.allowedUserIds ?? [] : JSON.parse(process.env.JEO_MESSENGER_ALLOWED_USER_IDS || "[]");
479
+ if (!Array.isArray(allowedUserIds) || allowedUserIds.some(id => typeof id !== "string" || !id.trim())) throw new Error("UNAUTHORIZED: invalid sandbox actor allowlist");
480
+ const authority = await createGrantAuthority({ sessionKey, cwd, config: sandboxConfig, supervisor: sessionSupervisor, allowedUserIds, stateDirectory, privateRoots });
481
+ if (closing) throw new Error("Messenger worker stopping");
482
+ await withSandboxExecution({ supervisor: sessionSupervisor, authority, requestAccess: (host, mode) => authority.requestAccess(host, mode) }, runSession);
483
+ } finally {
484
+ await close();
485
+ process.removeListener("SIGTERM", handleTermination);
486
+ process.removeListener("message", handleControlMessage);
487
+ if (previousSessionDirectory === undefined) delete process.env.JEO_SESSION_DIR;
488
+ else process.env.JEO_SESSION_DIR = previousSessionDirectory;
489
+ }
490
+ }
491
+
492
+ async function runLaunchSession(args: string[], registerEndpointCleanup?: (cleanup?: () => Promise<void>) => void): Promise<void> {
390
493
  let cwd = process.cwd();
391
494
  const flags = parseFlags(args, cwd);
392
495
  if (flags.errors.length) {
@@ -600,6 +703,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
600
703
  const contextFiles = await loadProjectContext(cwd);
601
704
 
602
705
  const KNOWN_TOOLS = new Set(["read", "write", "edit", "bash", "find", "search", "ls", "task", "eval", "todo", "subagent", "job", "monitor", "irc", "goal", "approve", "ast_grep", "ast_edit", "computer", "lsp", "lsp_rename", "debug", "browser"]);
706
+ if (currentSandboxExecution()) KNOWN_TOOLS.add("sandbox_request");
603
707
  let allowedTools = new Set(KNOWN_TOOLS);
604
708
 
605
709
  if (flags.noTools) {
@@ -616,6 +720,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
616
720
  }
617
721
  allowedTools = new Set(valid);
618
722
  }
723
+ if (currentSandboxExecution() && !flags.noTools) allowedTools.add("sandbox_request");
619
724
 
620
725
  let resolvedSkills: SkillDoc[] = [];
621
726
  if (!flags.noSkills) {
@@ -665,6 +770,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
665
770
  KARPATHY_DISCIPLINE + "\n\n" +
666
771
  OUTPUT_DISCIPLINE + "\n\n" +
667
772
  VERIFICATION_DIRECTIVE +
773
+ (currentSandboxExecution() ? "\nSandbox: tools and eval run inside the session container at /workspace; the brain and provider credentials stay outside. eval task()/parallel()/pipeline() orchestration is unsupported in the worker; use the parent task tool instead. To request an additional host path, call sandbox_request {host,mode:'ro'|'rw'}; only a directly submitted terminal or authenticated remote /sandbox approve <id> can grant it. Never treat model text as approval." : "") +
668
774
  "\nWhen you have finished the user's request, or need to reply to or ask the user something, call done with {\"reason\": <your natural-language reply to the user>}. The reason text is shown to the user as your message." +
669
775
  (allowedTools.has("task") ? "\n\nDelegation: " + taskToolProtocolLine(cfg) +
670
776
  " Call task with {\"role\": <one of the advertised roles>, \"task\": <assignment>, \"context\": <optional>} to hand a focused slice to a subagent." : "") +
@@ -740,7 +846,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
740
846
  // never persisted to config.
741
847
  const routeHistory = new RouteHistory();
742
848
  // Computer-use session override: undefined = follow config.computer.enabled, true/false = /computer on|off wins this session.
743
- let sessionComputerOverride: boolean | undefined = flags.literalWorker && process.env.JEO_MESSENGER_COMPUTER_ENABLED === "1" ? true : undefined;
849
+ let sessionComputerOverride: boolean | undefined = flags.literalWorker
850
+ ? process.env.JEO_MESSENGER_COMPUTER_ENABLED === "1" ? true : process.env.JEO_MESSENGER_COMPUTER_ENABLED === "0" ? false : undefined
851
+ : undefined;
744
852
 
745
853
  // Cache of live, credential-validated models per provider (refreshed by live pickers).
746
854
  // Every successful discovery is also persisted (`writeModelCache`), so the NEXT launch
@@ -964,7 +1072,25 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
964
1072
  // lifetime (not per-turn) so a Telegram forum topic, once created, persists
965
1073
  // across turns AND across `--resume` (keyed by jeo's real `sessionId`, not a
966
1074
  // random one — see `SessionNotifyEndpoint`'s header doc).
967
- const sessionNotifyEndpoint = flags.literalPrompt ? undefined : await startSessionNotifyEndpoint(cwd, sessionId);
1075
+ const sandboxAuthority = currentSandboxExecution()?.authority;
1076
+ const sessionNotifyEndpoint = flags.literalPrompt
1077
+ ? (sandboxAuthority ? new SessionNotifyEndpoint(cwd, process.env.JEO_MESSENGER_SESSION_KEY || sessionId) : undefined)
1078
+ : await startSessionNotifyEndpoint(cwd, sessionId);
1079
+ try {
1080
+ if (sessionNotifyEndpoint && sandboxAuthority) {
1081
+ sessionNotifyEndpoint.onSandboxControl = (text, actor) => {
1082
+ if (actor.sessionKey !== sessionNotifyEndpoint.sessionId) return Promise.resolve({ handled: true, output: "SESSION_MISMATCH" });
1083
+ return handleSandboxControl(text, { ...actor, sessionKey: sandboxAuthority.snapshot().sessionKey }, sandboxAuthority);
1084
+ };
1085
+ if (flags.literalPrompt) {
1086
+ sessionNotifyEndpoint.setDiscordControlEnabled(true);
1087
+ await sessionNotifyEndpoint.start();
1088
+ }
1089
+ }
1090
+ } finally {
1091
+ registerEndpointCleanup?.(sessionNotifyEndpoint ? () => sessionNotifyEndpoint.stop() : undefined);
1092
+ }
1093
+ const sandboxTranscriptId = sessionId;
968
1094
  if (sessionNotifyEndpoint) {
969
1095
  sessionNotifyEndpoint.sendIdentity({ repo: cwd.split(/[\\/]/).filter(Boolean).pop() ?? cwd, branch, cwd });
970
1096
  }
@@ -1007,6 +1133,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1007
1133
  sessionNotifyEndpoint?.detachRegistry();
1008
1134
  await sessionNotifyEndpoint?.stop();
1009
1135
  await stopSessionNotifyEndpoint(subagentRegistry);
1136
+ await turnSandboxScope?.supervisor.close();
1010
1137
  };
1011
1138
 
1012
1139
  // Persist the active per-session model into the session header so `/resume` restores
@@ -1077,7 +1204,8 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1077
1204
 
1078
1205
  // Run one conversational turn: compact, persist user msg, run the loop, persist + return the reply.
1079
1206
  // When `useTui`, a live TUI renders the turn and prints the final reply itself (rendered=true).
1080
- const runTurn = async (
1207
+ const turnSandboxScope = currentSandboxExecution();
1208
+ const runTurnScoped = async (
1081
1209
  userInput: string,
1082
1210
  useTui: boolean,
1083
1211
  images?: ImageAttachment[],
@@ -1088,6 +1216,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1088
1216
  opts?: { userCard?: string | null; signal?: AbortSignal; trustedContext?: string },
1089
1217
  ): Promise<{ done: boolean; steps: number; reply: string; rendered: boolean; usage: string }> => {
1090
1218
  opts?.signal?.throwIfAborted();
1219
+ if (currentSandboxExecution() && sessionId !== sandboxTranscriptId) throw new Error("SESSION_MISMATCH: start a new launch for the selected transcript; sandbox grants cannot cross sessions");
1091
1220
  let busyDraftHasPaste = false;
1092
1221
  const captureLiveInput = (chunk: string, view: LaunchTui | null): { captured: boolean; submittedEnter: boolean; handled: boolean } => {
1093
1222
  if (!queueBusyInput || !queueBusySnapshot) return { captured: false, submittedEnter: false, handled: false };
@@ -1097,6 +1226,18 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1097
1226
  const captured = queueBusyInput?.(chunk) ?? false;
1098
1227
  const draft = expandSentinel(queueBusySnapshot?.().text ?? "");
1099
1228
  if (!draft && !paste && !(queueBusyPasteActive?.() ?? false)) busyDraftHasPaste = false;
1229
+ if (submittedEnter && /^\/sandbox(?:\s|$)/.test(draft.trim())) {
1230
+ const report = (message: string): void => { if (view) view.events().onNotice?.(message); else console.log(message); };
1231
+ if (busyDraftHasPaste || !process.stdin.isTTY || !sandboxAuthority) {
1232
+ report("UNAUTHORIZED: sandbox controls require an active sandbox and directly submitted local prompt.");
1233
+ } else {
1234
+ queueBusyClear?.();
1235
+ view?.setLivePromptInput("");
1236
+ void handleSandboxControl(draft.trim(), { origin: "terminal", sessionKey: sandboxAuthority.snapshot().sessionKey }, sandboxAuthority)
1237
+ .then(result => report(result.output), error => report(error instanceof Error ? error.message : String(error)));
1238
+ }
1239
+ return { captured, submittedEnter, handled: true };
1240
+ }
1100
1241
  if (submittedEnter && /^\/discord\s+control\s+off$/.test(draft.trim())) {
1101
1242
  const endpoint = sessionNotifyEndpoint;
1102
1243
  if (!busyDraftHasPaste && endpoint && endpoint.sessionId === sessionId) {
@@ -1139,6 +1280,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1139
1280
  };
1140
1281
  const selectedModel = sessionModel || defaultModel;
1141
1282
  if (selectedModel.startsWith("agy/")) {
1283
+ if (currentSandboxExecution()) throw new Error("SANDBOX_UNSUPPORTED: official CLI agents cannot execute outside the session sandbox; select a raw-model provider");
1142
1284
  const oneShot = !!flags.print || flags.message.length > 0 || !process.stdin.isTTY;
1143
1285
  if (flags.noTools || flags.tools || flags.maxSteps > 0 || flags.systemPrompt || flags.appendSystemPrompt || flags.thinking) {
1144
1286
  if (oneShot) process.exitCode = 1;
@@ -1755,20 +1897,30 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1755
1897
  reason: string,
1756
1898
  evidence: { sawMutation: boolean; sawVerification: boolean; verificationStale: boolean },
1757
1899
  ): Promise<string | null> => {
1758
- const runningJobs = jobRegistry.running();
1900
+ const liveJobs = jobRegistry.running();
1901
+ if (liveJobs.length > 0) {
1902
+ const jobs = liveJobs.map(record => `${record.id} (${record.command})`).join(", ");
1903
+ return `Cannot finish while background job(s) are still running: ${jobs}. Wait for them to exit or cancel them, then run fresh verification before calling done again.`;
1904
+ }
1905
+ const runningJobs = jobRegistry.unobserved();
1759
1906
  if (runningJobs.length > 0) {
1760
1907
  const jobs = runningJobs.map(record => `${record.id} (${record.command})`).join(", ");
1761
1908
  return (
1762
- `Cannot finish while background job(s) are still running: ${jobs}. ` +
1763
- `Wait for them to exit or cancel them, then call done again.`
1909
+ `Cannot finish while background job(s) have unobserved completions: ${jobs}. ` +
1910
+ `Await them, then run fresh verification before calling done again.`
1764
1911
  );
1765
1912
  }
1766
- const runningMonitors = monitorRegistry.running();
1913
+ const liveMonitors = monitorRegistry.running();
1914
+ if (liveMonitors.length > 0) {
1915
+ const monitors = liveMonitors.map(record => `${record.id} (${record.command})`).join(", ");
1916
+ return `Cannot finish while background monitor(s) are still running: ${monitors}. Wait for them to exit or cancel them, then run fresh verification before calling done again.`;
1917
+ }
1918
+ const runningMonitors = monitorRegistry.unobserved();
1767
1919
  if (runningMonitors.length > 0) {
1768
1920
  const monitors = runningMonitors.map(record => `${record.id} (${record.command})`).join(", ");
1769
1921
  return (
1770
- `Cannot finish while background monitor(s) are still running: ${monitors}. ` +
1771
- `Wait for them to exit or cancel them, then call done again.`
1922
+ `Cannot finish while background monitor(s) have unobserved completions: ${monitors}. ` +
1923
+ `Await them, then run fresh verification before calling done again.`
1772
1924
  );
1773
1925
  }
1774
1926
 
@@ -1867,14 +2019,41 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1867
2019
  irc: createIrcTool(subagentRegistry),
1868
2020
  goal: createGoalTool(),
1869
2021
  approve: createApproveTool(),
2022
+ sandbox_request: async (a: Record<string, any>) => {
2023
+ const scope = currentSandboxExecution();
2024
+ if (!scope?.requestAccess) return { success: false, output: "", error: "No session sandbox authority" };
2025
+ if (a.action === "status" && scope.authority) {
2026
+ const { revision, grants, requests, blocked } = scope.authority.snapshot();
2027
+ return { success: true, output: JSON.stringify({ revision, grants, requests, blocked }) };
2028
+ }
2029
+ if (typeof a.host !== "string" || (a.mode !== "ro" && a.mode !== "rw")) return { success: false, output: "", error: "sandbox_request requires host and mode ro|rw" };
2030
+ const request = await scope.requestAccess(a.host, a.mode);
2031
+ return { success: true, output: JSON.stringify({ request, grants: scope.authority?.snapshot().grants ?? [] }) };
2032
+ },
1870
2033
  // `/computer on|off` session override wins over `config.computer.enabled`
1871
2034
  // (see `sessionComputerOverride`/`computer-slash.ts`); undefined = fall
1872
2035
  // through to DEFAULT_TOOLS' own config-only gate inside executeComputerAction.
1873
- computer: (a: Record<string, any>) => {
1874
- computerSupervisor.setKillSwitchLive(true);
1875
- computerSupervisor.heartbeat();
1876
- return executeComputerAction(a as any, { enabledOverride: sessionComputerOverride });
1877
- },
2036
+ // Trusted mediated wrapper (SANDBOX_MEDIATED_TOOLS): inside a sandbox the parent
2037
+ // resolves consent from session/config ONLY and forwards it over the private
2038
+ // `__computer` hands handler; the host never executes the action.
2039
+ computer: (() => {
2040
+ const handler = async (a: Record<string, any>, hcwd: string, onProgress?: (text: string) => void) => {
2041
+ const sandbox = currentSandboxExecution();
2042
+ if (sandbox) {
2043
+ const cfg = await readGlobalConfig().catch(() => null);
2044
+ return sandbox.supervisor.call(
2045
+ "__computer",
2046
+ { action: a, enabled: sessionComputerOverride ?? !!cfg?.computer?.enabled },
2047
+ { cwd: hcwd, signal: sandbox.signal, onProgress },
2048
+ );
2049
+ }
2050
+ computerSupervisor.setKillSwitchLive(true);
2051
+ computerSupervisor.heartbeat();
2052
+ return executeComputerAction(a as any, { enabledOverride: sessionComputerOverride });
2053
+ };
2054
+ SANDBOX_MEDIATED_TOOLS.add(handler as any);
2055
+ return handler;
2056
+ })(),
1878
2057
 
1879
2058
  };
1880
2059
  const tools = filterToolMap(fullTools, Array.from(allowedTools));
@@ -2245,15 +2424,21 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2245
2424
  const usage = result.usage ? ` (${result.usage.inputTokens} in / ${result.usage.outputTokens} out tokens)` : "";
2246
2425
 
2247
2426
  if (!flags.literalPrompt && turnConfig.gitAutoCommit) {
2427
+ const sandbox = currentSandboxExecution();
2248
2428
  try {
2249
- const statusRes = Bun.spawnSync(["git", "status", "--porcelain"], { cwd, stdout: "pipe", stderr: "ignore" });
2250
- if (statusRes.exitCode === 0 && statusRes.stdout.toString().trim().length > 0) {
2251
- Bun.spawnSync(["git", "add", "."], { cwd });
2252
- const diffRes = Bun.spawnSync(["git", "diff", "--cached", "--name-status"], { cwd, stdout: "pipe", stderr: "ignore" });
2253
- const changedFiles = diffRes.stdout.toString().trim().split("\n").map(line => line.split("\t")[1]).filter(Boolean);
2429
+ const statusRes = sandbox
2430
+ ? await sandbox.supervisor.exec(["git", "status", "--porcelain"], { cwd })
2431
+ : Bun.spawnSync(["git", "status", "--porcelain"], { cwd, stdout: "pipe", stderr: "ignore" });
2432
+ if (statusRes.exitCode === 0 && statusRes.stdout?.toString().trim()) {
2433
+ const addRes = sandbox ? await sandbox.supervisor.exec(["git", "add", "."], { cwd }) : Bun.spawnSync(["git", "add", "."], { cwd });
2434
+ if (addRes.exitCode !== 0) throw new Error("git add failed");
2435
+ const diffRes = sandbox
2436
+ ? await sandbox.supervisor.exec(["git", "diff", "--cached", "--name-status"], { cwd })
2437
+ : Bun.spawnSync(["git", "diff", "--cached", "--name-status"], { cwd, stdout: "pipe", stderr: "ignore" });
2438
+ const changedFiles = (diffRes.stdout?.toString() ?? "").trim().split("\n").map(line => line.split("\t")[1]).filter(Boolean);
2254
2439
  const fileSummary = changedFiles.length > 0 ? ` (modified: ${changedFiles.slice(0, 3).join(", ")}${changedFiles.length > 3 ? ", ..." : ""})` : "";
2255
2440
  const commitMsg = `[jeo] auto-commit${fileSummary}\n\nPrompt: ${userInput.slice(0, 100)}${userInput.length > 100 ? "..." : ""}`;
2256
- const commitRes = Bun.spawnSync(["git", "commit", "-m", commitMsg], { cwd });
2441
+ const commitRes = sandbox ? await sandbox.supervisor.exec(["git", "commit", "-m", commitMsg], { cwd }) : Bun.spawnSync(["git", "commit", "-m", commitMsg], { cwd });
2257
2442
  if (commitRes.exitCode === 0) {
2258
2443
  if (tui) tui.events().onNotice?.(`(auto-committed changes to git)`);
2259
2444
  else console.log(`(auto-committed changes to git)`);
@@ -2278,6 +2463,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2278
2463
  };
2279
2464
 
2280
2465
 
2466
+ const runTurn = (...args: Parameters<typeof runTurnScoped>) => turnSandboxScope
2467
+ ? withSandboxExecution(turnSandboxScope, () => runTurnScoped(...args))
2468
+ : runTurnScoped(...args);
2469
+
2281
2470
  const joinedArgs = flags.message;
2282
2471
  const isOneShot = flags.print || joinedArgs.length > 0 || !process.stdin.isTTY;
2283
2472
  // hermes-style experience distill (plan/gjc-inheritance.md B6) for one-shot
@@ -3064,6 +3253,8 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3064
3253
  let promptServedFromPaste = false;
3065
3254
  let promptWasRemote = false;
3066
3255
  let exclusiveRemote: { text: string; finish: (text?: string, artifactsAllowed?: boolean) => void; signal: AbortSignal; epoch: number; stage?: { output: string; input?: string } } | undefined;
3256
+ let pendingRemote: NonNullable<typeof exclusiveRemote> | undefined;
3257
+ let admitPendingRemote: () => void = () => {};
3067
3258
  const promptInput = async (prompt: string): Promise<string> => {
3068
3259
  promptServedFromPaste = false;
3069
3260
  promptWasRemote = false;
@@ -3114,6 +3305,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3114
3305
  }),
3115
3306
  new Promise<{ remote: string }>(resolve => {
3116
3307
  remoteInjectResolve = resolve;
3308
+ admitPendingRemote();
3117
3309
  }),
3118
3310
  ]);
3119
3311
  // The aborted `rl.question()` lost the race (a remote message or stdin-close
@@ -3650,6 +3842,13 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3650
3842
  sessionNotifyEndpoint?.detachRegistry();
3651
3843
  void sessionNotifyEndpoint?.stop();
3652
3844
  void stopSessionNotifyEndpoint(subagentRegistry);
3845
+ if (turnSandboxScope) {
3846
+ void turnSandboxScope.supervisor.close().then(() => process.exit(130), error => {
3847
+ console.error(`REVOKE_INCOMPLETE: ${error instanceof Error ? error.message : String(error)}`);
3848
+ process.exit(1);
3849
+ });
3850
+ return;
3851
+ }
3653
3852
  process.exit(130);
3654
3853
  };
3655
3854
  // Prompt Ctrl+C: a single press clears a non-empty box; on an empty box it exits.
@@ -4571,19 +4770,32 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4571
4770
  // established by now. See the Tier 2 contract's "Session-side routing".
4572
4771
  if (sessionNotifyEndpoint) {
4573
4772
  sessionNotifyEndpoint.onRegistryMutation = () => { relayInputEpoch++; };
4574
- sessionNotifyEndpoint.onRemoteRequest = (text, finish, signal, stage): boolean => {
4575
- const rli = rl as unknown as { line?: string };
4576
- if (exclusiveRemote || interactiveTurnActive || remoteInputPending || promptPasteActive || pasteLineFired || pasteInFlight || draftHasPaste || rli.line !== "" || !pendingRemoteInject ||
4577
- pendingImages.length || pendingStdinLines.length || pendingMidTurnCommands.length || queuedPromptInput.partial ||
4578
- queuedPromptInput.pastedLines.length || sessionNotifyEndpoint?.sessionId !== sessionId) return false;
4579
- if (stage && ((sessionModel || defaultModel).startsWith("agy/") || history[0]?.role !== "system")) return false;
4580
- const request = { text, finish, signal, stage, epoch: ++relayInputEpoch };
4773
+ admitPendingRemote = (): void => {
4774
+ if (!pendingRemote || exclusiveRemote || interactiveTurnActive || remoteInputPending || promptPasteActive || pasteLineFired || pasteInFlight || draftHasPaste || Reflect.get(rl, "line") !== "" || !pendingRemoteInject ||
4775
+ pendingImages.length || pendingStdinLines.length || pendingMidTurnCommands.length || queuedPromptInput.partial || queuedPromptInput.pastedLines.length || sessionNotifyEndpoint.sessionId !== sessionId) return;
4776
+ const request = pendingRemote;
4777
+ if (request.signal.aborted) { pendingRemote = undefined; request.finish(); return; }
4778
+ request.epoch = ++relayInputEpoch;
4581
4779
  exclusiveRemote = request;
4582
- if (pendingRemoteInject(text)) return true;
4583
- exclusiveRemote = undefined;
4584
- return false;
4780
+ if (pendingRemoteInject(request.text)) pendingRemote = undefined;
4781
+ else exclusiveRemote = undefined;
4782
+ };
4783
+ sessionNotifyEndpoint.onRemoteRequest = (text, finish, signal, stage, actor): boolean => {
4784
+ if (!sandboxAuthority || actor?.origin !== "discord" || actor.sessionKey !== sessionNotifyEndpoint.sessionId ||
4785
+ !cfg.notifications?.discord?.allowedUserIds?.includes(actor.userId ?? "") || pendingRemote || signal.aborted) return false;
4786
+ if (stage && ((sessionModel || defaultModel).startsWith("agy/") || history[0]?.role !== "system")) return false;
4787
+ const request = { text, finish, signal, stage, epoch: 0 };
4788
+ pendingRemote = request;
4789
+ signal.addEventListener("abort", () => {
4790
+ if (pendingRemote === request) { pendingRemote = undefined; finish(); }
4791
+ }, { once: true });
4792
+ admitPendingRemote();
4793
+ return true;
4585
4794
  };
4586
4795
  sessionNotifyEndpoint.onUserMessage = async (msg): Promise<void | boolean> => {
4796
+ if (!sandboxAuthority) return false;
4797
+ if (msg.actor && (msg.actor.origin !== "discord" || msg.actor.sessionKey !== sessionNotifyEndpoint.sessionId ||
4798
+ !cfg.notifications?.discord?.allowedUserIds?.includes(msg.actor.userId ?? ""))) return false;
4587
4799
  relayInputEpoch++;
4588
4800
  remoteInputPending++;
4589
4801
  try {
@@ -4768,6 +4980,18 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4768
4980
  pendingSelection = undefined;
4769
4981
  navMatches = [];
4770
4982
  navIdx = -1;
4983
+ if (input === "/sandbox" || input.startsWith("/sandbox ")) {
4984
+ if (!directLocalPrompt || explicitPastedDraft || promptServedFromPaste || remoteInputPending !== 0 ||
4985
+ relayInputEpoch - relayEpoch !== ambiguousPasteEpoch - ambiguousEpoch) {
4986
+ console.log("UNAUTHORIZED: sandbox controls require a directly submitted local prompt.");
4987
+ } else if (!sandboxAuthority) {
4988
+ console.log("No session sandbox is active.");
4989
+ } else {
4990
+ const result = await handleSandboxControl(input, { origin: "terminal", sessionKey: sandboxAuthority.snapshot().sessionKey }, sandboxAuthority);
4991
+ console.log(result.output);
4992
+ }
4993
+ continue;
4994
+ }
4771
4995
  if (input === "/discord" || input.startsWith("/discord ")) {
4772
4996
  const parts = input.split(/\s+/);
4773
4997
  const isControlOn = parts.length === 4 && parts[1] === "control" && parts[2] === "on" && parts[3] === "--allow-full-control";
@@ -4776,6 +5000,8 @@ navMatches = [];
4776
5000
  if (!directLocalPrompt || explicitPastedDraft || promptServedFromPaste || remoteInputPending !== 0 ||
4777
5001
  relayInputEpoch - relayEpoch !== ambiguousPasteEpoch - ambiguousEpoch) {
4778
5002
  console.log("Discord control requires a directly submitted local prompt.");
5003
+ } else if (isControlOn && !sandboxAuthority) {
5004
+ console.log("SANDBOX_REQUIRED: configure a sandbox and start a new launch before enabling Discord control.");
4779
5005
  } else if (!sessionNotifyEndpoint || sessionNotifyEndpoint.sessionId !== sessionId) {
4780
5006
  console.log("Discord control unavailable: notification session endpoint unavailable.");
4781
5007
  } else {
@@ -4786,7 +5012,7 @@ navMatches = [];
4786
5012
  } else {
4787
5013
  sessionNotifyEndpoint.setDiscordControlEnabled(isControlOn);
4788
5014
  console.log(isControlOn
4789
- ? "Discord control enabled for this session. Authorized Discord users can use this host session's tools and approval policy, including reading or changing files and running commands; this is not a sandbox. Run /discord control off to revoke."
5015
+ ? "Discord control enabled for this sandbox session. Only authenticated allowlisted users can submit turns or explicit /sandbox controls. Run /discord control off to revoke remote control."
4790
5016
  : "Discord control revoked for this session.");
4791
5017
  }
4792
5018
  }