jeo-code 0.12.13 → 0.12.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +55 -1
- package/README.ja.md +33 -9
- package/README.ko.md +33 -9
- package/README.md +33 -9
- package/README.zh.md +33 -9
- package/package.json +4 -2
- package/src/agent/browser-tool.ts +9 -1
- package/src/agent/config-schema.ts +8 -0
- package/src/agent/context-files.ts +19 -0
- package/src/agent/engine.ts +25 -6
- package/src/agent/eval-tool.ts +6 -0
- package/src/agent/goal-verifier.ts +30 -6
- package/src/agent/hooks.ts +52 -4
- package/src/agent/job-registry.ts +110 -18
- package/src/agent/job-tool.ts +43 -9
- package/src/agent/loop-guards.ts +17 -2
- package/src/agent/memory.ts +151 -112
- package/src/agent/monitor-registry.ts +142 -30
- package/src/agent/monitor-tool.ts +34 -6
- package/src/agent/notify/discord-daemon.ts +135 -42
- package/src/agent/notify/session-endpoint.ts +27 -5
- package/src/agent/sandbox/assets.d.ts +4 -0
- package/src/agent/sandbox/authority.ts +358 -0
- package/src/agent/sandbox/bundle.ts +40 -0
- package/src/agent/sandbox/control.ts +49 -0
- package/src/agent/sandbox/docker.ts +293 -0
- package/src/agent/sandbox/hands-source.text +14548 -0
- package/src/agent/sandbox/hands-worker.ts +168 -0
- package/src/agent/sandbox/policy.ts +172 -0
- package/src/agent/sandbox/rpc.ts +90 -0
- package/src/agent/sandbox/supervisor.ts +210 -0
- package/src/agent/sandbox/types.ts +77 -0
- package/src/agent/shell-lease.ts +190 -0
- package/src/agent/state.ts +167 -0
- package/src/agent/subagents.ts +4 -3
- package/src/agent/task-tool.ts +8 -0
- package/src/agent/todo-tool.ts +30 -1
- package/src/agent/tool-schemas.ts +13 -0
- package/src/ai/model-discovery.ts +12 -0
- package/src/ai/providers/anthropic.ts +27 -2
- package/src/ai/providers/antigravity.ts +10 -2
- package/src/ai/providers/gemini.ts +20 -16
- package/src/ai/providers/ollama.ts +14 -0
- package/src/ai/providers/openai-responses.ts +22 -5
- package/src/ai/providers/openai.ts +23 -40
- package/src/cli/runner.ts +3 -0
- package/src/commands/approve.ts +37 -13
- package/src/commands/launch/flags.ts +2 -1
- package/src/commands/launch/jobs-slash.ts +1 -1
- package/src/commands/launch.ts +275 -35
- package/src/commands/messenger.ts +107 -39
- package/src/commands/notify.ts +2 -2
- package/src/commands/team.ts +44 -12
- package/src/commands/ultragoal.ts +6 -16
- package/src/messenger/activity.ts +2 -1
- package/src/messenger/config.ts +78 -10
- package/src/messenger/discord-setup.ts +216 -34
- package/src/messenger/discord.ts +37 -43
- package/src/messenger/gateway.ts +85 -37
- package/src/messenger/model-selection.ts +101 -0
- package/src/messenger/session-pool.ts +189 -35
- package/src/messenger/types.ts +5 -2
- package/src/skills/catalog.ts +207 -0
- package/src/util/retry.ts +12 -0
|
@@ -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
|
}
|
package/src/commands/launch.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
1763
|
-
`
|
|
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
|
|
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)
|
|
1771
|
-
`
|
|
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
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
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 =
|
|
2250
|
-
|
|
2251
|
-
Bun.spawnSync(["git", "
|
|
2252
|
-
|
|
2253
|
-
const
|
|
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
|
-
|
|
4575
|
-
|
|
4576
|
-
|
|
4577
|
-
|
|
4578
|
-
|
|
4579
|
-
|
|
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))
|
|
4583
|
-
exclusiveRemote = undefined;
|
|
4584
|
-
|
|
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.
|
|
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
|
}
|