jeo-code 0.6.41 → 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/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 a single class of common model slip: a trailing
55
- * comma right before a closing `}` or `]`. Returns `undefined` (not null — a bare
56
- * `null`/`false` is a legal JSON value) when nothing parses.
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 a trailing-comma repair */
66
+ /* fall through to layered repairs */
63
67
  }
64
- const repaired = stripTrailingCommas(s);
65
- if (repaired !== s) {
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(repaired) as T;
81
+ return JSON.parse(candidate) as T;
68
82
  } catch {
69
- /* unrecoverable here */
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
@@ -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
- return empty
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
- if (tool === "read") return `You already read that and its content is unchanged — use what you read, or read a DIFFERENT file.`;
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). */
@@ -24,7 +24,9 @@ export const JEO_TYPES = [
24
24
  "Gotcha",
25
25
  "UserPreference",
26
26
  "Reference",
27
+ "FailedAttempt",
27
28
  ] as const;
29
+
28
30
  export type JeoType = (typeof JEO_TYPES)[number];
29
31
 
30
32
  /** Reserved OKF filenames — never concept documents. */
@@ -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 rebuildIndex(bundleDir);
169
- await updateLog(bundleDir, written);
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), and user preferences. " +
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 rebuildIndex(bundleDir);
711
- if (updatedConcepts.length > 0) {
712
- await updateLog(bundleDir, updatedConcepts);
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.
@@ -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
- const payload = JSON.stringify({ pid: process.pid, at: Date.now() });
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 fs.writeFile(lockPath, payload, { flag: "wx" });
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 alive = typeof holder.pid === "number" && holder.pid > 0 && (() => {
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
- if (alive) {
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
- await fs.unlink(lockPath).catch(() => {}); // stale (dead/unreadable) → take over once
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.`);
@@ -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: withProjectContext(subagentSystemPrompt(role), projectContext) },
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
- results[i] = await runOne(role, items[i]!.task, items[i]!.context, cwd, { slot: { index: i + 1, total: items.length }, projectContext: batchContext, steer: workerSteer });
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.",
@@ -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
  ];
@@ -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?.usage) {
456
- // Cache only — usage is reported ONCE at message_delta so an accumulating
457
- // sink can't double-count input (and a pre-first-chunk retry that replays
458
- // message_start is harmless).
459
- cachedInput = totalInputTokens(evt.message.usage);
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";
@@ -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 { StoredOAuth } from "../agent/state";
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
- const fresh = await flow.refresh(stored.refresh);
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,
@@ -8,11 +8,13 @@ import { jeoEnv } from "../util/env";
8
8
  /** Providers with an interactive OAuth login + refresh flow. */
9
9
  export type OAuthProvider = "anthropic" | "openai" | "gemini" | "antigravity";
10
10
  /** Every provider jeo resolves a credential for: OAuth-capable ∪ API-key-only. */
11
- export type AuthProvider = OAuthProvider | "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";
11
+ export type AuthProvider = OAuthProvider | "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";
12
+
12
13
 
13
14
  export const OAUTH_PROVIDERS: readonly OAuthProvider[] = ["anthropic", "openai", "gemini", "antigravity"];
14
15
  /** API-key-only providers (no OAuth flow) — resolved from config.providers / `<NAME>_API_KEY`. */
15
- export const API_KEY_ONLY_PROVIDERS: readonly AuthProvider[] = ["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"];
16
+ export const API_KEY_ONLY_PROVIDERS: readonly AuthProvider[] = ["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"];
17
+
16
18
  /** Narrow an AuthProvider to the OAuth-capable subset (xai/kimi have no OAuth flow). */
17
19
  export function isOAuthProvider(p: AuthProvider): p is OAuthProvider {
18
20
  return (OAUTH_PROVIDERS as readonly string[]).includes(p);
@@ -167,9 +169,13 @@ export async function resolveCredential(provider: AuthProvider): Promise<Credent
167
169
  }).catch(() => {});
168
170
  }
169
171
  const result = await refreshPromise;
170
- if (result.refreshed && result.credential.kind === "oauth") {
171
- return result.credential;
172
- }
172
+ // refreshOAuthToken encodes the best credential for every outcome: the
173
+ // fresh token on success, the stale token on a transient blip, and an
174
+ // API-key/logged-out fallback after a definitive (dead refresh token)
175
+ // failure cleared the credential. Return it directly instead of reusing
176
+ // the in-memory stale token, so a dead OAuth credential degrades cleanly
177
+ // (to an API key or re-login) rather than looping on a doomed refresh.
178
+ return result.credential;
173
179
  } catch {
174
180
  // Fall through and use the (stale) access token; the provider call will surface a 401.
175
181
  }
package/src/cli/runner.ts CHANGED
@@ -235,6 +235,15 @@ export const COMMANDS: readonly CommandSpec[] = [
235
235
  return args => Promise.resolve(m.runLedger(args));
236
236
  },
237
237
  },
238
+ {
239
+ name: "computer",
240
+ summary: "Execute desktop automation actions (screenshot, click, type, keypress, scroll, drag, wait, batch).",
241
+ usage: "computer <action> [args]",
242
+ loader: async () => {
243
+ const m = await import("../commands/computer");
244
+ return args => m.runComputerCommand(args);
245
+ },
246
+ },
238
247
  ];
239
248
 
240
249
  export function findCommand(name: string): CommandSpec | undefined {
package/src/cli.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import { dispatch } from "./cli/runner";
3
+ import { restoreTerminalState } from "./util/terminal-restore";
3
4
  import pkg from "../package.json";
4
5
 
5
6
  const APP_NAME = "jeo";
@@ -16,11 +17,26 @@ if (typeof Bun !== "undefined" && Bun.semver?.order(Bun.version, MIN_BUN_VERSION
16
17
  }
17
18
  process.title = APP_NAME;
18
19
 
20
+ // Last-resort crash net: a background `fetch()` socket error (or any stray
21
+ // rejection/throw) can tear the process down outside the try/catch below, AFTER
22
+ // the REPL has put stdin in raw mode + enabled bracketed paste. Without this the
23
+ // shell is left mute ("error printed, then input is dead"). Restore the terminal
24
+ // SYNCHRONOUSLY, print one clean line, and exit non-zero — never a raw stack dump.
25
+ const fatal = (err: unknown): never => {
26
+ restoreTerminalState();
27
+ const msg = (err as Error)?.message ?? String(err);
28
+ process.stderr.write(`error: ${msg}\n`);
29
+ process.exit(1);
30
+ };
31
+ process.on("uncaughtException", fatal);
32
+ process.on("unhandledRejection", fatal);
33
+
19
34
  try {
20
35
  const code = await dispatch(process.argv.slice(2), { appName: APP_NAME, version: VERSION });
21
36
  if (code !== 0) process.exit(code);
22
37
  } catch (err) {
23
38
  // Service-readiness: never surface a raw stack trace to users; clean error + non-zero exit.
39
+ restoreTerminalState();
24
40
  process.stderr.write(`error: ${(err as Error)?.message ?? String(err)}\n`);
25
41
  process.exit(1);
26
42
  }