jeo-code 0.6.40 → 0.7.0
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 +34 -0
- package/README.ja.md +31 -38
- package/README.ko.md +31 -38
- package/README.md +32 -38
- package/README.zh.md +31 -38
- package/package.json +4 -2
- package/src/agent/compaction.ts +2 -1
- package/src/agent/computer-supervisor.ts +59 -0
- package/src/agent/config-schema.ts +6 -0
- package/src/agent/engine.ts +9 -4
- package/src/agent/json.ts +75 -8
- package/src/agent/loop-guards.ts +16 -5
- package/src/agent/memory-okf.ts +2 -0
- package/src/agent/memory.ts +25 -8
- package/src/agent/state.ts +39 -7
- package/src/agent/task-tool.ts +12 -3
- package/src/agent/tool-schemas.ts +19 -0
- package/src/ai/model-catalog.ts +4 -0
- package/src/ai/model-manager.ts +23 -5
- package/src/ai/providers/anthropic.ts +10 -6
- package/src/ai/providers/openai-compatible-catalog.ts +2 -0
- package/src/ai/types.ts +2 -1
- package/src/auth/index.ts +1 -1
- package/src/auth/refresh.ts +47 -2
- package/src/auth/storage.ts +11 -5
- package/src/cli/runner.ts +9 -0
- package/src/cli.ts +16 -0
- package/src/commands/approve.ts +17 -1
- package/src/commands/computer.ts +334 -0
- package/src/commands/launch/input.ts +19 -6
- package/src/commands/launch.ts +56 -8
- package/src/commands/ralplan.ts +4 -0
- package/src/commands/team.ts +13 -0
- package/src/index.ts +1 -0
- package/src/prompts/agents/executor.md +6 -4
- package/src/prompts/skills/ralplan/SKILL.md +5 -0
- package/src/prompts/skills/team/SKILL.md +5 -0
- package/src/tui/components/slash.ts +1 -0
- package/src/util/terminal-restore.ts +41 -0
package/src/agent/json.ts
CHANGED
|
@@ -51,27 +51,94 @@ export function tryExtractJsonObject<T = unknown>(
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
/**
|
|
54
|
-
* Parse `s` as JSON, tolerating
|
|
55
|
-
*
|
|
56
|
-
*
|
|
54
|
+
* Parse `s` as JSON, tolerating common model slips that produce *almost*-valid
|
|
55
|
+
* JSON. Returns `undefined` (not null — a bare `null`/`false` is a legal JSON
|
|
56
|
+
* value) when nothing parses. Repairs attempted, in order:
|
|
57
|
+
* - a trailing comma right before a closing `}` or `]`;
|
|
58
|
+
* - raw control characters (literal newlines/tabs/etc.) inside a string value,
|
|
59
|
+
* which JSON forbids — a frequent slip when a tool argument carries multi-line
|
|
60
|
+
* code (e.g. an `editBlock`/`content` payload the model failed to `\n`-escape).
|
|
57
61
|
*/
|
|
58
62
|
function tryParse<T>(s: string): T | undefined {
|
|
59
63
|
try {
|
|
60
64
|
return JSON.parse(s) as T;
|
|
61
65
|
} catch {
|
|
62
|
-
/* fall through to
|
|
66
|
+
/* fall through to layered repairs */
|
|
63
67
|
}
|
|
64
|
-
|
|
65
|
-
|
|
68
|
+
// Each repair targets one well-scoped, common slip; try the cheap single
|
|
69
|
+
// repairs first, then the combination, so valid JSON is never altered.
|
|
70
|
+
const candidates: string[] = [];
|
|
71
|
+
const noTrailing = stripTrailingCommas(s);
|
|
72
|
+
if (noTrailing !== s) candidates.push(noTrailing);
|
|
73
|
+
const escaped = escapeJsonControlChars(s);
|
|
74
|
+
if (escaped !== s) {
|
|
75
|
+
candidates.push(escaped);
|
|
76
|
+
const escapedNoTrailing = stripTrailingCommas(escaped);
|
|
77
|
+
if (escapedNoTrailing !== escaped) candidates.push(escapedNoTrailing);
|
|
78
|
+
}
|
|
79
|
+
for (const candidate of candidates) {
|
|
66
80
|
try {
|
|
67
|
-
return JSON.parse(
|
|
81
|
+
return JSON.parse(candidate) as T;
|
|
68
82
|
} catch {
|
|
69
|
-
/*
|
|
83
|
+
/* try the next repair */
|
|
70
84
|
}
|
|
71
85
|
}
|
|
72
86
|
return undefined;
|
|
73
87
|
}
|
|
74
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Escape raw control characters (codepoint < 0x20) that appear *inside* a JSON
|
|
91
|
+
* string literal, where the JSON grammar requires them to be escaped. Models
|
|
92
|
+
* routinely emit literal newlines inside a multi-line tool argument (code in an
|
|
93
|
+
* `editBlock`/`content` field), which makes `JSON.parse` reject the whole object.
|
|
94
|
+
* String/escape state is tracked so control bytes outside strings (insignificant
|
|
95
|
+
* whitespace between tokens) are left untouched.
|
|
96
|
+
*/
|
|
97
|
+
function escapeJsonControlChars(s: string): string {
|
|
98
|
+
let out = "";
|
|
99
|
+
let inString = false;
|
|
100
|
+
let escaped = false;
|
|
101
|
+
for (let i = 0; i < s.length; i++) {
|
|
102
|
+
const ch = s[i]!;
|
|
103
|
+
if (inString) {
|
|
104
|
+
if (escaped) {
|
|
105
|
+
out += ch;
|
|
106
|
+
escaped = false;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (ch === "\\") {
|
|
110
|
+
out += ch;
|
|
111
|
+
escaped = true;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (ch === '"') {
|
|
115
|
+
out += ch;
|
|
116
|
+
inString = false;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const code = ch.charCodeAt(0);
|
|
120
|
+
if (code < 0x20) {
|
|
121
|
+
switch (ch) {
|
|
122
|
+
case "\n": out += "\\n"; break;
|
|
123
|
+
case "\r": out += "\\r"; break;
|
|
124
|
+
case "\t": out += "\\t"; break;
|
|
125
|
+
case "\b": out += "\\b"; break;
|
|
126
|
+
case "\f": out += "\\f"; break;
|
|
127
|
+
default: out += "\\u" + code.toString(16).padStart(4, "0"); break;
|
|
128
|
+
}
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
out += ch;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (ch === '"') {
|
|
135
|
+
inString = true;
|
|
136
|
+
}
|
|
137
|
+
out += ch;
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
75
142
|
/**
|
|
76
143
|
* Remove a comma that directly precedes a closing `}` or `]` (ignoring
|
|
77
144
|
* whitespace), but never a comma inside a string literal. String/escape state is
|
package/src/agent/loop-guards.ts
CHANGED
|
@@ -68,19 +68,30 @@ export function isVerificationSignal(cmd: string, output = ""): boolean {
|
|
|
68
68
|
|
|
69
69
|
/**
|
|
70
70
|
* Result-aware repeat nudge: tells the model WHY repeating the call won't help and what
|
|
71
|
-
* to try instead, tailored to the repeated tool and its last actual result.
|
|
71
|
+
* to try instead, tailored to the repeated tool and its last actual result. When the
|
|
72
|
+
* previous attempt FAILED, prepend a reflection directive so the model extracts the
|
|
73
|
+
* lesson and changes tool/arguments instead of retrying the same failing call unchanged.
|
|
72
74
|
*/
|
|
73
75
|
export function repeatHint(tool: string, prev?: { success: boolean; output: string }): string {
|
|
74
76
|
const out = prev?.output ?? "";
|
|
75
77
|
const empty = !prev || !prev.success || out.trim() === "" || /no match|0 match|no result|not found|no file/i.test(out);
|
|
78
|
+
const failed = prev !== undefined && !prev.success;
|
|
79
|
+
const reflect = failed
|
|
80
|
+
? "The previous attempt FAILED — don't retry it unchanged; state what its result tells you, then change the tool or its arguments. "
|
|
81
|
+
: "";
|
|
82
|
+
let base: string;
|
|
76
83
|
if (tool === "search" || tool === "find" || tool === "ls") {
|
|
77
|
-
|
|
84
|
+
base = empty
|
|
78
85
|
? `That '${tool}' returned nothing useful and will again — BROADEN it (a looser pattern, a parent directory, or a different tool such as ${tool === "search" ? "find" : "search"}), or call done if this lookup isn't needed.`
|
|
79
86
|
: `That '${tool}' already returned results — open one of the hits with read, or move on; re-running it changes nothing.`;
|
|
87
|
+
} else if (tool === "read") {
|
|
88
|
+
base = `You already read that and its content is unchanged — use what you read, or read a DIFFERENT file.`;
|
|
89
|
+
} else if (tool === "bash") {
|
|
90
|
+
base = `That command already ran with the same output — change the command, or call done.`;
|
|
91
|
+
} else {
|
|
92
|
+
base = `That call's result is unchanged — take a different action, or call done.`;
|
|
80
93
|
}
|
|
81
|
-
|
|
82
|
-
if (tool === "bash") return `That command already ran with the same output — change the command, or call done.`;
|
|
83
|
-
return `That call's result is unchanged — take a different action, or call done.`;
|
|
94
|
+
return reflect + base;
|
|
84
95
|
}
|
|
85
96
|
|
|
86
97
|
/** Inputs for the done-verification gate (jeo's descendant of gjc's ultragoal-guard). */
|
package/src/agent/memory-okf.ts
CHANGED
package/src/agent/memory.ts
CHANGED
|
@@ -35,7 +35,9 @@ const TYPE_LAYOUT = [
|
|
|
35
35
|
{ type: "Command", dir: "commands", header: "Commands" },
|
|
36
36
|
{ type: "Gotcha", dir: "gotchas", header: "Gotchas" },
|
|
37
37
|
{ type: "UserPreference", dir: "preferences", header: "User Preferences" },
|
|
38
|
+
{ type: "FailedAttempt", dir: "failed", header: "Failed Attempts" },
|
|
38
39
|
] as const;
|
|
40
|
+
|
|
39
41
|
const DIR_BY_TYPE: Record<string, string> = Object.fromEntries(TYPE_LAYOUT.map(t => [t.type, t.dir]));
|
|
40
42
|
|
|
41
43
|
export function memoryFilePath(cwd: string): string {
|
|
@@ -64,7 +66,9 @@ function headingToType(heading: string): string {
|
|
|
64
66
|
const h = heading.toLowerCase();
|
|
65
67
|
if (/\bcommand/.test(h)) return "Command";
|
|
66
68
|
if (/gotcha|pitfall|caveat/.test(h)) return "Gotcha";
|
|
69
|
+
if (/failed|dead.?end|didn't work|doesn't work|avoid/.test(h)) return "FailedAttempt";
|
|
67
70
|
if (/pref/.test(h)) return "UserPreference";
|
|
71
|
+
|
|
68
72
|
if (/repo|fact/.test(h)) return "RepoFact";
|
|
69
73
|
return "RepoFact";
|
|
70
74
|
}
|
|
@@ -165,8 +169,10 @@ export async function migrateLegacyMemory(cwd: string): Promise<MigrationResult>
|
|
|
165
169
|
await fs.rename(tmpPath, fullPath);
|
|
166
170
|
written.push({ title: c.title, type: c.type });
|
|
167
171
|
}
|
|
168
|
-
await
|
|
169
|
-
|
|
172
|
+
await withIndexLock(bundleDir, async () => {
|
|
173
|
+
await rebuildIndex(bundleDir);
|
|
174
|
+
await updateLog(bundleDir, written);
|
|
175
|
+
});
|
|
170
176
|
// Preserve the legacy doc as a rollback backup, off the active read path.
|
|
171
177
|
const backupPath = `${memoryFilePath(cwd)}.bak`;
|
|
172
178
|
await fs.rename(memoryFilePath(cwd), backupPath).catch(() => {});
|
|
@@ -449,6 +455,13 @@ async function findMarkdownFiles(dir: string): Promise<string[]> {
|
|
|
449
455
|
return files;
|
|
450
456
|
}
|
|
451
457
|
|
|
458
|
+
/** Serialises concurrent same-process index/log writes; cross-process last-write-wins is fine (cosmetic files). */
|
|
459
|
+
const _indexLocks = new Map<string, Promise<void>>();
|
|
460
|
+
function withIndexLock(bundleDir: string, fn: () => Promise<void>): Promise<void> {
|
|
461
|
+
const next = (_indexLocks.get(bundleDir) ?? Promise.resolve()).then(fn, fn);
|
|
462
|
+
_indexLocks.set(bundleDir, next.catch(() => {}));
|
|
463
|
+
return next;
|
|
464
|
+
}
|
|
452
465
|
async function rebuildIndex(bundleDir: string): Promise<void> {
|
|
453
466
|
const concepts = await loadConceptsFromBundle(bundleDir);
|
|
454
467
|
|
|
@@ -587,7 +600,8 @@ export async function distillSessionMemory(
|
|
|
587
600
|
"You maintain a compact project memory bundle for a coding agent. " +
|
|
588
601
|
"Extract durable learnings from the session transcript and merge them with the existing concepts. " +
|
|
589
602
|
"Keep ONLY what helps future sessions in THIS repository: repo facts (structure, conventions, key files), " +
|
|
590
|
-
"commands that work (build/test/run), gotchas (failures and their fixes),
|
|
603
|
+
"commands that work (build/test/run), gotchas (failures and their fixes), user preferences, " +
|
|
604
|
+
"and approaches that were tried and FAILED (with the cause, so a future session does not repeat the dead end). " +
|
|
591
605
|
"Drop session-specific noise (one-off tasks, transient errors, conversational detail). " +
|
|
592
606
|
"CRITICAL RULES for concept granularity:\n" +
|
|
593
607
|
" 1. Create ONE concept per distinct fact/command/gotcha/preference — never combine multiple learnings into a single concept.\n" +
|
|
@@ -595,12 +609,13 @@ export async function distillSessionMemory(
|
|
|
595
609
|
" 3. Each Command concept covers exactly one command or workflow (e.g., 'bun test', NOT 'All bun commands').\n" +
|
|
596
610
|
" 4. Each Gotcha covers exactly one failure mode and its fix.\n" +
|
|
597
611
|
" 5. Each UserPreference covers exactly one observable preference.\n" +
|
|
612
|
+
" 5b. Each FailedAttempt covers exactly one approach that did not work plus the cause/symptom — phrase it so the next session avoids that dead end.\n" +
|
|
598
613
|
" 6. RepoFact concepts describe structure/conventions — split by area (e.g., 'CLI entrypoint', 'Test setup').\n" +
|
|
599
614
|
" 7. If an existing concept is a mega-concept (lists many things), REPLACE it with properly split granular concepts.\n" +
|
|
600
615
|
" 8. Keep each concept body under 400 chars.\n\n" +
|
|
601
616
|
"You must output a JSON object with a single key \"concepts\", which is an array of concept objects. " +
|
|
602
617
|
"Each concept object must have the following fields:\n" +
|
|
603
|
-
" - \"type\": one of \"RepoFact\", \"Command\", \"Gotcha\", \"UserPreference\"\n" +
|
|
618
|
+
" - \"type\": one of \"RepoFact\", \"Command\", \"Gotcha\", \"UserPreference\", \"FailedAttempt\"\n" +
|
|
604
619
|
" - \"title\": a short, descriptive title (e.g., \"Bun test runner\")\n" +
|
|
605
620
|
" - \"description\": a brief one-line summary of the concept\n" +
|
|
606
621
|
" - \"body\": the detailed markdown content/body of the concept (≤400 chars)\n" +
|
|
@@ -707,10 +722,12 @@ export async function distillSessionMemory(
|
|
|
707
722
|
}
|
|
708
723
|
}
|
|
709
724
|
|
|
710
|
-
await
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
725
|
+
await withIndexLock(bundleDir, async () => {
|
|
726
|
+
await rebuildIndex(bundleDir);
|
|
727
|
+
if (updatedConcepts.length > 0) {
|
|
728
|
+
await updateLog(bundleDir, updatedConcepts);
|
|
729
|
+
}
|
|
730
|
+
});
|
|
714
731
|
// Concepts now own the durable memory and the legacy blob (if any) was
|
|
715
732
|
// folded into the merge above. Archive a lingering MEMORY.md off the active
|
|
716
733
|
// read path so it can never break OKF conformance or shadow the bundle.
|
package/src/agent/state.ts
CHANGED
|
@@ -95,7 +95,11 @@ export interface Config {
|
|
|
95
95
|
* `defaultModel`. Env `JEO_SMOL_MODEL`/`JEO_SLOW_MODEL`/`JEO_PLAN_MODEL` fill gaps.
|
|
96
96
|
*/
|
|
97
97
|
roles?: { smol?: string; slow?: string; plan?: string };
|
|
98
|
+
gitAutoCommit?: boolean;
|
|
98
99
|
hooks?: HookConfig;
|
|
100
|
+
computer?: {
|
|
101
|
+
enabled?: boolean;
|
|
102
|
+
};
|
|
99
103
|
}
|
|
100
104
|
|
|
101
105
|
export interface WorkflowTopologyComponent {
|
|
@@ -140,6 +144,10 @@ export interface WorkflowState {
|
|
|
140
144
|
consensus?: string;
|
|
141
145
|
/** ralplan: the critic's justification (bounded excerpt) for auditability. */
|
|
142
146
|
consensus_detail?: string;
|
|
147
|
+
/** ralplan: hash of the exact plan content the consensus critic gated (round-13).
|
|
148
|
+
* `jeo approve` recomputes the on-disk plan's hash and refuses if it differs, so a
|
|
149
|
+
* schema-valid edit made AFTER the [OKAY] verdict cannot be silently approved. */
|
|
150
|
+
consensus_hash?: string;
|
|
143
151
|
approved?: boolean;
|
|
144
152
|
/** ultragoal terminal outcome. */
|
|
145
153
|
status?: string;
|
|
@@ -476,7 +484,20 @@ export async function clearWorkflowState(
|
|
|
476
484
|
* team-state.json last-writer-wins — tasks executed twice, completions lost.
|
|
477
485
|
* O_EXCL lockfile carrying the holder's pid; a DEAD holder's stale lock is taken
|
|
478
486
|
* over once, a LIVE holder refuses with an actionable error. Returns a release fn.
|
|
487
|
+
*
|
|
488
|
+
* Round-13 hardening: `process.kill(pid, 0)` alone cannot detect PID REUSE — a
|
|
489
|
+
* dead `jeo` pid recycled by an unrelated live process would look "alive" forever
|
|
490
|
+
* and starve the lock. The holder now HEARTBEATS the lock's `at` timestamp on an
|
|
491
|
+
* unref'd interval; a contender treats a lock as stale when the pid is dead OR the
|
|
492
|
+
* timestamp is older than the TTL (a live `jeo` run keeps it fresh, a dead/hung one
|
|
493
|
+
* or a reused pid lets it expire). Override the TTL with JEO_RUN_LOCK_TTL_MS.
|
|
479
494
|
*/
|
|
495
|
+
const RUN_LOCK_TTL_MS = (() => {
|
|
496
|
+
const raw = Number(jeoEnv("RUN_LOCK_TTL_MS"));
|
|
497
|
+
return Number.isFinite(raw) && raw > 0 ? raw : 60_000;
|
|
498
|
+
})();
|
|
499
|
+
const RUN_LOCK_HEARTBEAT_MS = Math.max(1_000, Math.floor(RUN_LOCK_TTL_MS / 3));
|
|
500
|
+
|
|
480
501
|
export async function acquireWorkflowRunLock(
|
|
481
502
|
skill: "team" | "ultragoal",
|
|
482
503
|
cwd: string = process.cwd(),
|
|
@@ -484,19 +505,27 @@ export async function acquireWorkflowRunLock(
|
|
|
484
505
|
const stateDir = path.join(getLocalJeoDir(cwd), "state");
|
|
485
506
|
await fs.mkdir(stateDir, { recursive: true });
|
|
486
507
|
const lockPath = path.join(stateDir, `${skill}.lock`);
|
|
487
|
-
|
|
508
|
+
// Single source of the lock payload: exclusive-create on acquire, plain rewrite on heartbeat.
|
|
509
|
+
const stamp = (flag?: "wx") =>
|
|
510
|
+
fs.writeFile(lockPath, JSON.stringify({ pid: process.pid, at: Date.now() }), flag ? { flag } : {});
|
|
488
511
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
489
512
|
try {
|
|
490
|
-
await
|
|
513
|
+
await stamp("wx");
|
|
514
|
+
// Heartbeat: refresh `at` so a live run never looks stale; unref'd so it
|
|
515
|
+
// never keeps the process alive on its own.
|
|
516
|
+
const hb = setInterval(() => { stamp().catch(() => {}); }, RUN_LOCK_HEARTBEAT_MS);
|
|
517
|
+
hb.unref?.();
|
|
491
518
|
return async () => {
|
|
519
|
+
clearInterval(hb);
|
|
492
520
|
await fs.unlink(lockPath).catch(() => {});
|
|
493
521
|
};
|
|
522
|
+
|
|
494
523
|
} catch {
|
|
495
|
-
let holder: { pid?: number } = {};
|
|
524
|
+
let holder: { pid?: number; at?: number } = {};
|
|
496
525
|
try {
|
|
497
|
-
holder = JSON.parse(await fs.readFile(lockPath, "utf-8")) as { pid?: number };
|
|
526
|
+
holder = JSON.parse(await fs.readFile(lockPath, "utf-8")) as { pid?: number; at?: number };
|
|
498
527
|
} catch { /* unreadable/torn lock → treat as stale */ }
|
|
499
|
-
const
|
|
528
|
+
const pidAlive = typeof holder.pid === "number" && holder.pid > 0 && (() => {
|
|
500
529
|
try {
|
|
501
530
|
process.kill(holder.pid!, 0);
|
|
502
531
|
return true;
|
|
@@ -504,12 +533,15 @@ export async function acquireWorkflowRunLock(
|
|
|
504
533
|
return false;
|
|
505
534
|
}
|
|
506
535
|
})();
|
|
507
|
-
|
|
536
|
+
const fresh = typeof holder.at === "number" && (Date.now() - holder.at) < RUN_LOCK_TTL_MS;
|
|
537
|
+
if (pidAlive && fresh) {
|
|
508
538
|
throw new Error(
|
|
509
539
|
`another 'jeo ${skill}' run (pid ${holder.pid}) holds ${lockPath} — wait for it to finish, or delete the lock file if that process is gone.`,
|
|
510
540
|
);
|
|
511
541
|
}
|
|
512
|
-
|
|
542
|
+
// stale: dead pid, unreadable lock, or a stalled/reused-pid holder whose
|
|
543
|
+
// heartbeat lapsed past the TTL → take over once.
|
|
544
|
+
await fs.unlink(lockPath).catch(() => {});
|
|
513
545
|
}
|
|
514
546
|
}
|
|
515
547
|
throw new Error(`could not acquire ${lockPath} even after stale-lock takeover.`);
|
package/src/agent/task-tool.ts
CHANGED
|
@@ -13,6 +13,8 @@ import { runAgentLoop, type ToolHandler } from "./engine";
|
|
|
13
13
|
import type { ToolResult } from "./tools";
|
|
14
14
|
import type { Message } from "./loop";
|
|
15
15
|
import { loadProjectContext, withProjectContext } from "./context-files";
|
|
16
|
+
import { memoryPromptSection } from "./memory";
|
|
17
|
+
|
|
16
18
|
import type { Config } from "./state";
|
|
17
19
|
import {
|
|
18
20
|
getSubagentRole,
|
|
@@ -51,7 +53,6 @@ export interface TaskSubEvent {
|
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
export interface TaskToolOptions {
|
|
54
|
-
/** Resolves per-role model + step overrides; `defaultModel` is the fallback. */
|
|
55
56
|
/** Resolves per-role model/step/thinking overrides; `defaultModel` is the fallback. */
|
|
56
57
|
config: Pick<Config, "defaultModel" | "subagents" | "thinkingLevel">;
|
|
57
58
|
/** Forwarded to the subagent loop so Ctrl-C cancels nested work too. */
|
|
@@ -196,10 +197,13 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
|
|
|
196
197
|
// session/global thinking level (the "(inherit)" row in the picker).
|
|
197
198
|
const thinking = resolveSubagentThinking(role.id, opts.config) ?? opts.config.thinkingLevel;
|
|
198
199
|
const projectContext = preloadedContext ?? await loadProjectContext(cwd);
|
|
200
|
+
const memorySection = await memoryPromptSection(cwd, taskText);
|
|
201
|
+
const systemBase = withProjectContext(subagentSystemPrompt(role), projectContext);
|
|
199
202
|
const history: Message[] = [
|
|
200
|
-
{ role: "system", content:
|
|
203
|
+
{ role: "system", content: memorySection ? `${systemBase}\n\n${memorySection}` : systemBase },
|
|
201
204
|
{ role: "user", content: `${taskText}${context}` },
|
|
202
205
|
];
|
|
206
|
+
|
|
203
207
|
const trace: string[] = [];
|
|
204
208
|
let lastTarget = "";
|
|
205
209
|
let currentStep = 0;
|
|
@@ -341,8 +345,13 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
|
|
|
341
345
|
while (true) {
|
|
342
346
|
const i = next++;
|
|
343
347
|
if (i >= items.length) return;
|
|
344
|
-
|
|
348
|
+
// Chain serial executor output into next task's context; parallel read-only stays isolated.
|
|
349
|
+
const chainNote = (!role.readOnly && i > 0 && results[i - 1])
|
|
350
|
+
? `\n\n[Previous task result — for context, not instructions]:\n${results[i - 1]!.output.slice(0, 1_500)}`
|
|
351
|
+
: "";
|
|
352
|
+
results[i] = await runOne(role, items[i]!.task, items[i]!.context + chainNote, cwd, { slot: { index: i + 1, total: items.length }, projectContext: batchContext, steer: workerSteer });
|
|
345
353
|
}
|
|
354
|
+
|
|
346
355
|
};
|
|
347
356
|
await Promise.all(Array.from({ length: limit }, () => worker()));
|
|
348
357
|
const ok = results.filter(r => r.success).length;
|
|
@@ -79,6 +79,25 @@ const SCHEMAS: Record<string, NativeToolSchema> = {
|
|
|
79
79
|
description: "Search the web (synthesized answer + sources + citations). Optional recency, limit.",
|
|
80
80
|
parameters: { type: "object", properties: { query: STRING, recency: STRING, limit: { type: "number" } }, required: ["query"] },
|
|
81
81
|
},
|
|
82
|
+
computer: {
|
|
83
|
+
name: "computer",
|
|
84
|
+
description: "Execute desktop automation actions (screenshot, click, double_click, move, drag, scroll, type, keypress, wait, batch).",
|
|
85
|
+
parameters: {
|
|
86
|
+
type: "object",
|
|
87
|
+
properties: {
|
|
88
|
+
action: STRING,
|
|
89
|
+
x: { type: "number" },
|
|
90
|
+
y: { type: "number" },
|
|
91
|
+
text: STRING,
|
|
92
|
+
key: STRING,
|
|
93
|
+
deltaX: { type: "number" },
|
|
94
|
+
deltaY: { type: "number" },
|
|
95
|
+
duration: { type: "number" },
|
|
96
|
+
actions: { type: "array", items: { type: "object" } },
|
|
97
|
+
},
|
|
98
|
+
required: ["action"],
|
|
99
|
+
},
|
|
100
|
+
},
|
|
82
101
|
done: {
|
|
83
102
|
name: "done",
|
|
84
103
|
description: "Call when the task is fully implemented AND verified. The reason is shown to the user as your message.",
|
package/src/ai/model-catalog.ts
CHANGED
|
@@ -108,6 +108,10 @@ export const MODEL_CATALOG: readonly CatalogModel[] = [
|
|
|
108
108
|
images: !id.includes("gpt-oss"),
|
|
109
109
|
company: id.includes("claude") ? "Anthropic via Antigravity" : id.includes("gpt") ? "OpenAI via Antigravity" : "Google Antigravity",
|
|
110
110
|
})),
|
|
111
|
+
// Tencent
|
|
112
|
+
{ canonical: "deepseek-v4-pro", provider: "tencent", providerModel: "deepseek-v4-pro", contextTokens: 128_000, maxOutputTokens: 8192, thinking: FULL, images: false, company: "Tencent" },
|
|
113
|
+
{ canonical: "deepseek-v4-flash", provider: "tencent", providerModel: "deepseek-v4-flash", contextTokens: 128_000, maxOutputTokens: 8192, thinking: FULL, images: false, company: "Tencent" },
|
|
114
|
+
{ canonical: "minimax-m3", provider: "tencent", providerModel: "minimax-m3", contextTokens: 128_000, maxOutputTokens: 8192, thinking: FULL, images: false, company: "Tencent" },
|
|
111
115
|
// Ollama (local)
|
|
112
116
|
{ canonical: "qwen2.5", provider: "ollama", providerModel: "ollama/qwen2.5:0.5b", contextTokens: 32_768, maxOutputTokens: 8_192, thinking: [], images: false },
|
|
113
117
|
];
|
package/src/ai/model-manager.ts
CHANGED
|
@@ -383,9 +383,18 @@ async function resolveCall(options: Partial<CallOptions>, kind: "request" | "str
|
|
|
383
383
|
return { adapter, callOptions, credential: credentialForCall(provider, effective, config, baseUrl), retry };
|
|
384
384
|
}
|
|
385
385
|
|
|
386
|
-
/** Hard cap for a single
|
|
387
|
-
* blackholed/unreachable provider must not hang the agent or `jeo team`).
|
|
388
|
-
|
|
386
|
+
/** Hard wall-clock cap for a single NON-streaming provider request (service-readiness: a
|
|
387
|
+
* blackholed/unreachable provider must not hang the agent or `jeo team`). Unlike the
|
|
388
|
+
* streaming idle watchdog, this path collects an opaque buffered body (`response.json()`,
|
|
389
|
+
* or an internally-streamed collect in codexResponsesCall / antigravity.call) and exposes
|
|
390
|
+
* no per-chunk signal — so a wall clock is the only lever and a wire heartbeat cannot help.
|
|
391
|
+
* Raised to 300s to match STREAM_IDLE_TIMEOUT_MS: non-interactive turns (callLlm WITHOUT
|
|
392
|
+
* onToken — compaction, ralplan, deep-interview, memory distill, goal-verify, and subagent/
|
|
393
|
+
* autopilot engine steps) route here, and a long reasoning completion legitimately exceeds
|
|
394
|
+
* 120s; too-tight, it aborts an alive call, retries re-incur the same slow request, the
|
|
395
|
+
* attempt budget exhausts, and the turn STOPS — the same false-failure the streaming
|
|
396
|
+
* watchdog guards, on the path the wire heartbeat never reaches. */
|
|
397
|
+
const DEFAULT_CALL_TIMEOUT_MS = 300_000;
|
|
389
398
|
|
|
390
399
|
/** Per-chunk idle cap for streaming: a stream that emits NOTHING for this long is
|
|
391
400
|
* aborted, but a healthy long generation (chunks keep arriving) runs unbounded —
|
|
@@ -505,6 +514,15 @@ export function streamIdleMs(env?: Record<string, string | undefined>): number {
|
|
|
505
514
|
return Number.isFinite(n) && n > 0 ? n : STREAM_IDLE_TIMEOUT_MS;
|
|
506
515
|
}
|
|
507
516
|
|
|
517
|
+
/** Hard wall-clock cap (ms) for a NON-streaming provider call, falling back to the
|
|
518
|
+
* built-in default. Mirrors streamIdleMs so a slow non-interactive turn (callLlm without
|
|
519
|
+
* onToken) can relax the cap via JEO_CALL_TIMEOUT_MS without a code change. */
|
|
520
|
+
export function callTimeoutMs(env?: Record<string, string | undefined>): number {
|
|
521
|
+
const raw = jeoEnv("CALL_TIMEOUT_MS", env);
|
|
522
|
+
const n = raw !== undefined ? parseInt(raw, 10) : NaN;
|
|
523
|
+
return Number.isFinite(n) && n > 0 ? n : DEFAULT_CALL_TIMEOUT_MS;
|
|
524
|
+
}
|
|
525
|
+
|
|
508
526
|
export async function* retryableStream(
|
|
509
527
|
makeIter: () => AsyncIterator<string>,
|
|
510
528
|
retry: RetryOptions,
|
|
@@ -526,7 +544,7 @@ export function createModelManager(): ModelManager {
|
|
|
526
544
|
resolveProvider,
|
|
527
545
|
async call(messages, options = {}) {
|
|
528
546
|
const { adapter, callOptions, credential, retry } = await resolveCall(options);
|
|
529
|
-
return withRetry(() => adapter.call(messages, { ...callOptions, signal: withTimeout(callOptions.signal,
|
|
547
|
+
return withRetry(() => adapter.call(messages, { ...callOptions, signal: withTimeout(callOptions.signal, callTimeoutMs()) }, credential), retry);
|
|
530
548
|
},
|
|
531
549
|
async *stream(messages, options = {}) {
|
|
532
550
|
const { adapter, callOptions, credential, retry } = await resolveCall(options, "stream");
|
|
@@ -572,7 +590,7 @@ export function createModelManager(): ModelManager {
|
|
|
572
590
|
});
|
|
573
591
|
} else {
|
|
574
592
|
// Fallback: providers without streaming yield the full response as one chunk.
|
|
575
|
-
yield await withRetry(() => adapter.call(messages, { ...callOptions, signal: withTimeout(callOptions.signal,
|
|
593
|
+
yield await withRetry(() => adapter.call(messages, { ...callOptions, signal: withTimeout(callOptions.signal, callTimeoutMs()) }, credential), retry);
|
|
576
594
|
}
|
|
577
595
|
},
|
|
578
596
|
};
|
|
@@ -416,7 +416,7 @@ export const anthropicAdapter: ProviderAdapter = {
|
|
|
416
416
|
index?: number;
|
|
417
417
|
content_block?: { type?: string; name?: string; data?: string };
|
|
418
418
|
delta?: { type?: string; text?: string; partial_json?: string; thinking?: string; signature?: string; stop_reason?: string };
|
|
419
|
-
message?: { usage?: AnthropicUsage };
|
|
419
|
+
message?: { usage?: AnthropicUsage; stop_reason?: string };
|
|
420
420
|
usage?: { output_tokens?: number };
|
|
421
421
|
};
|
|
422
422
|
try {
|
|
@@ -452,11 +452,14 @@ export const anthropicAdapter: ProviderAdapter = {
|
|
|
452
452
|
const tb = thinkBlocks.get(evt.index) ?? { text: "" };
|
|
453
453
|
tb.signature = (tb.signature ?? "") + evt.delta.signature;
|
|
454
454
|
thinkBlocks.set(evt.index, tb);
|
|
455
|
-
} else if (evt.type === "message_start" && evt.message
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
455
|
+
} else if (evt.type === "message_start" && evt.message) {
|
|
456
|
+
if (evt.message.stop_reason) stopReason = evt.message.stop_reason;
|
|
457
|
+
if (evt.message.usage) {
|
|
458
|
+
// Cache only — usage is reported ONCE at message_delta so an accumulating
|
|
459
|
+
// sink can't double-count input (and a pre-first-chunk retry that replays
|
|
460
|
+
// message_start is harmless).
|
|
461
|
+
cachedInput = totalInputTokens(evt.message.usage);
|
|
462
|
+
}
|
|
460
463
|
} else if (evt.type === "message_delta") {
|
|
461
464
|
if (evt.delta?.stop_reason) stopReason = evt.delta.stop_reason;
|
|
462
465
|
if (evt.usage) options.onUsage?.({ inputTokens: cachedInput, outputTokens: evt.usage.output_tokens });
|
|
@@ -465,6 +468,7 @@ export const anthropicAdapter: ProviderAdapter = {
|
|
|
465
468
|
// Emit captured thinking blocks as replay artifacts (signed thought + signature).
|
|
466
469
|
for (const tb of thinkBlocks.values()) {
|
|
467
470
|
if (tb.text || tb.signature) {
|
|
471
|
+
yieldedAny = true;
|
|
468
472
|
options.onReasoningArtifact?.({ provider: "anthropic", model: options.model, text: tb.text || undefined, signature: tb.signature });
|
|
469
473
|
}
|
|
470
474
|
}
|
|
@@ -67,6 +67,8 @@ export const OPENAI_COMPAT_PROVIDERS: readonly OpenAICompatProviderDef[] = [
|
|
|
67
67
|
// Anthropic-Messages-protocol providers (served via makeAnthropicCompatibleAdapter).
|
|
68
68
|
{ name: "zai", label: "z.ai", baseUrl: "https://api.z.ai/api/anthropic", apiKeyEnv: "ZAI_API_KEY", defaultModel: "zai/glm-5.2", protocol: "anthropic" },
|
|
69
69
|
{ name: "minimax", label: "MiniMax", baseUrl: "https://api.minimax.io/anthropic", apiKeyEnv: "MINIMAX_API_KEY", defaultModel: "minimax/minimax-m3", protocol: "anthropic" },
|
|
70
|
+
// Tencent Cloud MaaS (international) serves deepseek-v4 / minimax-m3 over the Anthropic Messages wire format at tokenhub-intl; `${base}/v1/messages` is pinned by the adapter.
|
|
71
|
+
{ name: "tencent", label: "Tencent", baseUrl: "https://tokenhub-intl.tencentcloudmaas.com", apiKeyEnv: "TENCENT_API_KEY", defaultModel: "tencent/deepseek-v4-pro", knownModels: ["deepseek-v4-pro", "deepseek-v4-flash", "minimax-m3"], protocol: "anthropic" },
|
|
70
72
|
];
|
|
71
73
|
|
|
72
74
|
const BY_NAME = new Map<string, OpenAICompatProviderDef>(OPENAI_COMPAT_PROVIDERS.map(p => [p.name, p]));
|
package/src/ai/types.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Credential } from "../auth";
|
|
2
2
|
|
|
3
|
-
export type ProviderName = "anthropic" | "openai" | "gemini" | "antigravity" | "ollama" | "lmstudio" | "xai" | "kimi" | "groq" | "deepseek" | "mistral" | "openrouter" | "together" | "cerebras" | "fireworks" | "nvidia" | "alibaba-coding-plan" | "huggingface" | "nanogpt" | "qwen-portal" | "synthetic" | "venice" | "zenmux" | "qianfan" | "xiaomi" | "xiaomi-token-plan-ams" | "xiaomi-token-plan-cn" | "xiaomi-token-plan-sgp" | "minimax-code" | "minimax-code-cn" | "zai" | "minimax";
|
|
3
|
+
export type ProviderName = "anthropic" | "openai" | "gemini" | "antigravity" | "ollama" | "lmstudio" | "xai" | "kimi" | "groq" | "deepseek" | "mistral" | "openrouter" | "together" | "cerebras" | "fireworks" | "nvidia" | "alibaba-coding-plan" | "huggingface" | "nanogpt" | "qwen-portal" | "synthetic" | "venice" | "zenmux" | "qianfan" | "xiaomi" | "xiaomi-token-plan-ams" | "xiaomi-token-plan-cn" | "xiaomi-token-plan-sgp" | "minimax-code" | "minimax-code-cn" | "zai" | "minimax" | "tencent";
|
|
4
|
+
|
|
4
5
|
|
|
5
6
|
/** An image attached to a (user) message — base64 payload + IANA media type. */
|
|
6
7
|
export interface ImageAttachment {
|
package/src/auth/index.ts
CHANGED
|
@@ -19,7 +19,7 @@ export {
|
|
|
19
19
|
logoutOAuth,
|
|
20
20
|
} from "./oauth";
|
|
21
21
|
export type { OauthFlowDef } from "./oauth";
|
|
22
|
-
export { refreshOAuthToken, rotateOAuthToken } from "./refresh";
|
|
22
|
+
export { refreshOAuthToken, rotateOAuthToken, classifyRefreshFailure } from "./refresh";
|
|
23
23
|
export type { RefreshResult } from "./refresh";
|
|
24
24
|
export { OAUTH_FLOW_REGISTRY } from "./flows";
|
|
25
25
|
export type { OAuthFlow } from "./flows";
|
package/src/auth/refresh.ts
CHANGED
|
@@ -6,12 +6,30 @@ import {
|
|
|
6
6
|
snapshotProvider,
|
|
7
7
|
acquireLock,
|
|
8
8
|
releaseLock,
|
|
9
|
+
clearOauthToken,
|
|
9
10
|
type AuthProvider,
|
|
10
11
|
isOAuthProvider,
|
|
11
12
|
type Credential,
|
|
12
13
|
} from "./storage";
|
|
13
14
|
import { OAUTH_FLOW_REGISTRY } from "./flows";
|
|
14
|
-
import type
|
|
15
|
+
import { readGlobalConfig, type StoredOAuth } from "../agent/state";
|
|
16
|
+
|
|
17
|
+
// gjc auth-broker refresher parity: a refresh attempt fails either *definitively*
|
|
18
|
+
// (the refresh token itself is dead — invalid_grant / revoked / a 401-403 that is
|
|
19
|
+
// not a network blip) or *transiently* (timeout / connection reset). A definitive
|
|
20
|
+
// failure must stop us re-attempting a doomed refresh on every call; a transient
|
|
21
|
+
// one must leave the credential untouched for the next sweep.
|
|
22
|
+
const DEFINITIVE_REFRESH_RE = /invalid_grant|invalid_token|revoked|unauthorized|expired.*refresh|refresh.*expired/i;
|
|
23
|
+
const TRANSIENT_REFRESH_RE = /timeout|network|fetch failed|ECONNREFUSED/i;
|
|
24
|
+
const HTTP_401_403_RE = /\b(401|403)\b/;
|
|
25
|
+
|
|
26
|
+
/** Classify an OAuth refresh error as a dead-token ("definitive") vs retryable ("transient") failure. */
|
|
27
|
+
export function classifyRefreshFailure(err: unknown): "definitive" | "transient" {
|
|
28
|
+
const msg = (err as Error)?.message ?? String(err);
|
|
29
|
+
if (DEFINITIVE_REFRESH_RE.test(msg)) return "definitive";
|
|
30
|
+
if (HTTP_401_403_RE.test(msg) && !TRANSIENT_REFRESH_RE.test(msg)) return "definitive";
|
|
31
|
+
return "transient";
|
|
32
|
+
}
|
|
15
33
|
|
|
16
34
|
export interface RefreshResult {
|
|
17
35
|
refreshed: boolean;
|
|
@@ -55,7 +73,34 @@ export async function refreshOAuthToken(provider: AuthProvider): Promise<Refresh
|
|
|
55
73
|
}
|
|
56
74
|
|
|
57
75
|
const flow = OAUTH_FLOW_REGISTRY[provider];
|
|
58
|
-
|
|
76
|
+
let fresh: Awaited<ReturnType<typeof flow.refresh>>;
|
|
77
|
+
try {
|
|
78
|
+
fresh = await flow.refresh(stored.refresh);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
if (classifyRefreshFailure(err) === "definitive") {
|
|
81
|
+
// Dead refresh token: re-attempting it on every resolution only burns a
|
|
82
|
+
// round-trip and then emits an opaque stale-token 401. Clear it so the
|
|
83
|
+
// next resolution degrades cleanly to an API key (or a logged-out state
|
|
84
|
+
// that prompts re-login). Mirrors gjc auth-broker disableCredentialById.
|
|
85
|
+
await clearOauthToken(provider);
|
|
86
|
+
const cfg = await readGlobalConfig();
|
|
87
|
+
const apiKey = cfg.providers[provider];
|
|
88
|
+
return {
|
|
89
|
+
refreshed: false,
|
|
90
|
+
reason: "refresh_token_invalid",
|
|
91
|
+
credential: apiKey
|
|
92
|
+
? { kind: "api_key", provider, token: apiKey }
|
|
93
|
+
: { kind: "none", provider },
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
// Transient failure: keep the credential and surface the stale access token
|
|
97
|
+
// so the in-flight call can try once more; the next expiry sweep retries.
|
|
98
|
+
return {
|
|
99
|
+
refreshed: false,
|
|
100
|
+
reason: "refresh_transient_error",
|
|
101
|
+
credential: { kind: "oauth", provider, token: stored.access, projectId: stored.projectId },
|
|
102
|
+
};
|
|
103
|
+
}
|
|
59
104
|
const next: StoredOAuth = {
|
|
60
105
|
access: fresh.access,
|
|
61
106
|
refresh: fresh.refresh || stored.refresh,
|