jeo-code 0.6.41 → 0.7.1
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 +40 -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 +5 -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 +38 -7
- package/src/agent/task-tool.ts +12 -3
- package/src/agent/tool-schemas.ts +19 -0
- package/src/ai/model-catalog.ts +22 -0
- package/src/ai/model-discovery.ts +27 -9
- 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 +49 -6
- package/src/commands/launch.ts +41 -18
- 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/app.ts +7 -4
- package/src/tui/components/ascii-art.ts +6 -2
- package/src/tui/components/slash.ts +1 -0
- package/src/tui/components/themes.ts +14 -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
|
@@ -97,6 +97,9 @@ export interface Config {
|
|
|
97
97
|
roles?: { smol?: string; slow?: string; plan?: string };
|
|
98
98
|
gitAutoCommit?: boolean;
|
|
99
99
|
hooks?: HookConfig;
|
|
100
|
+
computer?: {
|
|
101
|
+
enabled?: boolean;
|
|
102
|
+
};
|
|
100
103
|
}
|
|
101
104
|
|
|
102
105
|
export interface WorkflowTopologyComponent {
|
|
@@ -141,6 +144,10 @@ export interface WorkflowState {
|
|
|
141
144
|
consensus?: string;
|
|
142
145
|
/** ralplan: the critic's justification (bounded excerpt) for auditability. */
|
|
143
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;
|
|
144
151
|
approved?: boolean;
|
|
145
152
|
/** ultragoal terminal outcome. */
|
|
146
153
|
status?: string;
|
|
@@ -477,7 +484,20 @@ export async function clearWorkflowState(
|
|
|
477
484
|
* team-state.json last-writer-wins — tasks executed twice, completions lost.
|
|
478
485
|
* O_EXCL lockfile carrying the holder's pid; a DEAD holder's stale lock is taken
|
|
479
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.
|
|
480
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
|
+
|
|
481
501
|
export async function acquireWorkflowRunLock(
|
|
482
502
|
skill: "team" | "ultragoal",
|
|
483
503
|
cwd: string = process.cwd(),
|
|
@@ -485,19 +505,27 @@ export async function acquireWorkflowRunLock(
|
|
|
485
505
|
const stateDir = path.join(getLocalJeoDir(cwd), "state");
|
|
486
506
|
await fs.mkdir(stateDir, { recursive: true });
|
|
487
507
|
const lockPath = path.join(stateDir, `${skill}.lock`);
|
|
488
|
-
|
|
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 } : {});
|
|
489
511
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
490
512
|
try {
|
|
491
|
-
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?.();
|
|
492
518
|
return async () => {
|
|
519
|
+
clearInterval(hb);
|
|
493
520
|
await fs.unlink(lockPath).catch(() => {});
|
|
494
521
|
};
|
|
522
|
+
|
|
495
523
|
} catch {
|
|
496
|
-
let holder: { pid?: number } = {};
|
|
524
|
+
let holder: { pid?: number; at?: number } = {};
|
|
497
525
|
try {
|
|
498
|
-
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 };
|
|
499
527
|
} catch { /* unreadable/torn lock → treat as stale */ }
|
|
500
|
-
const
|
|
528
|
+
const pidAlive = typeof holder.pid === "number" && holder.pid > 0 && (() => {
|
|
501
529
|
try {
|
|
502
530
|
process.kill(holder.pid!, 0);
|
|
503
531
|
return true;
|
|
@@ -505,12 +533,15 @@ export async function acquireWorkflowRunLock(
|
|
|
505
533
|
return false;
|
|
506
534
|
}
|
|
507
535
|
})();
|
|
508
|
-
|
|
536
|
+
const fresh = typeof holder.at === "number" && (Date.now() - holder.at) < RUN_LOCK_TTL_MS;
|
|
537
|
+
if (pidAlive && fresh) {
|
|
509
538
|
throw new Error(
|
|
510
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.`,
|
|
511
540
|
);
|
|
512
541
|
}
|
|
513
|
-
|
|
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(() => {});
|
|
514
545
|
}
|
|
515
546
|
}
|
|
516
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,28 @@ 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
|
+
// Tencent — DeepSeek
|
|
113
|
+
{ canonical: "deepseek-v4-pro", provider: "tencent", providerModel: "deepseek-v4-pro", contextTokens: 128_000, maxOutputTokens: 8192, thinking: FULL, images: false, company: "Tencent" },
|
|
114
|
+
{ canonical: "deepseek-v4-pro-202606", provider: "tencent", providerModel: "deepseek-v4-pro-202606", contextTokens: 128_000, maxOutputTokens: 8192, thinking: FULL, images: false, company: "Tencent" },
|
|
115
|
+
{ canonical: "deepseek-v4-flash", provider: "tencent", providerModel: "deepseek-v4-flash", contextTokens: 128_000, maxOutputTokens: 8192, thinking: FULL, images: false, company: "Tencent" },
|
|
116
|
+
{ canonical: "deepseek-v4-flash-202605", provider: "tencent", providerModel: "deepseek-v4-flash-202605", contextTokens: 128_000, maxOutputTokens: 8192, thinking: FULL, images: false, company: "Tencent" },
|
|
117
|
+
{ canonical: "deepseek-v3.2", provider: "tencent", providerModel: "deepseek-v3.2", contextTokens: 128_000, maxOutputTokens: 8192, thinking: FULL, images: false, company: "Tencent" },
|
|
118
|
+
// Tencent — MiniMax
|
|
119
|
+
{ canonical: "minimax-m3", provider: "tencent", providerModel: "minimax-m3", contextTokens: 128_000, maxOutputTokens: 8192, thinking: FULL, images: false, company: "Tencent" },
|
|
120
|
+
{ canonical: "minimax-m2.7", provider: "tencent", providerModel: "minimax-m2.7", contextTokens: 128_000, maxOutputTokens: 8192, thinking: FULL, images: false, company: "Tencent" },
|
|
121
|
+
{ canonical: "minimax-m2.5", provider: "tencent", providerModel: "minimax-m2.5", contextTokens: 128_000, maxOutputTokens: 8192, thinking: FULL, images: false, company: "Tencent" },
|
|
122
|
+
// Tencent — Zhipu GLM
|
|
123
|
+
{ canonical: "glm-5.2", provider: "tencent", providerModel: "glm-5.2", contextTokens: 128_000, maxOutputTokens: 8192, thinking: FULL, images: false, company: "Tencent" },
|
|
124
|
+
{ canonical: "glm-5.1", provider: "tencent", providerModel: "glm-5.1", contextTokens: 128_000, maxOutputTokens: 8192, thinking: FULL, images: false, company: "Tencent" },
|
|
125
|
+
{ canonical: "glm-5", provider: "tencent", providerModel: "glm-5", contextTokens: 128_000, maxOutputTokens: 8192, thinking: FULL, images: false, company: "Tencent" },
|
|
126
|
+
{ canonical: "glm-5-turbo", provider: "tencent", providerModel: "glm-5-turbo", contextTokens: 128_000, maxOutputTokens: 8192, thinking: FULL, images: false, company: "Tencent" },
|
|
127
|
+
{ canonical: "glm-5v-turbo", provider: "tencent", providerModel: "glm-5v-turbo", contextTokens: 128_000, maxOutputTokens: 8192, thinking: FULL, images: true, company: "Tencent" },
|
|
128
|
+
// Tencent — Moonshot Kimi
|
|
129
|
+
{ canonical: "kimi-k2.6", provider: "tencent", providerModel: "kimi-k2.6", contextTokens: 128_000, maxOutputTokens: 8192, thinking: FULL, images: false, company: "Tencent" },
|
|
130
|
+
{ canonical: "kimi-k2.5", provider: "tencent", providerModel: "kimi-k2.5", contextTokens: 128_000, maxOutputTokens: 8192, thinking: FULL, images: false, company: "Tencent" },
|
|
131
|
+
// Tencent — Hunyuan MT
|
|
132
|
+
{ canonical: "hy-mt2-plus", provider: "tencent", providerModel: "hy-mt2-plus", contextTokens: 128_000, maxOutputTokens: 8192, thinking: STD, images: false, company: "Tencent" },
|
|
111
133
|
// Ollama (local)
|
|
112
134
|
{ canonical: "qwen2.5", provider: "ollama", providerModel: "ollama/qwen2.5:0.5b", contextTokens: 32_768, maxOutputTokens: 8_192, thinking: [], images: false },
|
|
113
135
|
];
|
|
@@ -270,23 +270,41 @@ export function parseModelsBody(provider: ProviderName, body: unknown): string[]
|
|
|
270
270
|
|
|
271
271
|
/**
|
|
272
272
|
* When a provider is authenticated (oauth/api_key) but the live `models` endpoint
|
|
273
|
-
* is unusable — e.g. ChatGPT/Codex OAuth tokens are rejected by `api.openai.com/v1/models
|
|
274
|
-
*
|
|
275
|
-
*
|
|
273
|
+
* is unusable — e.g. ChatGPT/Codex OAuth tokens are rejected by `api.openai.com/v1/models`,
|
|
274
|
+
* or a provider (Tencent MaaS) simply has no `/v1/models` route — surface the static
|
|
275
|
+
* catalog ids so the provider's models still appear in pickers.
|
|
276
|
+
* Keyless/not-logged-in results, and api_key *auth rejections* (a bad key), are
|
|
277
|
+
* returned unchanged so they stay honest failures.
|
|
276
278
|
*/
|
|
277
279
|
export function catalogOr(result: ProviderModelsResult): ProviderModelsResult {
|
|
278
280
|
if (result.ok && result.models.length > 0) return result;
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
// back to their static catalog too — EXCEPT Antigravity, whose available
|
|
282
|
-
// models depend on the Cloud Code Assist agent backend and must not be faked.
|
|
283
|
-
if (result.source !== "oauth") return result;
|
|
281
|
+
// Antigravity's available models depend on the Cloud Code Assist agent backend
|
|
282
|
+
// and must never be faked from a hard-coded catalog.
|
|
284
283
|
if (result.provider === "antigravity") return result;
|
|
285
|
-
|
|
284
|
+
// OAuth: OpenAI/Codex legitimately rejects the standard /models endpoint while
|
|
285
|
+
// the fixed Codex ids still work; other OAuth providers fall back to their full
|
|
286
|
+
// static catalog too. API-key providers only fall back when the provider has NO
|
|
287
|
+
// models-list endpoint at all (HTTP 404 / not found) — never for an auth
|
|
288
|
+
// rejection, so a wrong key stays a failure instead of fabricating catalog rows.
|
|
289
|
+
const eligible = result.source === "oauth"
|
|
290
|
+
|| (result.source === "api_key" && isMissingModelsEndpoint(result.error));
|
|
291
|
+
if (!eligible) return result;
|
|
292
|
+
const ids = result.provider === "openai" && result.source === "oauth"
|
|
293
|
+
? [...CODEX_MODELS]
|
|
294
|
+
: catalogByProvider(result.provider).map(m => m.providerModel);
|
|
286
295
|
if (ids.length === 0) return result;
|
|
287
296
|
return { ...result, models: ids, ok: true, fallback: true };
|
|
288
297
|
}
|
|
289
298
|
|
|
299
|
+
/**
|
|
300
|
+
* True when a discovery error means the models-list endpoint itself is absent
|
|
301
|
+
* (HTTP 404 / "not found") rather than an auth/key or transient network problem.
|
|
302
|
+
*/
|
|
303
|
+
function isMissingModelsEndpoint(error: string | undefined): boolean {
|
|
304
|
+
if (!error) return false;
|
|
305
|
+
return /(^|\D)404(\D|$)/.test(error) || /not found/i.test(error);
|
|
306
|
+
}
|
|
307
|
+
|
|
290
308
|
/** Discover the live model list for one provider. Never throws. */
|
|
291
309
|
export async function listProviderModels(
|
|
292
310
|
provider: ProviderName,
|
|
@@ -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 / deepseek-v3.2 / minimax-m3 over the Anthropic Messages wire format at tokenhub-intl; `${base}/v1/messages` is pinned by the adapter. Model ids verified live (2026-06) via /v1/messages probes — the host has no /v1/models route.
|
|
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", "deepseek-v3.2"], 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,
|