jeo-code 0.12.13 → 0.12.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +55 -1
  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 +358 -0
  24. package/src/agent/sandbox/bundle.ts +40 -0
  25. package/src/agent/sandbox/control.ts +49 -0
  26. package/src/agent/sandbox/docker.ts +293 -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 +172 -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 +275 -35
  51. package/src/commands/messenger.ts +107 -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 +78 -10
  57. package/src/messenger/discord-setup.ts +216 -34
  58. package/src/messenger/discord.ts +37 -43
  59. package/src/messenger/gateway.ts +85 -37
  60. package/src/messenger/model-selection.ts +101 -0
  61. package/src/messenger/session-pool.ts +189 -35
  62. package/src/messenger/types.ts +5 -2
  63. package/src/skills/catalog.ts +207 -0
  64. package/src/util/retry.ts +12 -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, ensurePrivateSandboxDirectory } 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";
@@ -68,6 +75,7 @@ import { RouteHistory } from "../agent/route-history";
68
75
 
69
76
  import { isRateLimitError, isUsageLimitError, isConnectionError } from "../util/retry";
70
77
  import { messengerUsageFallback } from "../messenger/model-policy";
78
+ import { messengerProfilePrivatePaths } from "../messenger/config";
71
79
 
72
80
 
73
81
  import { listAliases } from "../ai/model-registry";
@@ -387,6 +395,115 @@ export function formatResumeHint(sessionId: string): string {
387
395
  }
388
396
 
389
397
  export async function runLaunchCommand(args: string[]): Promise<void> {
398
+ // Brain-only control secret: remove before any tool, catalog, or guest setup.
399
+ const shutdownNonce = process.env.JEO_MESSENGER_SHUTDOWN_NONCE;
400
+ delete process.env.JEO_MESSENGER_SHUTDOWN_NONCE;
401
+ let supervisor: SandboxSupervisor | undefined;
402
+ let stateDirectory: string | undefined;
403
+ let stopEndpoint: (() => Promise<void>) | undefined;
404
+ const setup = Promise.withResolvers<void>();
405
+ let endpointStartup: Promise<void> | undefined;
406
+ let closing: Promise<void> | undefined;
407
+ const acknowledge = async (gone: boolean): Promise<void> => {
408
+ if (!shutdownNonce) return;
409
+ const written = Promise.withResolvers<void>();
410
+ process.stderr.write(`JEO_MESSENGER_SHUTDOWN_ACK ${JSON.stringify({ nonce: shutdownNonce, gone })}\n`, error => error ? written.reject(error) : written.resolve());
411
+ await written.promise;
412
+ };
413
+ const close = (): Promise<void> => closing ??= (async () => {
414
+ for (;;) {
415
+ try {
416
+ await setup.promise;
417
+ await endpointStartup;
418
+ await stopEndpoint?.();
419
+ // No supervisor means startup never had authority to create an environment.
420
+ await supervisor?.close();
421
+ if (stateDirectory) fs.rmSync(stateDirectory, { recursive: true, force: true });
422
+ await acknowledge(true);
423
+ return;
424
+ } catch (error) {
425
+ console.error(`REVOKE_INCOMPLETE: ${error instanceof Error ? error.message : String(error)}`);
426
+ await acknowledge(false).catch(() => { /* A broken parent pipe must not destroy the cleanup owner. */ });
427
+ // Keep the sole cleanup owner and its private state alive until removal succeeds.
428
+ const retry = Promise.withResolvers<void>();
429
+ setTimeout(retry.resolve, 1_000);
430
+ await retry.promise;
431
+ }
432
+ }
433
+ })();
434
+ const handleTermination = (): void => { void close().then(() => process.exit(143)); };
435
+ process.on("SIGTERM", handleTermination);
436
+ // Private cross-platform control: the pool's IPC channel (not a signal, not stdin) carries the shutdown
437
+ // request. Installed before any await so no setup phase can miss it; accepts only the exact nonce message.
438
+ const handleControlMessage = (message: unknown): void => {
439
+ if (!shutdownNonce || typeof message !== "object" || message === null || Array.isArray(message)) return;
440
+ const keys = Object.keys(message);
441
+ if (keys.length !== 2 || !keys.includes("type") || !keys.includes("nonce")) return;
442
+ const control = message as { type?: unknown; nonce?: unknown };
443
+ if (control.type !== "jeo_messenger_shutdown" || control.nonce !== shutdownNonce) return;
444
+ handleTermination();
445
+ };
446
+ if (shutdownNonce) process.on("message", handleControlMessage);
447
+ const previousSessionDirectory = process.env.JEO_SESSION_DIR;
448
+ const runSession = (): Promise<void> => {
449
+ const ready = Promise.withResolvers<void>();
450
+ endpointStartup = ready.promise;
451
+ setup.resolve();
452
+ return runLaunchSession(args, cleanup => { stopEndpoint = cleanup; ready.resolve(); }).finally(ready.resolve);
453
+ };
454
+ try {
455
+ const flags = parseFlags(args, process.cwd());
456
+ const config = await readGlobalConfig();
457
+ if (closing) throw new Error("Messenger worker stopping");
458
+ const remoteConfig = process.env.JEO_MESSENGER_SANDBOX;
459
+ const sandboxConfig: SandboxConfig | undefined = remoteConfig === undefined ? config.sandbox : JSON.parse(remoteConfig);
460
+ if ((flags.literalPrompt || process.env.JEO_MESSENGER_SESSION_KEY) && !sandboxConfig) throw new Error("SANDBOX_REQUIRED: remote execution requires sandbox configuration");
461
+ if (!sandboxConfig) return await runSession();
462
+ const cwd = flags.worktree ? resolveWorktree(process.cwd(), flags.worktree) : process.cwd();
463
+ const sessionKey = process.env.JEO_MESSENGER_SESSION_KEY || crypto.randomUUID();
464
+ for (const legacy of [path.join(cwd, ".jeo", "sessions"), path.join(cwd, ".jeo", "messenger-sessions"), path.join(cwd, ".jeo", "memory", "raw")]) {
465
+ if (fs.existsSync(legacy)) throw new Error("SANDBOX_PRIVATE_STATE: legacy transcripts are inside the workspace; relocate them before granting workspace access");
466
+ }
467
+ // The messenger profile (config, token file, run script) is private control state wherever it lives, not only under ~/.jeo.
468
+ const profileDirectory = process.env.JEO_MESSENGER_PROFILE_DIR;
469
+ if (profileDirectory !== undefined && !path.isAbsolute(profileDirectory)) throw new Error("SANDBOX_PRIVATE_STATE: invalid messenger profile directory");
470
+ const profilePaths = profileDirectory === undefined ? [] : await messengerProfilePrivatePaths(profileDirectory, cwd);
471
+ if (closing) throw new Error("Messenger worker stopping");
472
+ // Publish the candidate before creating it so close() removes it even if owner-only creation or ACL verification fails midway.
473
+ const candidateStateDirectory = path.join(tmpdir(), `jeo-sandbox-authority-${crypto.randomUUID()}`);
474
+ stateDirectory = candidateStateDirectory;
475
+ stateDirectory = await ensurePrivateSandboxDirectory(candidateStateDirectory);
476
+ if (closing) throw new Error("Messenger worker stopping");
477
+ const sessionDirectory = previousSessionDirectory || path.join(jeoHomeDir(), "sandbox-sessions", createHash("sha256").update(path.resolve(cwd)).digest("hex"));
478
+ const privateRoots = [stateDirectory, sessionDirectory, jeoHomeDir(), notifySessionsDir(), ...profilePaths];
479
+ const policy = await createSandboxPolicy({ sessionKey, revision: 0, cwd, config: sandboxConfig, privatePaths: privateRoots });
480
+ if (closing) throw new Error("Messenger worker stopping");
481
+ const sessionSupervisor = createSandboxSupervisor({ policy });
482
+ supervisor = sessionSupervisor;
483
+ process.env.JEO_SESSION_DIR = sessionDirectory;
484
+ await prepareSandboxSkillCatalog(sessionSupervisor, cwd, policy.mounts.filter(mount => mount.mode === "rw").map(mount => mount.host), policy.mounts.map(mount => mount.host));
485
+ if (closing) throw new Error("Messenger worker stopping");
486
+ const replacePolicy = sessionSupervisor.replacePolicy;
487
+ sessionSupervisor.replacePolicy = async nextPolicy => {
488
+ updateSandboxSkillCatalogRoots(sessionSupervisor, nextPolicy.mounts.filter(mount => mount.mode === "rw").map(mount => mount.host), nextPolicy.mounts.map(mount => mount.host));
489
+ await replacePolicy(nextPolicy);
490
+ };
491
+ const allowedUserIds: string[] = remoteConfig === undefined ? config.notifications?.discord?.allowedUserIds ?? [] : JSON.parse(process.env.JEO_MESSENGER_ALLOWED_USER_IDS || "[]");
492
+ if (!Array.isArray(allowedUserIds) || allowedUserIds.some(id => typeof id !== "string" || !id.trim())) throw new Error("UNAUTHORIZED: invalid sandbox actor allowlist");
493
+ const authority = await createGrantAuthority({ sessionKey, cwd, config: sandboxConfig, supervisor: sessionSupervisor, allowedUserIds, stateDirectory, privateRoots });
494
+ if (closing) throw new Error("Messenger worker stopping");
495
+ await withSandboxExecution({ supervisor: sessionSupervisor, authority, requestAccess: (host, mode) => authority.requestAccess(host, mode) }, runSession);
496
+ } finally {
497
+ setup.resolve();
498
+ await close();
499
+ process.removeListener("SIGTERM", handleTermination);
500
+ process.removeListener("message", handleControlMessage);
501
+ if (previousSessionDirectory === undefined) delete process.env.JEO_SESSION_DIR;
502
+ else process.env.JEO_SESSION_DIR = previousSessionDirectory;
503
+ }
504
+ }
505
+
506
+ async function runLaunchSession(args: string[], registerEndpointCleanup?: (cleanup?: () => Promise<void>) => void): Promise<void> {
390
507
  let cwd = process.cwd();
391
508
  const flags = parseFlags(args, cwd);
392
509
  if (flags.errors.length) {
@@ -600,6 +717,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
600
717
  const contextFiles = await loadProjectContext(cwd);
601
718
 
602
719
  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"]);
720
+ if (currentSandboxExecution()) KNOWN_TOOLS.add("sandbox_request");
603
721
  let allowedTools = new Set(KNOWN_TOOLS);
604
722
 
605
723
  if (flags.noTools) {
@@ -616,6 +734,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
616
734
  }
617
735
  allowedTools = new Set(valid);
618
736
  }
737
+ if (currentSandboxExecution() && !flags.noTools) allowedTools.add("sandbox_request");
619
738
 
620
739
  let resolvedSkills: SkillDoc[] = [];
621
740
  if (!flags.noSkills) {
@@ -665,6 +784,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
665
784
  KARPATHY_DISCIPLINE + "\n\n" +
666
785
  OUTPUT_DISCIPLINE + "\n\n" +
667
786
  VERIFICATION_DIRECTIVE +
787
+ (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
788
  "\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
789
  (allowedTools.has("task") ? "\n\nDelegation: " + taskToolProtocolLine(cfg) +
670
790
  " Call task with {\"role\": <one of the advertised roles>, \"task\": <assignment>, \"context\": <optional>} to hand a focused slice to a subagent." : "") +
@@ -740,7 +860,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
740
860
  // never persisted to config.
741
861
  const routeHistory = new RouteHistory();
742
862
  // 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;
863
+ let sessionComputerOverride: boolean | undefined = flags.literalWorker
864
+ ? process.env.JEO_MESSENGER_COMPUTER_ENABLED === "1" ? true : process.env.JEO_MESSENGER_COMPUTER_ENABLED === "0" ? false : undefined
865
+ : undefined;
744
866
 
745
867
  // Cache of live, credential-validated models per provider (refreshed by live pickers).
746
868
  // Every successful discovery is also persisted (`writeModelCache`), so the NEXT launch
@@ -964,7 +1086,25 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
964
1086
  // lifetime (not per-turn) so a Telegram forum topic, once created, persists
965
1087
  // across turns AND across `--resume` (keyed by jeo's real `sessionId`, not a
966
1088
  // random one — see `SessionNotifyEndpoint`'s header doc).
967
- const sessionNotifyEndpoint = flags.literalPrompt ? undefined : await startSessionNotifyEndpoint(cwd, sessionId);
1089
+ const sandboxAuthority = currentSandboxExecution()?.authority;
1090
+ const sessionNotifyEndpoint = flags.literalPrompt
1091
+ ? (sandboxAuthority ? new SessionNotifyEndpoint(cwd, process.env.JEO_MESSENGER_SESSION_KEY || sessionId) : undefined)
1092
+ : await startSessionNotifyEndpoint(cwd, sessionId);
1093
+ try {
1094
+ if (sessionNotifyEndpoint && sandboxAuthority) {
1095
+ sessionNotifyEndpoint.onSandboxControl = (text, actor) => {
1096
+ if (actor.sessionKey !== sessionNotifyEndpoint.sessionId) return Promise.resolve({ handled: true, output: "SESSION_MISMATCH" });
1097
+ return handleSandboxControl(text, { ...actor, sessionKey: sandboxAuthority.snapshot().sessionKey }, sandboxAuthority);
1098
+ };
1099
+ if (flags.literalPrompt) {
1100
+ sessionNotifyEndpoint.setDiscordControlEnabled(true);
1101
+ await sessionNotifyEndpoint.start();
1102
+ }
1103
+ }
1104
+ } finally {
1105
+ registerEndpointCleanup?.(sessionNotifyEndpoint ? () => sessionNotifyEndpoint.stop() : undefined);
1106
+ }
1107
+ const sandboxTranscriptId = sessionId;
968
1108
  if (sessionNotifyEndpoint) {
969
1109
  sessionNotifyEndpoint.sendIdentity({ repo: cwd.split(/[\\/]/).filter(Boolean).pop() ?? cwd, branch, cwd });
970
1110
  }
@@ -1007,6 +1147,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1007
1147
  sessionNotifyEndpoint?.detachRegistry();
1008
1148
  await sessionNotifyEndpoint?.stop();
1009
1149
  await stopSessionNotifyEndpoint(subagentRegistry);
1150
+ await turnSandboxScope?.supervisor.close();
1010
1151
  };
1011
1152
 
1012
1153
  // Persist the active per-session model into the session header so `/resume` restores
@@ -1077,7 +1218,8 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1077
1218
 
1078
1219
  // Run one conversational turn: compact, persist user msg, run the loop, persist + return the reply.
1079
1220
  // When `useTui`, a live TUI renders the turn and prints the final reply itself (rendered=true).
1080
- const runTurn = async (
1221
+ const turnSandboxScope = currentSandboxExecution();
1222
+ const runTurnScoped = async (
1081
1223
  userInput: string,
1082
1224
  useTui: boolean,
1083
1225
  images?: ImageAttachment[],
@@ -1088,6 +1230,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1088
1230
  opts?: { userCard?: string | null; signal?: AbortSignal; trustedContext?: string },
1089
1231
  ): Promise<{ done: boolean; steps: number; reply: string; rendered: boolean; usage: string }> => {
1090
1232
  opts?.signal?.throwIfAborted();
1233
+ if (currentSandboxExecution() && sessionId !== sandboxTranscriptId) throw new Error("SESSION_MISMATCH: start a new launch for the selected transcript; sandbox grants cannot cross sessions");
1091
1234
  let busyDraftHasPaste = false;
1092
1235
  const captureLiveInput = (chunk: string, view: LaunchTui | null): { captured: boolean; submittedEnter: boolean; handled: boolean } => {
1093
1236
  if (!queueBusyInput || !queueBusySnapshot) return { captured: false, submittedEnter: false, handled: false };
@@ -1097,6 +1240,18 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1097
1240
  const captured = queueBusyInput?.(chunk) ?? false;
1098
1241
  const draft = expandSentinel(queueBusySnapshot?.().text ?? "");
1099
1242
  if (!draft && !paste && !(queueBusyPasteActive?.() ?? false)) busyDraftHasPaste = false;
1243
+ if (submittedEnter && /^\/sandbox(?:\s|$)/.test(draft.trim())) {
1244
+ const report = (message: string): void => { if (view) view.events().onNotice?.(message); else console.log(message); };
1245
+ if (busyDraftHasPaste || !process.stdin.isTTY || !sandboxAuthority) {
1246
+ report("UNAUTHORIZED: sandbox controls require an active sandbox and directly submitted local prompt.");
1247
+ } else {
1248
+ queueBusyClear?.();
1249
+ view?.setLivePromptInput("");
1250
+ void handleSandboxControl(draft.trim(), { origin: "terminal", sessionKey: sandboxAuthority.snapshot().sessionKey }, sandboxAuthority)
1251
+ .then(result => report(result.output), error => report(error instanceof Error ? error.message : String(error)));
1252
+ }
1253
+ return { captured, submittedEnter, handled: true };
1254
+ }
1100
1255
  if (submittedEnter && /^\/discord\s+control\s+off$/.test(draft.trim())) {
1101
1256
  const endpoint = sessionNotifyEndpoint;
1102
1257
  if (!busyDraftHasPaste && endpoint && endpoint.sessionId === sessionId) {
@@ -1139,6 +1294,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1139
1294
  };
1140
1295
  const selectedModel = sessionModel || defaultModel;
1141
1296
  if (selectedModel.startsWith("agy/")) {
1297
+ if (currentSandboxExecution()) throw new Error("SANDBOX_UNSUPPORTED: official CLI agents cannot execute outside the session sandbox; select a raw-model provider");
1142
1298
  const oneShot = !!flags.print || flags.message.length > 0 || !process.stdin.isTTY;
1143
1299
  if (flags.noTools || flags.tools || flags.maxSteps > 0 || flags.systemPrompt || flags.appendSystemPrompt || flags.thinking) {
1144
1300
  if (oneShot) process.exitCode = 1;
@@ -1755,20 +1911,30 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1755
1911
  reason: string,
1756
1912
  evidence: { sawMutation: boolean; sawVerification: boolean; verificationStale: boolean },
1757
1913
  ): Promise<string | null> => {
1758
- const runningJobs = jobRegistry.running();
1914
+ const liveJobs = jobRegistry.running();
1915
+ if (liveJobs.length > 0) {
1916
+ const jobs = liveJobs.map(record => `${record.id} (${record.command})`).join(", ");
1917
+ 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.`;
1918
+ }
1919
+ const runningJobs = jobRegistry.unobserved();
1759
1920
  if (runningJobs.length > 0) {
1760
1921
  const jobs = runningJobs.map(record => `${record.id} (${record.command})`).join(", ");
1761
1922
  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.`
1923
+ `Cannot finish while background job(s) have unobserved completions: ${jobs}. ` +
1924
+ `Await them, then run fresh verification before calling done again.`
1764
1925
  );
1765
1926
  }
1766
- const runningMonitors = monitorRegistry.running();
1927
+ const liveMonitors = monitorRegistry.running();
1928
+ if (liveMonitors.length > 0) {
1929
+ const monitors = liveMonitors.map(record => `${record.id} (${record.command})`).join(", ");
1930
+ 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.`;
1931
+ }
1932
+ const runningMonitors = monitorRegistry.unobserved();
1767
1933
  if (runningMonitors.length > 0) {
1768
1934
  const monitors = runningMonitors.map(record => `${record.id} (${record.command})`).join(", ");
1769
1935
  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.`
1936
+ `Cannot finish while background monitor(s) have unobserved completions: ${monitors}. ` +
1937
+ `Await them, then run fresh verification before calling done again.`
1772
1938
  );
1773
1939
  }
1774
1940
 
@@ -1867,14 +2033,41 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1867
2033
  irc: createIrcTool(subagentRegistry),
1868
2034
  goal: createGoalTool(),
1869
2035
  approve: createApproveTool(),
2036
+ sandbox_request: async (a: Record<string, any>) => {
2037
+ const scope = currentSandboxExecution();
2038
+ if (!scope?.requestAccess) return { success: false, output: "", error: "No session sandbox authority" };
2039
+ if (a.action === "status" && scope.authority) {
2040
+ const { revision, grants, requests, blocked } = scope.authority.snapshot();
2041
+ return { success: true, output: JSON.stringify({ revision, grants, requests, blocked }) };
2042
+ }
2043
+ if (typeof a.host !== "string" || (a.mode !== "ro" && a.mode !== "rw")) return { success: false, output: "", error: "sandbox_request requires host and mode ro|rw" };
2044
+ const request = await scope.requestAccess(a.host, a.mode);
2045
+ return { success: true, output: JSON.stringify({ request, grants: scope.authority?.snapshot().grants ?? [] }) };
2046
+ },
1870
2047
  // `/computer on|off` session override wins over `config.computer.enabled`
1871
2048
  // (see `sessionComputerOverride`/`computer-slash.ts`); undefined = fall
1872
2049
  // 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
- },
2050
+ // Trusted mediated wrapper (SANDBOX_MEDIATED_TOOLS): inside a sandbox the parent
2051
+ // resolves consent from session/config ONLY and forwards it over the private
2052
+ // `__computer` hands handler; the host never executes the action.
2053
+ computer: (() => {
2054
+ const handler = async (a: Record<string, any>, hcwd: string, onProgress?: (text: string) => void) => {
2055
+ const sandbox = currentSandboxExecution();
2056
+ if (sandbox) {
2057
+ const cfg = await readGlobalConfig().catch(() => null);
2058
+ return sandbox.supervisor.call(
2059
+ "__computer",
2060
+ { action: a, enabled: sessionComputerOverride ?? !!cfg?.computer?.enabled },
2061
+ { cwd: hcwd, signal: sandbox.signal, onProgress },
2062
+ );
2063
+ }
2064
+ computerSupervisor.setKillSwitchLive(true);
2065
+ computerSupervisor.heartbeat();
2066
+ return executeComputerAction(a as any, { enabledOverride: sessionComputerOverride });
2067
+ };
2068
+ SANDBOX_MEDIATED_TOOLS.add(handler as any);
2069
+ return handler;
2070
+ })(),
1878
2071
 
1879
2072
  };
1880
2073
  const tools = filterToolMap(fullTools, Array.from(allowedTools));
@@ -2245,15 +2438,21 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2245
2438
  const usage = result.usage ? ` (${result.usage.inputTokens} in / ${result.usage.outputTokens} out tokens)` : "";
2246
2439
 
2247
2440
  if (!flags.literalPrompt && turnConfig.gitAutoCommit) {
2441
+ const sandbox = currentSandboxExecution();
2248
2442
  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);
2443
+ const statusRes = sandbox
2444
+ ? await sandbox.supervisor.exec(["git", "status", "--porcelain"], { cwd })
2445
+ : Bun.spawnSync(["git", "status", "--porcelain"], { cwd, stdout: "pipe", stderr: "ignore" });
2446
+ if (statusRes.exitCode === 0 && statusRes.stdout?.toString().trim()) {
2447
+ const addRes = sandbox ? await sandbox.supervisor.exec(["git", "add", "."], { cwd }) : Bun.spawnSync(["git", "add", "."], { cwd });
2448
+ if (addRes.exitCode !== 0) throw new Error("git add failed");
2449
+ const diffRes = sandbox
2450
+ ? await sandbox.supervisor.exec(["git", "diff", "--cached", "--name-status"], { cwd })
2451
+ : Bun.spawnSync(["git", "diff", "--cached", "--name-status"], { cwd, stdout: "pipe", stderr: "ignore" });
2452
+ const changedFiles = (diffRes.stdout?.toString() ?? "").trim().split("\n").map(line => line.split("\t")[1]).filter(Boolean);
2254
2453
  const fileSummary = changedFiles.length > 0 ? ` (modified: ${changedFiles.slice(0, 3).join(", ")}${changedFiles.length > 3 ? ", ..." : ""})` : "";
2255
2454
  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 });
2455
+ const commitRes = sandbox ? await sandbox.supervisor.exec(["git", "commit", "-m", commitMsg], { cwd }) : Bun.spawnSync(["git", "commit", "-m", commitMsg], { cwd });
2257
2456
  if (commitRes.exitCode === 0) {
2258
2457
  if (tui) tui.events().onNotice?.(`(auto-committed changes to git)`);
2259
2458
  else console.log(`(auto-committed changes to git)`);
@@ -2278,6 +2477,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2278
2477
  };
2279
2478
 
2280
2479
 
2480
+ const runTurn = (...args: Parameters<typeof runTurnScoped>) => turnSandboxScope
2481
+ ? withSandboxExecution(turnSandboxScope, () => runTurnScoped(...args))
2482
+ : runTurnScoped(...args);
2483
+
2281
2484
  const joinedArgs = flags.message;
2282
2485
  const isOneShot = flags.print || joinedArgs.length > 0 || !process.stdin.isTTY;
2283
2486
  // hermes-style experience distill (plan/gjc-inheritance.md B6) for one-shot
@@ -3064,6 +3267,8 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3064
3267
  let promptServedFromPaste = false;
3065
3268
  let promptWasRemote = false;
3066
3269
  let exclusiveRemote: { text: string; finish: (text?: string, artifactsAllowed?: boolean) => void; signal: AbortSignal; epoch: number; stage?: { output: string; input?: string } } | undefined;
3270
+ let pendingRemote: NonNullable<typeof exclusiveRemote> | undefined;
3271
+ let admitPendingRemote: () => void = () => {};
3067
3272
  const promptInput = async (prompt: string): Promise<string> => {
3068
3273
  promptServedFromPaste = false;
3069
3274
  promptWasRemote = false;
@@ -3114,6 +3319,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3114
3319
  }),
3115
3320
  new Promise<{ remote: string }>(resolve => {
3116
3321
  remoteInjectResolve = resolve;
3322
+ admitPendingRemote();
3117
3323
  }),
3118
3324
  ]);
3119
3325
  // The aborted `rl.question()` lost the race (a remote message or stdin-close
@@ -3650,6 +3856,13 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3650
3856
  sessionNotifyEndpoint?.detachRegistry();
3651
3857
  void sessionNotifyEndpoint?.stop();
3652
3858
  void stopSessionNotifyEndpoint(subagentRegistry);
3859
+ if (turnSandboxScope) {
3860
+ void turnSandboxScope.supervisor.close().then(() => process.exit(130), error => {
3861
+ console.error(`REVOKE_INCOMPLETE: ${error instanceof Error ? error.message : String(error)}`);
3862
+ process.exit(1);
3863
+ });
3864
+ return;
3865
+ }
3653
3866
  process.exit(130);
3654
3867
  };
3655
3868
  // Prompt Ctrl+C: a single press clears a non-empty box; on an empty box it exits.
@@ -4571,19 +4784,32 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4571
4784
  // established by now. See the Tier 2 contract's "Session-side routing".
4572
4785
  if (sessionNotifyEndpoint) {
4573
4786
  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 };
4787
+ admitPendingRemote = (): void => {
4788
+ if (!pendingRemote || exclusiveRemote || interactiveTurnActive || remoteInputPending || promptPasteActive || pasteLineFired || pasteInFlight || draftHasPaste || Reflect.get(rl, "line") !== "" || !pendingRemoteInject ||
4789
+ pendingImages.length || pendingStdinLines.length || pendingMidTurnCommands.length || queuedPromptInput.partial || queuedPromptInput.pastedLines.length || sessionNotifyEndpoint.sessionId !== sessionId) return;
4790
+ const request = pendingRemote;
4791
+ if (request.signal.aborted) { pendingRemote = undefined; request.finish(); return; }
4792
+ request.epoch = ++relayInputEpoch;
4581
4793
  exclusiveRemote = request;
4582
- if (pendingRemoteInject(text)) return true;
4583
- exclusiveRemote = undefined;
4584
- return false;
4794
+ if (pendingRemoteInject(request.text)) pendingRemote = undefined;
4795
+ else exclusiveRemote = undefined;
4796
+ };
4797
+ sessionNotifyEndpoint.onRemoteRequest = (text, finish, signal, stage, actor): boolean => {
4798
+ if (!sandboxAuthority || actor?.origin !== "discord" || actor.sessionKey !== sessionNotifyEndpoint.sessionId ||
4799
+ !cfg.notifications?.discord?.allowedUserIds?.includes(actor.userId ?? "") || pendingRemote || signal.aborted) return false;
4800
+ if (stage && ((sessionModel || defaultModel).startsWith("agy/") || history[0]?.role !== "system")) return false;
4801
+ const request = { text, finish, signal, stage, epoch: 0 };
4802
+ pendingRemote = request;
4803
+ signal.addEventListener("abort", () => {
4804
+ if (pendingRemote === request) { pendingRemote = undefined; finish(); }
4805
+ }, { once: true });
4806
+ admitPendingRemote();
4807
+ return true;
4585
4808
  };
4586
4809
  sessionNotifyEndpoint.onUserMessage = async (msg): Promise<void | boolean> => {
4810
+ if (!sandboxAuthority) return false;
4811
+ if (msg.actor && (msg.actor.origin !== "discord" || msg.actor.sessionKey !== sessionNotifyEndpoint.sessionId ||
4812
+ !cfg.notifications?.discord?.allowedUserIds?.includes(msg.actor.userId ?? ""))) return false;
4587
4813
  relayInputEpoch++;
4588
4814
  remoteInputPending++;
4589
4815
  try {
@@ -4768,6 +4994,18 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4768
4994
  pendingSelection = undefined;
4769
4995
  navMatches = [];
4770
4996
  navIdx = -1;
4997
+ if (input === "/sandbox" || input.startsWith("/sandbox ")) {
4998
+ if (!directLocalPrompt || explicitPastedDraft || promptServedFromPaste || remoteInputPending !== 0 ||
4999
+ relayInputEpoch - relayEpoch !== ambiguousPasteEpoch - ambiguousEpoch) {
5000
+ console.log("UNAUTHORIZED: sandbox controls require a directly submitted local prompt.");
5001
+ } else if (!sandboxAuthority) {
5002
+ console.log("No session sandbox is active.");
5003
+ } else {
5004
+ const result = await handleSandboxControl(input, { origin: "terminal", sessionKey: sandboxAuthority.snapshot().sessionKey }, sandboxAuthority);
5005
+ console.log(result.output);
5006
+ }
5007
+ continue;
5008
+ }
4771
5009
  if (input === "/discord" || input.startsWith("/discord ")) {
4772
5010
  const parts = input.split(/\s+/);
4773
5011
  const isControlOn = parts.length === 4 && parts[1] === "control" && parts[2] === "on" && parts[3] === "--allow-full-control";
@@ -4776,6 +5014,8 @@ navMatches = [];
4776
5014
  if (!directLocalPrompt || explicitPastedDraft || promptServedFromPaste || remoteInputPending !== 0 ||
4777
5015
  relayInputEpoch - relayEpoch !== ambiguousPasteEpoch - ambiguousEpoch) {
4778
5016
  console.log("Discord control requires a directly submitted local prompt.");
5017
+ } else if (isControlOn && !sandboxAuthority) {
5018
+ console.log("SANDBOX_REQUIRED: configure a sandbox and start a new launch before enabling Discord control.");
4779
5019
  } else if (!sessionNotifyEndpoint || sessionNotifyEndpoint.sessionId !== sessionId) {
4780
5020
  console.log("Discord control unavailable: notification session endpoint unavailable.");
4781
5021
  } else {
@@ -4786,7 +5026,7 @@ navMatches = [];
4786
5026
  } else {
4787
5027
  sessionNotifyEndpoint.setDiscordControlEnabled(isControlOn);
4788
5028
  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."
5029
+ ? "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
5030
  : "Discord control revoked for this session.");
4791
5031
  }
4792
5032
  }