jeo-code 0.12.16 → 0.12.17
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 +14 -1
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +10 -10
- package/README.zh.md +1 -1
- package/package.json +1 -1
- package/src/agent/engine.ts +63 -8
- package/src/agent/hooks.ts +2 -1
- package/src/agent/loop-guards.ts +18 -7
- package/src/agent/sandbox/hands-source.text +540 -540
- package/src/agent/sandbox/supervisor.ts +21 -9
- package/src/agent/shell-lease.ts +2 -1
- package/src/agent/task-tool.ts +17 -3
- package/src/agent/tools.ts +2 -1
- package/src/commands/launch/input.ts +25 -3
- package/src/commands/launch.ts +9 -2
- package/src/commands/ralplan.ts +14 -1
- package/src/prompts/AGENTS.md +1 -1
- package/src/util/posix-tools.ts +66 -0
|
@@ -162,20 +162,29 @@ export function createSandboxSupervisor(options: { policy: SandboxPolicy; backen
|
|
|
162
162
|
return effect.result.promise;
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
/** A Windows host path that matches no mount is not "relative" to the Linux guest; it must never be forwarded. */
|
|
165
166
|
function guestPath(value: string): string {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
167
|
+
const mount = path.isAbsolute(value) ? [...policy.mounts].sort((a, b) => b.host.length - a.host.length).find(item => sandboxPathContains(item.host, value)) : undefined;
|
|
168
|
+
if (mount) {
|
|
169
|
+
const relative = path.relative(mount.host, value).split(path.sep).join("/");
|
|
170
|
+
return relative ? `${mount.guest}/${relative}` : mount.guest;
|
|
171
|
+
}
|
|
172
|
+
if (process.platform === "win32" && /^(?:[A-Za-z]:|[\\/]{2}|\\)/.test(value)) throw new Error("SANDBOX_POLICY: host path is outside approved mounts");
|
|
173
|
+
return value;
|
|
171
174
|
}
|
|
172
175
|
|
|
173
176
|
function call(tool: string, args: Record<string, unknown>, callOptions?: SandboxCallOptions): Promise<ToolResult> {
|
|
174
177
|
const mapped = { ...args };
|
|
175
|
-
|
|
176
|
-
|
|
178
|
+
let cwd: string | undefined;
|
|
179
|
+
try {
|
|
180
|
+
for (const key of ["filePath", "path", "dirPath", "dir", "targetPath", "cwd", "subdir"]) {
|
|
181
|
+
if (typeof mapped[key] === "string") mapped[key] = guestPath(mapped[key]);
|
|
182
|
+
}
|
|
183
|
+
if (callOptions?.cwd) cwd = guestPath(callOptions.cwd);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
return Promise.resolve({ success: false, output: "", error: error instanceof Error ? error.message : String(error) });
|
|
177
186
|
}
|
|
178
|
-
return enqueue({ id: crypto.randomUUID(), type: "tool", tool: tool === "approve" ? "__plan_approve" : tool, args: mapped, ...(
|
|
187
|
+
return enqueue({ id: crypto.randomUUID(), type: "tool", tool: tool === "approve" ? "__plan_approve" : tool, args: mapped, ...(cwd ? { cwd } : {}) }, callOptions);
|
|
179
188
|
}
|
|
180
189
|
|
|
181
190
|
return {
|
|
@@ -184,7 +193,10 @@ export function createSandboxSupervisor(options: { policy: SandboxPolicy; backen
|
|
|
184
193
|
return Object.fromEntries(names.map(name => [name, (args: Record<string, unknown>, cwd: string, onProgress?: (text: string) => void) => call(name, args, { cwd, onProgress })]));
|
|
185
194
|
},
|
|
186
195
|
exec(argv: readonly string[], execOptions: SandboxExecOptions = {}): Promise<SandboxExecResult> {
|
|
187
|
-
|
|
196
|
+
let cwd: string | undefined;
|
|
197
|
+
try { if (execOptions.cwd) cwd = guestPath(execOptions.cwd); }
|
|
198
|
+
catch (error) { return Promise.resolve({ success: false, output: "", error: error instanceof Error ? error.message : String(error) }); }
|
|
199
|
+
return enqueue({ id: crypto.randomUUID(), type: "exec", argv: [...argv], ...(cwd ? { cwd } : {}), ...(execOptions.env ? { env: execOptions.env } : {}), ...(execOptions.stdin !== undefined ? { stdin: execOptions.stdin } : {}) }, { signal: execOptions.signal });
|
|
188
200
|
},
|
|
189
201
|
async replacePolicy(next): Promise<void> {
|
|
190
202
|
if (state === "closed" || state === "changing" || next.sessionKey !== policy.sessionKey || next.revision <= policy.revision) throw new Error("SANDBOX_POLICY: closed, changing or invalid policy revision");
|
package/src/agent/shell-lease.ts
CHANGED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
*/
|
|
34
34
|
import * as cp from "node:child_process";
|
|
35
35
|
import * as fs from "node:fs";
|
|
36
|
+
import { resolvePosixTool } from "../util/posix-tools";
|
|
36
37
|
import * as os from "node:os";
|
|
37
38
|
import * as path from "node:path";
|
|
38
39
|
import { Readable } from "node:stream";
|
|
@@ -96,7 +97,7 @@ export function spawnLeasedShell(command: string, cwd: string): LeasedShell {
|
|
|
96
97
|
}
|
|
97
98
|
|
|
98
99
|
function spawnDirect(command: string, cwd: string): LeasedShell {
|
|
99
|
-
const proc = Bun.spawn(["bash", "-c", command], { cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe" });
|
|
100
|
+
const proc = Bun.spawn([resolvePosixTool("bash"), "-c", command], { cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe" });
|
|
100
101
|
const stdout = proc.stdout.getReader();
|
|
101
102
|
const stderr = proc.stderr.getReader();
|
|
102
103
|
const reaped = proc.exited.then(() => undefined);
|
package/src/agent/task-tool.ts
CHANGED
|
@@ -467,6 +467,7 @@ export async function runSubagentOnce(
|
|
|
467
467
|
const failoverEnabled = !!opts.config.routing?.enabled;
|
|
468
468
|
let activeModel = initialModel;
|
|
469
469
|
emit({ role: role.id, kind: "start", detail: taskText, maxSteps, model: activeModel });
|
|
470
|
+
let contractCorrections = 0;
|
|
470
471
|
const runOnce = () => {
|
|
471
472
|
return runAgentLoop(history, {
|
|
472
473
|
cwd,
|
|
@@ -474,14 +475,27 @@ export async function runSubagentOnce(
|
|
|
474
475
|
maxSteps,
|
|
475
476
|
maxTokens: resolveMaxOutputTokens(activeModel, thinking),
|
|
476
477
|
sessionKey,
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
budget: { maxExtensions:
|
|
478
|
+
// Allow bounded step-budget extensions when a subagent is actively making
|
|
479
|
+
// progress so multi-file edits + test verification do not get cut off mid-run.
|
|
480
|
+
budget: { maxExtensions: role.readOnly ? 1 : 2 },
|
|
480
481
|
signal,
|
|
481
482
|
steer,
|
|
482
483
|
tools: subagentToolset(role),
|
|
483
484
|
...(failoverEnabled ? { rateLimitFallbackAvailable, safetyFallbackAvailable } : {}),
|
|
484
485
|
events: {
|
|
486
|
+
onBeforeDone: reason => {
|
|
487
|
+
const hasToolActivity = fileMutations > 0 || bashRuns > 0 || readOnlyEvidenceCalls > 0;
|
|
488
|
+
if (!hasToolActivity || contractCorrections >= 1) return null;
|
|
489
|
+
const check = validateSubagentDoneReason(role, reason);
|
|
490
|
+
if (!check.ok) {
|
|
491
|
+
contractCorrections++;
|
|
492
|
+
return (
|
|
493
|
+
`Your done.reason is missing required section(s) for ${role.title}: ${check.missing?.join(", ")}. ` +
|
|
494
|
+
`Call done again with non-empty content under every required section header.`
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
return null;
|
|
498
|
+
},
|
|
485
499
|
onEvidence: evidence => {
|
|
486
500
|
if (evidence.sawMutation) mutationMayHaveOccurred = true;
|
|
487
501
|
},
|
package/src/agent/tools.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { applyBashFixups } from "./bash-fixups";
|
|
2
|
+
import { resolvePosixTool } from "../util/posix-tools";
|
|
2
3
|
import * as fs from "node:fs/promises";
|
|
3
4
|
import * as path from "node:path";
|
|
4
5
|
import { readWorkflowStateStrict, type WorkflowState } from "./state";
|
|
@@ -727,7 +728,7 @@ export async function bashTool(
|
|
|
727
728
|
const reapBackground = isBackgroundReapEnabled();
|
|
728
729
|
const grouped = reapBackground && process.platform !== "win32";
|
|
729
730
|
if (grouped) ensureBackgroundSweeper();
|
|
730
|
-
const proc = Bun.spawn(["bash", "-c", command], {
|
|
731
|
+
const proc = Bun.spawn([resolvePosixTool("bash"), "-c", command], {
|
|
731
732
|
cwd: runCwd,
|
|
732
733
|
// Detach stdin (no inherited TTY): a command that reads stdin (`cat`, `read`,
|
|
733
734
|
// a REPL, an interactive prompt) then gets immediate EOF and exits instead of
|
|
@@ -439,7 +439,7 @@ export function captureLivePromptInputChunk(
|
|
|
439
439
|
const plain = start === -1 ? rest : rest.slice(0, start);
|
|
440
440
|
if (start !== -1) state.inPaste = true;
|
|
441
441
|
rest = start === -1 ? "" : rest.slice(start + PASTE_START.length);
|
|
442
|
-
if (feedLivePromptSegment(state, plain)) accepted = true;
|
|
442
|
+
if (feedLivePromptSegment(state, decodeExtendedKeys(stripMouseReports(plain)))) accepted = true;
|
|
443
443
|
}
|
|
444
444
|
}
|
|
445
445
|
return accepted;
|
|
@@ -506,10 +506,17 @@ export function createInFlightAbortHarness(opts: AbortHarnessOptions = {}): InFl
|
|
|
506
506
|
opts.onBufferedInput?.(raw);
|
|
507
507
|
return;
|
|
508
508
|
}
|
|
509
|
+
// Strip mouse reports and terminal capability replies (OSC/CSI device attributes)
|
|
510
|
+
// first so terminal responses arriving mid-turn never leak into the live draft.
|
|
511
|
+
const stripped = stripMouseReports(raw);
|
|
512
|
+
if (!stripped && raw.length > 0) {
|
|
513
|
+
opts.onNoise?.();
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
509
516
|
// Kitty/modifyOtherKeys re-encodings → legacy bytes FIRST, so Esc (`CSI 27u`) and
|
|
510
517
|
// Ctrl+C (`CSI 99;5u` / `CSI 27;5;99~`) cancel a live turn on every terminal —
|
|
511
518
|
// previously they fell into the "noise" branch and did NOTHING mid-turn.
|
|
512
|
-
const text = decodeExtendedKeys(
|
|
519
|
+
const text = decodeExtendedKeys(stripped);
|
|
513
520
|
if (text === "\u000f") {
|
|
514
521
|
opts.onDetailKey?.();
|
|
515
522
|
return;
|
|
@@ -579,7 +586,22 @@ export function classifyMidTurnLine(line: string): "command" | "steer" | "empty"
|
|
|
579
586
|
// A lone sigil ("/" or "$") has no command name — ignore it instead of aborting the
|
|
580
587
|
// running turn to dispatch an empty command (a stray slash should not interrupt work).
|
|
581
588
|
if (t === "/" || t === "$") return "empty";
|
|
582
|
-
|
|
589
|
+
const firstToken = t.split(/\s+/, 1)[0] ?? "";
|
|
590
|
+
if (firstToken.startsWith("/")) {
|
|
591
|
+
// A slash command is a single leading token (/model, /help, /?); a token containing
|
|
592
|
+
// additional slashes (/Users/foo/bar, /tmp/a.ts, //comment) is a path or comment,
|
|
593
|
+
// never a REPL command that should abort the running turn.
|
|
594
|
+
const name = firstToken.slice(1);
|
|
595
|
+
if (!name || name.includes("/")) return "steer";
|
|
596
|
+
return /^[a-zA-Z?][a-zA-Z0-9_-]*$/.test(name) ? "command" : "steer";
|
|
597
|
+
}
|
|
598
|
+
if (firstToken.startsWith("$")) {
|
|
599
|
+
// A skill invocation starts with a skill identifier ($team, $ralplan), not a
|
|
600
|
+
// numeric dollar amount ($100) or path ($/foo).
|
|
601
|
+
const name = firstToken.slice(1);
|
|
602
|
+
return /^[a-zA-Z][a-zA-Z0-9_:-]*$/.test(name) ? "command" : "steer";
|
|
603
|
+
}
|
|
604
|
+
return "steer";
|
|
583
605
|
}
|
|
584
606
|
/** Default window (ms) over which the several delivery paths of ONE physical Ctrl+C
|
|
585
607
|
* press — readline 'keypress', the rl/process 'SIGINT' event, and the raw \u0003 stdin
|
package/src/commands/launch.ts
CHANGED
|
@@ -1967,7 +1967,7 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
|
|
|
1967
1967
|
if ((pendingMutationVerification || evidence.sawMutation) && !(evidence.sawVerification && !evidence.verificationStale)) {
|
|
1968
1968
|
return (
|
|
1969
1969
|
`Cannot finish: a workspace mutation from this session still lacks a successful, non-stale verification. ` +
|
|
1970
|
-
`Run a relevant test/build/typecheck/lint after the last change, then call done again.`
|
|
1970
|
+
`Run a relevant test/build/typecheck/lint after the last change (e.g. 'bun test', 'bun run typecheck', 'tsc --noEmit', 'npm test', 'pytest', 'cargo test', or 'go test ./...'), then call done again.`
|
|
1971
1971
|
);
|
|
1972
1972
|
}
|
|
1973
1973
|
|
|
@@ -3177,7 +3177,14 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
|
|
|
3177
3177
|
queueBusySnapshot = () => ({
|
|
3178
3178
|
text: queuedPromptInput.partial,
|
|
3179
3179
|
});
|
|
3180
|
-
queueBusyClear = () => {
|
|
3180
|
+
queueBusyClear = () => {
|
|
3181
|
+
queuedPromptInput.partial = "";
|
|
3182
|
+
const r = rl as unknown as { line?: string; cursor?: number };
|
|
3183
|
+
if (typeof r.line === "string") {
|
|
3184
|
+
r.line = "";
|
|
3185
|
+
r.cursor = 0;
|
|
3186
|
+
}
|
|
3187
|
+
};
|
|
3181
3188
|
queueBusyCommand = (line: string) => {
|
|
3182
3189
|
// NO_MIDTURN_DISPATCH=1 keeps the legacy prefill path (tee up, press Enter); the
|
|
3183
3190
|
// default routes to the immediate-dispatch queue served at the top of the loop.
|
package/src/commands/ralplan.ts
CHANGED
|
@@ -80,15 +80,28 @@ export async function runConsensusCriticGate(args: {
|
|
|
80
80
|
// is gated the SAME way: zero observed read/search/find/ast_grep/lsp calls means
|
|
81
81
|
// the [OKAY]/[ITERATE]/[REJECT] text is unevidenced regardless of what it claims.
|
|
82
82
|
let evidenceCalls = 0;
|
|
83
|
+
let contractCorrections = 0;
|
|
83
84
|
const runOnce = () => runAgentLoop(history, {
|
|
84
85
|
cwd: args.cwd,
|
|
85
86
|
model: activeModel,
|
|
86
87
|
maxSteps,
|
|
87
|
-
budget: { maxExtensions:
|
|
88
|
+
budget: { maxExtensions: 1 },
|
|
88
89
|
signal: args.signal,
|
|
89
90
|
tools: subagentToolset(role),
|
|
90
91
|
rateLimitFallbackAvailable,
|
|
91
92
|
events: {
|
|
93
|
+
onBeforeDone: reason => {
|
|
94
|
+
if (evidenceCalls === 0 || contractCorrections >= 1) return null;
|
|
95
|
+
const check = validateSubagentDoneReason(role, reason);
|
|
96
|
+
if (!check.ok) {
|
|
97
|
+
contractCorrections++;
|
|
98
|
+
return (
|
|
99
|
+
`Your critic report is missing required section(s): ${check.missing?.join(", ")}. ` +
|
|
100
|
+
`Call done again starting with [OKAY], [ITERATE], or [REJECT] and include a non-empty 'Justification:' section.`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
},
|
|
92
105
|
onToolResult: (tool, success) => {
|
|
93
106
|
if (success && (tool === "read" || tool === "search" || tool === "find" || tool === "ast_grep" || tool === "lsp")) evidenceCalls++;
|
|
94
107
|
},
|
package/src/prompts/AGENTS.md
CHANGED
|
@@ -23,7 +23,7 @@ Bundled system prompts, role definitions, and workflow skill documentation for t
|
|
|
23
23
|
- Do not remove existing constraints without explicit reason.
|
|
24
24
|
|
|
25
25
|
### Testing Requirements
|
|
26
|
-
- Prompt changes should be verified via end-to-end testing
|
|
26
|
+
- Prompt changes should be verified via end-to-end testing.
|
|
27
27
|
|
|
28
28
|
### Common Patterns
|
|
29
29
|
- XML-like tags for structural organization within prompts.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POSIX tool resolution for Windows hosts.
|
|
3
|
+
*
|
|
4
|
+
* `Bun.spawn(["bash"|"grep"|"find", …])` resolves from PATH, where Windows
|
|
5
|
+
* ships imposters ahead of Git for Windows:
|
|
6
|
+
* - `C:\Windows\System32\bash.exe` launches WSL — Windows paths
|
|
7
|
+
* (`C:\Users\…`) do not exist inside it and caller env vars are dropped.
|
|
8
|
+
* - `C:\Windows\System32\find.exe` is the legacy DOS FIND (substring
|
|
9
|
+
* filter), not POSIX find.
|
|
10
|
+
*
|
|
11
|
+
* Resolve to real Git for Windows binaries instead, with an env override.
|
|
12
|
+
* Non-Windows platforms pass the bare name through unchanged.
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync } from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
|
|
17
|
+
export type PosixTool = "bash" | "grep" | "find";
|
|
18
|
+
|
|
19
|
+
export interface ResolveOptions {
|
|
20
|
+
/** Platform override for tests (default: process.platform). */
|
|
21
|
+
readonly platform?: NodeJS.Platform;
|
|
22
|
+
/** Env override for tests (default: process.env). */
|
|
23
|
+
readonly env?: Record<string, string | undefined>;
|
|
24
|
+
/** File-existence probe override for tests (default: fs.existsSync). */
|
|
25
|
+
readonly exists?: (p: string) => boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Candidate absolute paths for a tool, in preference order. */
|
|
29
|
+
export function candidatePaths(
|
|
30
|
+
tool: PosixTool,
|
|
31
|
+
env: Record<string, string | undefined>
|
|
32
|
+
): string[] {
|
|
33
|
+
const override = env[`JOC_${tool.toUpperCase()}_PATH`];
|
|
34
|
+
const roots = [
|
|
35
|
+
env["ProgramFiles"] && path.join(env["ProgramFiles"], "Git"),
|
|
36
|
+
env["ProgramFiles(x86)"] && path.join(env["ProgramFiles(x86)"], "Git"),
|
|
37
|
+
env["LOCALAPPDATA"] && path.join(env["LOCALAPPDATA"], "Programs", "Git"),
|
|
38
|
+
].filter((r): r is string => Boolean(r));
|
|
39
|
+
// bash lives in both bin/ and usr/bin/; grep/find only in usr/bin/.
|
|
40
|
+
const subdirs = tool === "bash" ? ["bin", path.join("usr", "bin")] : [path.join("usr", "bin")];
|
|
41
|
+
return [
|
|
42
|
+
...(override ? [override] : []),
|
|
43
|
+
...roots.flatMap(root => subdirs.map(sub => path.join(root, sub, `${tool}.exe`))),
|
|
44
|
+
];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const cache = new Map<PosixTool, string>();
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resolve `tool` to a Unix-semantics executable. Returns an absolute Git
|
|
51
|
+
* for Windows path when found, otherwise the bare name (PATH fallback).
|
|
52
|
+
*/
|
|
53
|
+
export function resolvePosixTool(tool: PosixTool, opts: ResolveOptions = {}): string {
|
|
54
|
+
const platform = opts.platform ?? process.platform;
|
|
55
|
+
if (platform !== "win32") return tool;
|
|
56
|
+
const injected = opts.env || opts.exists;
|
|
57
|
+
if (!injected) {
|
|
58
|
+
const hit = cache.get(tool);
|
|
59
|
+
if (hit) return hit;
|
|
60
|
+
}
|
|
61
|
+
const env = opts.env ?? process.env;
|
|
62
|
+
const exists = opts.exists ?? existsSync;
|
|
63
|
+
const found = candidatePaths(tool, env).find(c => exists(c)) ?? tool;
|
|
64
|
+
if (!injected) cache.set(tool, found);
|
|
65
|
+
return found;
|
|
66
|
+
}
|