jeo-code 0.8.21 → 0.8.23
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 +37 -0
- package/README.ja.md +15 -3
- package/README.ko.md +15 -3
- package/README.md +15 -3
- package/README.zh.md +15 -3
- package/package.json +1 -1
- package/src/agent/AGENTS.md +1 -1
- package/src/agent/browser-tool.ts +32 -3
- package/src/agent/engine.ts +24 -4
- package/src/agent/loop.ts +3 -2
- package/src/agent/prompt-router.ts +115 -19
- package/src/agent/skill-lessons.ts +315 -0
- package/src/agent/subagents.ts +7 -6
- package/src/agent/task-tool.ts +210 -68
- package/src/agent/vision-verify.ts +75 -0
- package/src/ai/model-catalog.ts +61 -7
- package/src/ai/model-manager.ts +11 -2
- package/src/ai/types.ts +4 -2
- package/src/cli/runner.ts +13 -1
- package/src/commands/launch.ts +229 -39
- package/src/commands/ralplan.ts +40 -3
- package/src/commands/routine.ts +249 -0
- package/src/commands/skills.ts +57 -0
- package/src/tui/app.ts +19 -1
- package/src/tui/terminal.ts +89 -0
- package/src/util/provider-error.ts +15 -0
- package/src/util/retry.ts +15 -4
- package/src/util/routine-template.ts +206 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `jeo routine init` — generate a GitHub Actions workflow that runs
|
|
3
|
+
* `jeo "<prompt>" -p` headlessly on a schedule or repo event.
|
|
4
|
+
*
|
|
5
|
+
* This is the SCOPED-SAFE take on "Routines": jeo-code itself never runs a
|
|
6
|
+
* scheduler, listens on a port, or accepts a webhook — it's a pure template
|
|
7
|
+
* generator. GitHub's own hosted runners do the actual triggering/running,
|
|
8
|
+
* which achieves "runs without your laptop" with zero new attack surface
|
|
9
|
+
* inside jeo-code's own process. See ../util/routine-template.ts for the
|
|
10
|
+
* pure YAML-building logic this command wraps with flag parsing + I/O.
|
|
11
|
+
*/
|
|
12
|
+
import * as fs from "node:fs/promises";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import { renderRoutineWorkflow, validateCron, type RoutineConfig, type RoutineTrigger } from "../util/routine-template";
|
|
15
|
+
import { slugify } from "../agent/memory-okf";
|
|
16
|
+
|
|
17
|
+
const TRIGGERS: readonly RoutineTrigger[] = ["schedule", "issues", "pull_request"];
|
|
18
|
+
function isTrigger(val: string | undefined): val is RoutineTrigger {
|
|
19
|
+
return val !== undefined && (TRIGGERS as readonly string[]).includes(val);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const VALUE_FLAGS: Record<string, true> = { "--name": true, "--trigger": true, "--cron": true, "--prompt": true, "--api-key-env": true, "--out": true };
|
|
23
|
+
|
|
24
|
+
interface ParsedInit {
|
|
25
|
+
name: string;
|
|
26
|
+
triggerRaw?: string;
|
|
27
|
+
cron?: string;
|
|
28
|
+
prompt?: string;
|
|
29
|
+
apiKeyEnvVar: string;
|
|
30
|
+
openPr: boolean;
|
|
31
|
+
out?: string;
|
|
32
|
+
force: boolean;
|
|
33
|
+
dryRun: boolean;
|
|
34
|
+
json: boolean;
|
|
35
|
+
help: boolean;
|
|
36
|
+
/** Parse-level problems: unknown flags, or a value-flag given with no value. */
|
|
37
|
+
errors: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** `--flag value` or `--flag=value`, matching launch/flags.ts's takeValue shape
|
|
41
|
+
* (a bare value-flag followed by another `-`-prefixed token is treated as
|
|
42
|
+
* missing, not as accidentally swallowing the next flag). */
|
|
43
|
+
function takeValue(args: string[], index: number, flag: string): { value?: string; nextIndex: number } {
|
|
44
|
+
const current = args[index]!;
|
|
45
|
+
const inline = `${flag}=`;
|
|
46
|
+
if (current.startsWith(inline)) return { value: current.slice(inline.length), nextIndex: index };
|
|
47
|
+
const next = args[index + 1];
|
|
48
|
+
if (next !== undefined && !next.startsWith("-")) return { value: next, nextIndex: index + 1 };
|
|
49
|
+
return { nextIndex: index };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseInitArgs(args: string[]): ParsedInit {
|
|
53
|
+
const parsed: ParsedInit = {
|
|
54
|
+
name: "jeo routine",
|
|
55
|
+
apiKeyEnvVar: "ANTHROPIC_API_KEY",
|
|
56
|
+
openPr: true,
|
|
57
|
+
force: false,
|
|
58
|
+
dryRun: false,
|
|
59
|
+
json: false,
|
|
60
|
+
help: false,
|
|
61
|
+
errors: [],
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
for (let i = 0; i < args.length; i++) {
|
|
65
|
+
const a = args[i]!;
|
|
66
|
+
const flag = a.includes("=") ? a.slice(0, a.indexOf("=")) : a;
|
|
67
|
+
|
|
68
|
+
if (flag === "-h" || flag === "--help") { parsed.help = true; continue; }
|
|
69
|
+
if (flag === "--no-pr") { parsed.openPr = false; continue; }
|
|
70
|
+
if (flag === "--force") { parsed.force = true; continue; }
|
|
71
|
+
if (flag === "--dry-run") { parsed.dryRun = true; continue; }
|
|
72
|
+
if (flag === "--json") { parsed.json = true; continue; }
|
|
73
|
+
|
|
74
|
+
if (VALUE_FLAGS[flag]) {
|
|
75
|
+
const { value, nextIndex } = takeValue(args, i, flag);
|
|
76
|
+
i = nextIndex;
|
|
77
|
+
if (value === undefined) {
|
|
78
|
+
parsed.errors.push(`${flag} requires a value`);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
switch (flag) {
|
|
82
|
+
case "--name": parsed.name = value; break;
|
|
83
|
+
case "--trigger": parsed.triggerRaw = value; break;
|
|
84
|
+
case "--cron": parsed.cron = value; break;
|
|
85
|
+
case "--prompt": parsed.prompt = value; break;
|
|
86
|
+
case "--api-key-env": parsed.apiKeyEnvVar = value; break;
|
|
87
|
+
case "--out": parsed.out = value; break;
|
|
88
|
+
}
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
parsed.errors.push(`Unknown flag: ${a}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return parsed;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function printUsage(): void {
|
|
99
|
+
console.log("Usage: jeo routine init [options]");
|
|
100
|
+
console.log("");
|
|
101
|
+
console.log('Generate a GitHub Actions workflow that runs `jeo "<prompt>" -p` headlessly');
|
|
102
|
+
console.log("on a schedule or repo event. GitHub's own hosted runners do the triggering —");
|
|
103
|
+
console.log("jeo-code never runs a scheduler or listens on a port for this.");
|
|
104
|
+
console.log("");
|
|
105
|
+
console.log("Options:");
|
|
106
|
+
console.log(" --trigger <schedule|issues|pull_request> Required. What fires the routine.");
|
|
107
|
+
console.log(" --prompt <text> Required. The task/goal text passed to `jeo -p`.");
|
|
108
|
+
console.log(' --cron <expr> Required when --trigger schedule (e.g. "0 7 * * *").');
|
|
109
|
+
console.log(' --name <name> Workflow display name. Default: "jeo routine".');
|
|
110
|
+
console.log(" --api-key-env <VAR_NAME> Secret env var name. Default: ANTHROPIC_API_KEY.");
|
|
111
|
+
console.log(" --no-pr Commit directly to the branch instead of opening a PR.");
|
|
112
|
+
console.log(" --out <path> Output path. Default: .github/workflows/jeo-routine-<slug>.yml");
|
|
113
|
+
console.log(" --force Overwrite an existing file at --out.");
|
|
114
|
+
console.log(" --dry-run Print the rendered YAML instead of writing it.");
|
|
115
|
+
console.log(" --json Machine-readable output.");
|
|
116
|
+
console.log(" -h, --help Show this help message.");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function runInit(args: string[]): Promise<void> {
|
|
120
|
+
const parsed = parseInitArgs(args);
|
|
121
|
+
|
|
122
|
+
if (parsed.errors.length > 0) {
|
|
123
|
+
for (const e of parsed.errors) console.log(`Error: ${e}`);
|
|
124
|
+
printUsage();
|
|
125
|
+
process.exitCode = 1;
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (parsed.help) {
|
|
130
|
+
printUsage();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const problems: string[] = [];
|
|
135
|
+
if (!isTrigger(parsed.triggerRaw)) {
|
|
136
|
+
problems.push(
|
|
137
|
+
parsed.triggerRaw
|
|
138
|
+
? `--trigger must be one of: schedule, issues, pull_request (got '${parsed.triggerRaw}')`
|
|
139
|
+
: "--trigger is required (one of: schedule, issues, pull_request)",
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
if (!parsed.prompt) {
|
|
143
|
+
problems.push("--prompt is required");
|
|
144
|
+
}
|
|
145
|
+
const trigger = isTrigger(parsed.triggerRaw) ? parsed.triggerRaw : undefined;
|
|
146
|
+
if (trigger === "schedule") {
|
|
147
|
+
if (!parsed.cron) {
|
|
148
|
+
problems.push("--cron is required when --trigger schedule");
|
|
149
|
+
} else if (!validateCron(parsed.cron)) {
|
|
150
|
+
problems.push(`--cron '${parsed.cron}' does not look like a valid 5-field cron expression`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (problems.length > 0) {
|
|
155
|
+
for (const p of problems) console.log(`Error: ${p}`);
|
|
156
|
+
printUsage();
|
|
157
|
+
process.exitCode = 1;
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const config: RoutineConfig = {
|
|
162
|
+
name: parsed.name,
|
|
163
|
+
trigger: trigger!,
|
|
164
|
+
cron: parsed.cron,
|
|
165
|
+
prompt: parsed.prompt!,
|
|
166
|
+
apiKeyEnvVar: parsed.apiKeyEnvVar,
|
|
167
|
+
openPr: parsed.openPr,
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
let yaml: string;
|
|
171
|
+
try {
|
|
172
|
+
yaml = renderRoutineWorkflow(config);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
175
|
+
if (parsed.json) console.log(JSON.stringify({ error: msg }));
|
|
176
|
+
else console.log(`Error: ${msg}`);
|
|
177
|
+
process.exitCode = 1;
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const outPath = parsed.out ?? path.join(".github", "workflows", `jeo-routine-${slugify(parsed.name)}.yml`);
|
|
182
|
+
const resolvedOut = path.resolve(process.cwd(), outPath);
|
|
183
|
+
|
|
184
|
+
if (parsed.dryRun) {
|
|
185
|
+
if (parsed.json) console.log(JSON.stringify({ dryRun: true, out: resolvedOut, yaml }, null, 2));
|
|
186
|
+
else console.log(yaml);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
let exists = true;
|
|
191
|
+
try {
|
|
192
|
+
await fs.access(resolvedOut);
|
|
193
|
+
} catch {
|
|
194
|
+
exists = false;
|
|
195
|
+
}
|
|
196
|
+
if (exists && !parsed.force) {
|
|
197
|
+
const msg = `${resolvedOut} already exists. Use --force to overwrite.`;
|
|
198
|
+
if (parsed.json) console.log(JSON.stringify({ error: msg }));
|
|
199
|
+
else console.log(`Error: ${msg}`);
|
|
200
|
+
process.exitCode = 1;
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
await fs.mkdir(path.dirname(resolvedOut), { recursive: true });
|
|
205
|
+
// Atomic write: build the file fully off to the side, then rename into
|
|
206
|
+
// place, so a crash mid-write never leaves a truncated workflow file.
|
|
207
|
+
const tmpPath = `${resolvedOut}.tmp-${process.pid}`;
|
|
208
|
+
await fs.writeFile(tmpPath, yaml, "utf-8");
|
|
209
|
+
await fs.rename(tmpPath, resolvedOut);
|
|
210
|
+
|
|
211
|
+
if (parsed.json) {
|
|
212
|
+
console.log(JSON.stringify({ wrote: resolvedOut, trigger, apiKeyEnvVar: parsed.apiKeyEnvVar, openPr: parsed.openPr }, null, 2));
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
console.log(`Wrote ${resolvedOut}`);
|
|
217
|
+
console.log("");
|
|
218
|
+
console.log("Next steps:");
|
|
219
|
+
console.log(` - Add '${parsed.apiKeyEnvVar}' as a repo secret: Settings -> Secrets and variables -> Actions.`);
|
|
220
|
+
if (trigger === "schedule") {
|
|
221
|
+
console.log(" - Scheduled workflows only run on the repo's default branch, and GitHub may");
|
|
222
|
+
console.log(" delay or skip scheduled runs on low-activity repos — this is documented");
|
|
223
|
+
console.log(" GitHub Actions behavior, not a bug in the generated workflow.");
|
|
224
|
+
}
|
|
225
|
+
console.log(` - Commit and push ${resolvedOut} to enable it.`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function runRoutineCommand(args: string[]): Promise<void> {
|
|
229
|
+
const sub = args[0];
|
|
230
|
+
|
|
231
|
+
if (sub === "-h" || sub === "--help") {
|
|
232
|
+
printUsage();
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (sub === undefined) {
|
|
237
|
+
printUsage();
|
|
238
|
+
process.exitCode = 1;
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (sub !== "init") {
|
|
243
|
+
console.log(`Unknown 'jeo routine' subcommand '${sub}'. Usage: jeo routine init [options]`);
|
|
244
|
+
process.exitCode = 1;
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
await runInit(args.slice(1));
|
|
249
|
+
}
|
package/src/commands/skills.ts
CHANGED
|
@@ -2,6 +2,7 @@ import * as fs from "node:fs/promises";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { SKILLS, getSkillFrom, formatSkill, loadSkills, skillDirs, bundledSkillFileContent, userSkillsDir } from "../skills/catalog";
|
|
4
4
|
import { getLocalJeoDir } from "../agent/state";
|
|
5
|
+
import { appendSkillLesson, evalSkillLessons, type SkillLesson } from "../agent/skill-lessons";
|
|
5
6
|
|
|
6
7
|
export { userSkillsDir };
|
|
7
8
|
|
|
@@ -147,6 +148,62 @@ export async function runSkillsCommand(args: string[] = []): Promise<void> {
|
|
|
147
148
|
return;
|
|
148
149
|
}
|
|
149
150
|
|
|
151
|
+
// `jeo skills lesson <skill> <failure|anti-pattern> "<title>" "<detail>"` — manual
|
|
152
|
+
// lesson entry into the project-level skill file (the same compounding write the
|
|
153
|
+
// launch.ts stall-guard fires automatically; lets an agent or human record one directly).
|
|
154
|
+
if (cleanArgs[0] === "lesson") {
|
|
155
|
+
const [, skillName, kindArg, title, detail] = cleanArgs;
|
|
156
|
+
if (!skillName || !kindArg || !title || !detail) {
|
|
157
|
+
console.log('Usage: jeo skills lesson <skill> <failure|anti-pattern> "<title>" "<detail>"');
|
|
158
|
+
process.exitCode = 1;
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const kind: SkillLesson["kind"] | null =
|
|
162
|
+
kindArg === "failure" ? "failure-mode" : kindArg === "anti-pattern" ? "anti-pattern" : null;
|
|
163
|
+
if (!kind) {
|
|
164
|
+
console.log(`Error: kind must be 'failure' or 'anti-pattern' (got '${kindArg}').`);
|
|
165
|
+
process.exitCode = 1;
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const result = await appendSkillLesson(cwd, { skill: skillName, kind, title, detail });
|
|
169
|
+
if (isJson) {
|
|
170
|
+
console.log(JSON.stringify(result, null, 2));
|
|
171
|
+
} else if (result.appended) {
|
|
172
|
+
console.log("Appended.");
|
|
173
|
+
} else if (result.reason === "duplicate") {
|
|
174
|
+
console.log("Already recorded (duplicate).");
|
|
175
|
+
} else {
|
|
176
|
+
console.log(`Error: ${result.reason ?? "unknown error"}`);
|
|
177
|
+
process.exitCode = 1;
|
|
178
|
+
}
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// `jeo skills eval <skill>` — self-eval half: judge whether recorded lessons are
|
|
183
|
+
// still covered by the skill's current guidance, or have gone stale.
|
|
184
|
+
if (cleanArgs[0] === "eval") {
|
|
185
|
+
const skillName = cleanArgs[1];
|
|
186
|
+
if (!skillName) {
|
|
187
|
+
console.log("Error: Missing skill name for 'eval' command.");
|
|
188
|
+
process.exitCode = 1;
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const result = await evalSkillLessons(cwd, skillName);
|
|
192
|
+
if (result === null) {
|
|
193
|
+
console.log(`No recorded lessons for '${skillName}' yet.`);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (isJson) {
|
|
197
|
+
console.log(JSON.stringify(result, null, 2));
|
|
198
|
+
} else {
|
|
199
|
+
console.log(`${result.covered}/${result.total} lessons still covered by current guidance.`);
|
|
200
|
+
for (const s of result.stale) {
|
|
201
|
+
console.log(` STALE ${s.title}: ${s.reason}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
150
207
|
// List/lookup over the MERGED set (bundled + ~/.jeo/skills + ~/.agents/skills + project dirs), matching the REPL /skill.
|
|
151
208
|
const skills = await loadSkills(cwd);
|
|
152
209
|
const command = cleanArgs[0];
|
package/src/tui/app.ts
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { Renderer } from "./renderer";
|
|
13
13
|
import { readWorkflowStateStrict } from "../agent/state";
|
|
14
|
-
import { size, isTTY, hideCursor, showCursor, enterAltScreen, leaveAltScreen } from "./terminal";
|
|
14
|
+
import { size, isTTY, hideCursor, showCursor, enterAltScreen, leaveAltScreen, watchResize } from "./terminal";
|
|
15
|
+
|
|
15
16
|
import { Spinner } from "./components/spinner";
|
|
16
17
|
import { ToolList } from "./components/tool-list";
|
|
17
18
|
import { StreamRegion } from "./components/stream";
|
|
@@ -251,6 +252,10 @@ export class LaunchTui {
|
|
|
251
252
|
private lastResizeAt = 0;
|
|
252
253
|
private lastCols = -1;
|
|
253
254
|
private lastRows = -1;
|
|
255
|
+
// Stops the poll-based resize safety net (terminal.ts's watchResize) started in
|
|
256
|
+
// begin(); cleared in finish() alongside the 'resize'/SIGCONT listeners.
|
|
257
|
+
private stopResizeWatch: (() => void) | undefined;
|
|
258
|
+
|
|
254
259
|
private pendingIndex: number | null = null;
|
|
255
260
|
private pendingTitle: string | null = null;
|
|
256
261
|
private pendingForge: ForgeSummary | null = null;
|
|
@@ -1172,7 +1177,15 @@ export class LaunchTui {
|
|
|
1172
1177
|
if (processMax > 0 && resumeCount + 1 >= processMax) process.setMaxListeners(resumeCount + 2);
|
|
1173
1178
|
process.on("SIGCONT", this.onResume);
|
|
1174
1179
|
}
|
|
1180
|
+
// Poll-based safety net (terminal.ts's watchResize): catches a resize the TTY
|
|
1181
|
+
// 'resize' event missed (e.g. a tmux pane switch while this pane wasn't the
|
|
1182
|
+
// foreground one — tmux only forwards SIGWINCH to the active pane). Cheap
|
|
1183
|
+
// (300ms comparison poll), fires resizeRepaint() only on an actual geometry
|
|
1184
|
+
// change, and is a no-op alongside a real 'resize' event (whichever notices
|
|
1185
|
+
// first wins; lastCols/lastRows dedupe the other).
|
|
1186
|
+
this.stopResizeWatch = watchResize(() => this.resizeRepaint());
|
|
1175
1187
|
}
|
|
1188
|
+
|
|
1176
1189
|
// Animate the spinner + elapsed clock while the model is thinking.
|
|
1177
1190
|
this.timer = setInterval(() => {
|
|
1178
1191
|
try {
|
|
@@ -1269,7 +1282,12 @@ export class LaunchTui {
|
|
|
1269
1282
|
if (process.platform !== "win32") {
|
|
1270
1283
|
process.removeListener("SIGCONT", this.onResume);
|
|
1271
1284
|
}
|
|
1285
|
+
if (this.stopResizeWatch) {
|
|
1286
|
+
this.stopResizeWatch();
|
|
1287
|
+
this.stopResizeWatch = undefined;
|
|
1288
|
+
}
|
|
1272
1289
|
}
|
|
1290
|
+
|
|
1273
1291
|
if (this.usedAltScreen) {
|
|
1274
1292
|
// Leave the alt screen (restores the main buffer + scrollback), then print the
|
|
1275
1293
|
// static summary below the prior output so the turn leaves exactly one record.
|
package/src/tui/terminal.ts
CHANGED
|
@@ -84,6 +84,75 @@ export function size(): { cols: number; rows: number } {
|
|
|
84
84
|
rows: process.stdout.rows || 24,
|
|
85
85
|
};
|
|
86
86
|
}
|
|
87
|
+
/** True live geometry via `getWindowSize()` — a real `TIOCGWINSZ` ioctl through libuv,
|
|
88
|
+
* bypassing `process.stdout.columns`/`.rows` entirely. Node/Bun only refresh THOSE
|
|
89
|
+
* cached fields when a `'resize'` (SIGWINCH) event fires, which is exactly the signal
|
|
90
|
+
* {@link watchResize} exists to work around (tmux pane/window switch while jeo's pane
|
|
91
|
+
* isn't foregrounded, a SIGCONT race after Ctrl-Z, an SSH/multiplexer layer that only
|
|
92
|
+
* propagates the ioctl update on the next write) — in every one of those, the cached
|
|
93
|
+
* fields go stale and NOTHING re-freshes them without a real event. Returns `null` when
|
|
94
|
+
* unavailable (older runtimes, non-TTY streams — Bun/Node only expose `getWindowSize`
|
|
95
|
+
* on a real `tty.WriteStream`), it throws (handle torn down), or it reports `0,0` (no
|
|
96
|
+
* controlling terminal ever set a winsize, e.g. a bare pty as `bun test` sees). */
|
|
97
|
+
function liveWindowSize(): { cols: number; rows: number } | null {
|
|
98
|
+
const stream = process.stdout as NodeJS.WriteStream & { getWindowSize?: () => [number, number] };
|
|
99
|
+
if (typeof stream.getWindowSize !== "function") return null;
|
|
100
|
+
try {
|
|
101
|
+
const [cols, rows] = stream.getWindowSize();
|
|
102
|
+
return cols > 0 && rows > 0 ? { cols, rows } : null;
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Poll-based resize watcher — a SAFETY NET alongside the TTY `'resize'` event.
|
|
109
|
+
* `'resize'` (SIGWINCH) is missed in several real-world cases jeo actually hits: a
|
|
110
|
+
* tmux pane/window switch while jeo's pane isn't the foreground one (tmux only
|
|
111
|
+
* forwards SIGWINCH to the active pane), a SIGCONT race after Ctrl-Z where the resize
|
|
112
|
+
* happened entirely during the stop, and some SSH/multiplexer layers that only
|
|
113
|
+
* propagate the ioctl update on the next write rather than emitting a fresh event.
|
|
114
|
+
* In every one of these, `process.stdout.columns`/`.rows` (and therefore `size()`)
|
|
115
|
+
* go stale and NOTHING short of a real `'resize'` event ever refreshes them — so a
|
|
116
|
+
* naive poll comparing `size()` against itself can never observe a change that
|
|
117
|
+
* already happened, it would just keep comparing one stale cache to itself.
|
|
118
|
+
*
|
|
119
|
+
* Each tick instead asks the OS DIRECTLY via {@link liveWindowSize} (a cheap,
|
|
120
|
+
* allocation-free ioctl — safe every `intervalMs`). When that live read disagrees
|
|
121
|
+
* with the last-seen geometry, this SELF-HEALS the stale cache — writing the
|
|
122
|
+
* corrected values onto `process.stdout.columns`/`.rows` exactly like Node's own
|
|
123
|
+
* SIGWINCH handler would — before firing `onChange`, so every other reader of
|
|
124
|
+
* `size()` (the live-frame `draw()`, `resizeRepaint()`, `idleResizeHandler()`, a
|
|
125
|
+
* picker's `repaint()`) sees the corrected geometry too, not just this callback.
|
|
126
|
+
* When `getWindowSize` is unavailable, falls back to comparing `size()` against
|
|
127
|
+
* itself (the pre-existing behavior) — still catches a genuine cache update that
|
|
128
|
+
* arrived via some OTHER path (e.g. a real `'resize'` event firing between polls).
|
|
129
|
+
* Never fires spuriously — only on an actual geometry change — so it composes
|
|
130
|
+
* safely alongside an existing `'resize'` listener (whichever notices first wins,
|
|
131
|
+
* the other becomes a no-op once both routes converge on the same geometry).
|
|
132
|
+
* Returns a `stop()` to clear the interval; the timer is `unref()`d so it can never
|
|
133
|
+
* keep the process alive on its own. */
|
|
134
|
+
export function watchResize(onChange: (cols: number, rows: number) => void, intervalMs = 300): () => void {
|
|
135
|
+
let last = size();
|
|
136
|
+
const timer = setInterval(() => {
|
|
137
|
+
const live = liveWindowSize();
|
|
138
|
+
const cur = live ?? size();
|
|
139
|
+
if (cur.cols !== last.cols || cur.rows !== last.rows) {
|
|
140
|
+
last = cur;
|
|
141
|
+
if (live) {
|
|
142
|
+
try {
|
|
143
|
+
process.stdout.columns = live.cols;
|
|
144
|
+
process.stdout.rows = live.rows;
|
|
145
|
+
} catch { /* non-writable in this environment — onChange still fires below */ }
|
|
146
|
+
}
|
|
147
|
+
onChange(cur.cols, cur.rows);
|
|
148
|
+
}
|
|
149
|
+
}, intervalMs);
|
|
150
|
+
if (typeof (timer as unknown as { unref?: () => void }).unref === "function") {
|
|
151
|
+
(timer as unknown as { unref: () => void }).unref();
|
|
152
|
+
}
|
|
153
|
+
return () => clearInterval(timer);
|
|
154
|
+
}
|
|
155
|
+
|
|
87
156
|
|
|
88
157
|
export function isTTY(): boolean {
|
|
89
158
|
return !!process.stdout.isTTY;
|
|
@@ -137,3 +206,23 @@ export function enableKittyKeyboard(): string {
|
|
|
137
206
|
export function disableKittyKeyboard(): string {
|
|
138
207
|
return `${ESC}<u`;
|
|
139
208
|
}
|
|
209
|
+
/** Set the terminal's TEXT CURSOR color via OSC 12 (`\x1b]12;#rrggbb\x07`) — widely
|
|
210
|
+
* supported (xterm, iTerm2, kitty, wezterm, alacritty, ghostty, foot, most tmux/VTE
|
|
211
|
+
* terminals). jeo's input box parks the REAL terminal cursor at the caret (see
|
|
212
|
+
* `input-box.ts`'s `cursorRow`/`cursorCol`) rather than drawing a fake one, so the
|
|
213
|
+
* cursor's on-screen color is entirely up to the terminal's default — usually a
|
|
214
|
+
* solid white/light block. On every theme except `mono` (whose panels/prompt already
|
|
215
|
+
* render colorless), that default cursor color visually MATCHES the light card
|
|
216
|
+
* backgrounds used elsewhere in the UI (e.g. `cardFillPaint`), making the caret
|
|
217
|
+
* nearly impossible to spot against typed text. Setting it to the theme's accent hue
|
|
218
|
+
* gives the caret a color that's always distinct from body text and panel fills. */
|
|
219
|
+
export function setCursorColor(hex: string): string {
|
|
220
|
+
return `\x1b]12;${hex}\x07`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Restore the terminal's default cursor color (OSC 112, no color id) — the
|
|
224
|
+
* counterpart to {@link setCursorColor}, sent on exit so a shell/editor started
|
|
225
|
+
* afterward isn't left with jeo's accent-colored cursor. */
|
|
226
|
+
export function resetCursorColor(): string {
|
|
227
|
+
return `\x1b]112\x07`;
|
|
228
|
+
}
|
|
@@ -117,6 +117,21 @@ export function friendlyProviderError(err: unknown): string {
|
|
|
117
117
|
if (status === 401 || status === 403 || /\b40[13]\b/.test(msg)) {
|
|
118
118
|
return `${provider} rejected the credential (HTTP ${status ?? "401/403"}). Run 'jeo auth status', re-login with /provider login <name>, and for Antigravity prefer '/provider login antigravity' (gemini login only works when the Cloud Code Assist backend authorizes that token).`;
|
|
119
119
|
}
|
|
120
|
+
// A persistent 5xx (server-side fault on the provider's backend) — `ProviderHttpError`
|
|
121
|
+
// already embeds the numeric status directly in its message ("… failed (HTTP 500) …",
|
|
122
|
+
// caught here too, redundantly but harmlessly), but `ProviderStreamError` (the in-band
|
|
123
|
+
// SSE/stream error shape — antigravity.ts/gemini.ts's `google.rpc.Status` and
|
|
124
|
+
// openai-responses.ts's `response.failed`/`error` events) composes its `.message` from
|
|
125
|
+
// a STRING status code only ("Antigravity stream failed (INTERNAL): internal error"),
|
|
126
|
+
// never the numeric one, even though `.status` carries it correctly (explicitStatus
|
|
127
|
+
// for antigravity/gemini's google.rpc.Status `error.code`; the 500 default for
|
|
128
|
+
// openai-responses's non-rate_limit_exceeded codes). Without a digit anywhere in the
|
|
129
|
+
// text, launch.ts's `routeFailureReason` `/\b(?:500|502|503|504|529)\b/` text regex
|
|
130
|
+
// never matched, so the equivalent-pool fallback never fired for a stream-shaped 5xx
|
|
131
|
+
// even with a credentialed same-tier fallback sitting right there.
|
|
132
|
+
if (status !== undefined && status >= 500 && status < 600) {
|
|
133
|
+
return `${provider} hit a persistent server-side error (HTTP ${status}) — ${msg}. The provider's backend itself is failing; switch model with /model or wait and retry.`;
|
|
134
|
+
}
|
|
120
135
|
if ((err instanceof Error && err.name === "TimeoutError") || /the operation timed out/i.test(msg)) {
|
|
121
136
|
return `${provider} did not complete the request within the call timeout (default 30min). This is expected for a HIGH/XHIGH-reasoning-effort completion that legitimately runs long on a hard problem — raise JEO_CALL_TIMEOUT_MS (ms) if this recurs, or lower the thinking level with /model.`;
|
|
122
137
|
}
|
package/src/util/retry.ts
CHANGED
|
@@ -5,8 +5,16 @@ export interface RetryOptions {
|
|
|
5
5
|
isRetryable?: (err: unknown, attempt: number) => boolean;
|
|
6
6
|
sleep?: (ms: number) => Promise<void>; // injectable for tests (default real setTimeout)
|
|
7
7
|
random?: () => number; // injectable RNG for jitter (default Math.random); equal-jitter in [0.5x, 1x]
|
|
8
|
-
/** Notified before each backoff wait; `delayMs` is the wait actually applied.
|
|
9
|
-
|
|
8
|
+
/** Notified before each backoff wait; `delayMs` is the wait actually applied.
|
|
9
|
+
* Returning `false` (explicitly) aborts the retry ladder immediately — the
|
|
10
|
+
* pending error is thrown right away instead of sleeping and re-attempting.
|
|
11
|
+
* Lets a caller with a same-tier FALLBACK model available bail out of a
|
|
12
|
+
* same-model rate-limit retry loop early rather than riding the full
|
|
13
|
+
* `rateLimitRetries` budget (up to ~90s of escalating backoff) before the
|
|
14
|
+
* turn-level equivalent-pool fallback ever gets a chance to switch models.
|
|
15
|
+
* Any other return value (including `undefined`, the common case) continues
|
|
16
|
+
* the retry ladder unchanged. */
|
|
17
|
+
onRetry?: (attempt: number, err: unknown, delayMs: number) => void | false;
|
|
10
18
|
/** Aborts an in-progress backoff wait (e.g. Ctrl-C / turn cancel). Already-aborted or
|
|
11
19
|
* aborted-mid-wait rejects immediately instead of completing the sleep — the escape hatch
|
|
12
20
|
* for a long, honored provider `Retry-After` (see `rateLimitMaxServerDelayMs`: those are no
|
|
@@ -200,8 +208,11 @@ export async function withRetry<T>(fn: () => Promise<T>, opts?: RetryOptions): P
|
|
|
200
208
|
: 0;
|
|
201
209
|
const delay = Math.max(base, floor);
|
|
202
210
|
|
|
203
|
-
|
|
204
|
-
|
|
211
|
+
// A `false` return from onRetry means the caller wants to bail NOW instead of
|
|
212
|
+
// sleeping and re-attempting (see RetryOptions.onRetry's doc comment) — throw
|
|
213
|
+
// the pending error immediately rather than entering the backoff wait.
|
|
214
|
+
if (onRetry && onRetry(attempt, err, delay) === false) {
|
|
215
|
+
throw err;
|
|
205
216
|
}
|
|
206
217
|
|
|
207
218
|
await abortableSleep(sleep, delay, signal);
|