jeo-code 0.12.16 → 0.12.18
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 +21 -1
- package/README.ja.md +2 -2
- package/README.ko.md +2 -2
- package/README.md +11 -11
- package/README.zh.md +2 -2
- 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/session-model.ts +84 -0
- 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/session-slash.ts +0 -2
- package/src/commands/launch.ts +17 -7
- 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");
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-start model resolution against the models jeo is actually logged in to.
|
|
3
|
+
*
|
|
4
|
+
* A session opens on the configured default model. That default is honored only
|
|
5
|
+
* when the stored login can serve it; otherwise the first turn would fail with
|
|
6
|
+
* "No credential for provider …" even though another logged-in model works. In that
|
|
7
|
+
* case the session starts on the first authenticated candidate (recents, then role
|
|
8
|
+
* tiers) and says so. The saved config is never rewritten here: the fallback is
|
|
9
|
+
* session-local, so the configured default is untouched.
|
|
10
|
+
*
|
|
11
|
+
* Fail-open: a probe that throws keeps the configured default.
|
|
12
|
+
*/
|
|
13
|
+
import { describeModel, modelServableWithConfig } from "../ai/model-manager";
|
|
14
|
+
import { describeProvider } from "../ai/provider-status";
|
|
15
|
+
import { customProviderDef } from "../ai/providers/custom-providers";
|
|
16
|
+
import type { Config } from "./state";
|
|
17
|
+
|
|
18
|
+
export type ModelAuthCheck = (model: string, cfg: Config) => Promise<boolean>;
|
|
19
|
+
|
|
20
|
+
export interface StartupModel {
|
|
21
|
+
/** The model the session should treat as its default. */
|
|
22
|
+
model: string;
|
|
23
|
+
/** The configured default this replaced, when no login could serve it. */
|
|
24
|
+
fellBackFrom?: string;
|
|
25
|
+
/** One-line explanation for the user, present only when a fallback happened. */
|
|
26
|
+
notice?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Whether jeo's stored credentials can serve `model` right now. Same two gates
|
|
31
|
+
* routing uses: the provider has a live credential, and, for built-in providers,
|
|
32
|
+
* that credential serves this specific model (ChatGPT OAuth serves only Codex ids).
|
|
33
|
+
* agy runs as an external CLI session and custom providers resolve their own key,
|
|
34
|
+
* so for those only provider readiness applies. Local providers are keyless.
|
|
35
|
+
*/
|
|
36
|
+
export const isModelAuthenticated: ModelAuthCheck = async (model, cfg) => {
|
|
37
|
+
const { resolved, provider } = await describeModel(model, cfg);
|
|
38
|
+
const status = await describeProvider(provider, cfg);
|
|
39
|
+
if (!status.ready) return false;
|
|
40
|
+
if (provider === "agy" || customProviderDef(provider)) return true;
|
|
41
|
+
return modelServableWithConfig(provider, resolved, cfg);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const FALLBACK_ROLES = ["medium", "high", "smol", "xhigh", "slow", "plan"] as const;
|
|
45
|
+
|
|
46
|
+
/** Fallback order: the user's recently picked models, then their configured role tiers. */
|
|
47
|
+
function fallbackCandidates(cfg: Config): string[] {
|
|
48
|
+
const ordered = [...(cfg.recentModels ?? []), ...FALLBACK_ROLES.map(role => cfg.roles?.[role])];
|
|
49
|
+
const seen = new Set<string>([cfg.defaultModel]);
|
|
50
|
+
const out: string[] = [];
|
|
51
|
+
for (const model of ordered) {
|
|
52
|
+
if (typeof model !== "string" || !model.trim() || seen.has(model)) continue;
|
|
53
|
+
seen.add(model);
|
|
54
|
+
out.push(model);
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Resolve the model a new session should open on: the configured default when the
|
|
61
|
+
* login serves it, else the first authenticated fallback, else the configured
|
|
62
|
+
* default unchanged (the first turn then reports the real credential error).
|
|
63
|
+
*/
|
|
64
|
+
export async function resolveStartupDefaultModel(cfg: Config, check: ModelAuthCheck = isModelAuthenticated): Promise<StartupModel> {
|
|
65
|
+
const configured = cfg.defaultModel;
|
|
66
|
+
try {
|
|
67
|
+
if (await check(configured, cfg)) return { model: configured };
|
|
68
|
+
} catch {
|
|
69
|
+
return { model: configured };
|
|
70
|
+
}
|
|
71
|
+
for (const candidate of fallbackCandidates(cfg)) {
|
|
72
|
+
let ok = false;
|
|
73
|
+
try { ok = await check(candidate, cfg); } catch { ok = false; }
|
|
74
|
+
if (!ok) continue;
|
|
75
|
+
let provider = "its provider";
|
|
76
|
+
try { provider = (await describeModel(configured, cfg)).provider; } catch { /* keep the generic wording */ }
|
|
77
|
+
return {
|
|
78
|
+
model: candidate,
|
|
79
|
+
fellBackFrom: configured,
|
|
80
|
+
notice: `[model] default '${configured}' has no usable ${provider} login; this session starts on authenticated '${candidate}'. Run 'jeo auth login ${provider}' to restore it, or /model to pick another.`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return { model: configured };
|
|
84
|
+
}
|
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
|
|
@@ -188,8 +188,6 @@ export async function runSessionSlash(input: string, ctx: SessionSlashCtx): Prom
|
|
|
188
188
|
history.length = 1;
|
|
189
189
|
for (const m of messages) history.push(m);
|
|
190
190
|
sessionId = rid;
|
|
191
|
-
// Restore the model this session was last using (per-session model).
|
|
192
|
-
if (header.model) { sessionModel = header.model; sessionModelChanged = true; }
|
|
193
191
|
// Restore the unsent input-box draft left over at last save, if any.
|
|
194
192
|
if (header.draft) draft = header.draft;
|
|
195
193
|
|
package/src/commands/launch.ts
CHANGED
|
@@ -66,6 +66,7 @@ import type { SandboxConfig, SandboxSupervisor } from "../agent/sandbox/types";
|
|
|
66
66
|
import { runAgySessionTurn } from "./launch/agy-turn";
|
|
67
67
|
import { listProviderModels } from "../ai/model-discovery";
|
|
68
68
|
import { rememberModelPatch, recentModelsForDisplay } from "../agent/model-recency";
|
|
69
|
+
import { resolveStartupDefaultModel } from "../agent/session-model";
|
|
69
70
|
import { describeModel, describeAllProviders, describeProvider, resolveProvider, thinkingMaxTokens, resolveMaxOutputTokens, thinkingToReasoningEffort, discoverModels, flattenModels, resolveSelection, catalogMetadata, catalogByProvider, liveProviderCatalogModels, resolveRoleModel, CODEX_MODELS, qualifyModelId, modelServableWithConfig, isLocalProviderReachable } from "../ai";
|
|
70
71
|
import { rehydrateLiveModels, writeModelCache } from "../ai/model-cache";
|
|
71
72
|
import type { ProviderModelsResult, PickEntry, ProviderName, ModelRole, ThinkLevel } from "../ai";
|
|
@@ -544,7 +545,6 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
|
|
|
544
545
|
}
|
|
545
546
|
} catch {}
|
|
546
547
|
const cfg = await readGlobalConfig();
|
|
547
|
-
const defaultModel = cfg.defaultModel;
|
|
548
548
|
let initialSessionModel =
|
|
549
549
|
flags.model ??
|
|
550
550
|
(flags.modelRole ? resolveRoleModel(flags.modelRole, cfg) : flags.provider ? providerDefaultModel(flags.provider) : undefined);
|
|
@@ -564,6 +564,11 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
|
|
|
564
564
|
return;
|
|
565
565
|
}
|
|
566
566
|
}
|
|
567
|
+
// A session always opens on the configured default model, or, when the stored login cannot
|
|
568
|
+
// serve it, on the first authenticated model (session-local; the saved default is untouched).
|
|
569
|
+
// An explicit --model/--model-role/--provider pin takes precedence and skips this.
|
|
570
|
+
const startupModel = initialSessionModel === undefined ? await resolveStartupDefaultModel(cfg) : { model: cfg.defaultModel, notice: undefined };
|
|
571
|
+
const defaultModel = startupModel.model;
|
|
567
572
|
|
|
568
573
|
if (flags.tmux) {
|
|
569
574
|
if (!process.env.TMUX && jeoEnv("TMUX_LAUNCHED") !== "1") {
|
|
@@ -987,7 +992,6 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
|
|
|
987
992
|
const { header, messages } = await loadSession(id, cwd);
|
|
988
993
|
for (const m of messages) history.push(m);
|
|
989
994
|
sessionId = id;
|
|
990
|
-
if (!initialSessionModel && header.model) sessionModel = header.model;
|
|
991
995
|
const modelNote = sessionModel ? ` · model ${sessionModel}` : "";
|
|
992
996
|
console.log(`Resumed session ${id} (${messages.length} messages).${modelNote}`);
|
|
993
997
|
// Reproduce the prior screen exactly (gjc/gajae-code parity): a bare
|
|
@@ -1049,9 +1053,6 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
|
|
|
1049
1053
|
const { header, messages } = await loadSession(id, cwd);
|
|
1050
1054
|
for (const m of messages) history.push(m);
|
|
1051
1055
|
sessionId = id;
|
|
1052
|
-
// Restore the model this session was last using unless the CLI explicitly
|
|
1053
|
-
// pinned one (flags.model/role/provider → initialSessionModel wins).
|
|
1054
|
-
if (!initialSessionModel && header.model) sessionModel = header.model;
|
|
1055
1056
|
// Restore the unsent input-box draft (gajae-code parity) — see
|
|
1056
1057
|
// startupDraft's own doc comment for why this path can't feed
|
|
1057
1058
|
// queuedPromptInput.partial directly yet.
|
|
@@ -1967,7 +1968,7 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
|
|
|
1967
1968
|
if ((pendingMutationVerification || evidence.sawMutation) && !(evidence.sawVerification && !evidence.verificationStale)) {
|
|
1968
1969
|
return (
|
|
1969
1970
|
`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.`
|
|
1971
|
+
`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
1972
|
);
|
|
1972
1973
|
}
|
|
1973
1974
|
|
|
@@ -2497,6 +2498,7 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
|
|
|
2497
2498
|
};
|
|
2498
2499
|
|
|
2499
2500
|
if (isOneShot) {
|
|
2501
|
+
if (startupModel.notice) console.error(startupModel.notice);
|
|
2500
2502
|
// A one-shot run has ~17 exit points below. The session notify endpoint (a
|
|
2501
2503
|
// listening socket + a discovery file the Telegram daemon polls) is created for
|
|
2502
2504
|
// the WHOLE launch lifetime up at `startSessionNotifyEndpoint`, but its teardown
|
|
@@ -2843,6 +2845,7 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
|
|
|
2843
2845
|
if (sweepable) await playWelcomeSweep(welcomeData, { cycles: sweepCycles });
|
|
2844
2846
|
else console.log(renderWelcome(welcomeData).join("\n"));
|
|
2845
2847
|
}
|
|
2848
|
+
if (startupModel.notice) console.log(chalk.yellow(startupModel.notice));
|
|
2846
2849
|
|
|
2847
2850
|
|
|
2848
2851
|
|
|
@@ -3177,7 +3180,14 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
|
|
|
3177
3180
|
queueBusySnapshot = () => ({
|
|
3178
3181
|
text: queuedPromptInput.partial,
|
|
3179
3182
|
});
|
|
3180
|
-
queueBusyClear = () => {
|
|
3183
|
+
queueBusyClear = () => {
|
|
3184
|
+
queuedPromptInput.partial = "";
|
|
3185
|
+
const r = rl as unknown as { line?: string; cursor?: number };
|
|
3186
|
+
if (typeof r.line === "string") {
|
|
3187
|
+
r.line = "";
|
|
3188
|
+
r.cursor = 0;
|
|
3189
|
+
}
|
|
3190
|
+
};
|
|
3181
3191
|
queueBusyCommand = (line: string) => {
|
|
3182
3192
|
// NO_MIDTURN_DISPATCH=1 keeps the legacy prefill path (tee up, press Enter); the
|
|
3183
3193
|
// 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
|
+
}
|