jeo-code 0.6.3 → 0.6.5
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 +31 -0
- package/README.ja.md +6 -2
- package/README.ko.md +6 -2
- package/README.md +6 -2
- package/README.zh.md +6 -2
- package/package.json +1 -1
- package/src/agent/engine.ts +82 -26
- package/src/agent/goal-verifier.ts +115 -0
- package/src/agent/model-recency.ts +1 -1
- package/src/agent/tools.ts +77 -17
- package/src/commands/launch/flags.ts +282 -0
- package/src/commands/launch/input.ts +330 -0
- package/src/commands/launch/stream.ts +102 -0
- package/src/commands/launch/tmux.ts +227 -0
- package/src/commands/launch/workflow.ts +26 -0
- package/src/commands/launch.ts +373 -1101
- package/src/tui/app.ts +87 -25
- package/src/tui/components/autocomplete.ts +6 -4
- package/src/tui/components/config-panel.ts +2 -2
- package/src/tui/components/markdown-text.ts +19 -5
- package/src/tui/components/slash.ts +25 -2
- package/src/tui/components/welcome.ts +17 -5
- package/src/tui/renderer.ts +6 -2
- package/src/tui/terminal.ts +7 -0
package/src/commands/launch.ts
CHANGED
|
@@ -19,8 +19,9 @@ import { formatForgeBox } from "../tui/components/forge";
|
|
|
19
19
|
import { interactiveOAuthLogin } from "./auth";
|
|
20
20
|
import { logoutOAuth } from "../auth";
|
|
21
21
|
import type { AuthProvider } from "../auth";
|
|
22
|
-
import { matchSlash, isSlashAttempt, formatSlashCommandList, formatSlashPreview, slashPreviewMatches, activeTriggerToken, tabCompleteSelection, type SlashCommandInfo } from "../tui/components/slash";
|
|
22
|
+
import { matchSlash, isSlashAttempt, suggestSlashCommands, formatSlashCommandList, formatSlashPreview, slashPreviewMatches, activeTriggerToken, tabCompleteSelection, type SlashCommandInfo } from "../tui/components/slash";
|
|
23
23
|
import { staticCompletionContext, readlineCompleter, formatCompletionPreview, tokenize, type CompletionContext } from "../tui/components/autocomplete";
|
|
24
|
+
import { normalizeBaseUrl } from "./setup-helpers";
|
|
24
25
|
import { EVOLUTION_STAGES, animateAsciiArt } from "../tui/components/ascii-art";
|
|
25
26
|
import { getEvolutionTip } from "../tui/components/evolution";
|
|
26
27
|
import { renderWelcome, playWelcomeSweep } from "../tui/components/welcome";
|
|
@@ -37,6 +38,7 @@ import { readGlobalConfig, saveConfigPatch } from "../agent/state";
|
|
|
37
38
|
import { rememberModelPatch, recentModelsForDisplay } from "../agent/model-recency";
|
|
38
39
|
import { describeModel, describeAllProviders, thinkingMaxTokens, discoverModels, flattenModels, resolveSelection, catalogMetadata, resolveRoleModel, CODEX_MODELS, qualifyModelId } from "../ai";
|
|
39
40
|
import type { ProviderModelsResult, PickEntry, ProviderName, ModelRole, ThinkLevel } from "../ai";
|
|
41
|
+
import { readGoalState, writeGoalState, clearGoalState, verifyGoal } from "../agent/goal-verifier";
|
|
40
42
|
|
|
41
43
|
import { listAliases } from "../ai/model-registry";
|
|
42
44
|
|
|
@@ -45,7 +47,6 @@ import { SelectList, renderSelectList, type SelectItem } from "../tui/components
|
|
|
45
47
|
import {
|
|
46
48
|
formatModelLine,
|
|
47
49
|
formatProviderPanel,
|
|
48
|
-
emitLoginCleanup,
|
|
49
50
|
formatAgentsPanel,
|
|
50
51
|
formatAgentDetail,
|
|
51
52
|
formatConfigPanel,
|
|
@@ -56,12 +57,12 @@ import {
|
|
|
56
57
|
} from "../tui/components/config-panel";
|
|
57
58
|
import { liveModelPicker, renderLiveModelPicker, type ModelAssignmentBadge } from "../tui/components/live-model-picker";
|
|
58
59
|
|
|
59
|
-
import { providerPicker, renderProviderPicker
|
|
60
|
+
import { providerPicker, renderProviderPicker } from "../tui/components/provider-picker";
|
|
60
61
|
import { detectLanguage, languageLabel, parseLineRange, sliceLines, formatCodeBlock, formatDiff, sanitizeForTerminal } from "../tui/components/code-view";
|
|
61
62
|
import { categoryBadge } from "../tui/components/category-index";
|
|
62
63
|
import { renderInputFrame } from "../tui/components/input-box";
|
|
63
64
|
import { renderStatusBar } from "../tui/components/status";
|
|
64
|
-
import { detectColorLevel, ColorLevel } from "../tui/components/color";
|
|
65
|
+
import { detectColorLevel, ColorLevel, visibleWidth } from "../tui/components/color";
|
|
65
66
|
import { readClipboardImage } from "../util/clipboard-image";
|
|
66
67
|
import { formatTranscript } from "../tui/components/transcript";
|
|
67
68
|
import { loadInputHistory, appendInputHistory } from "../agent/input-history";
|
|
@@ -91,438 +92,100 @@ import {
|
|
|
91
92
|
sessionPath,
|
|
92
93
|
appendCompaction,
|
|
93
94
|
} from "../agent/session";
|
|
94
|
-
import { clearLine, cursorUp, toColumn, truncate as truncateAnsi, size as terminalSize, resetMouseTracking } from "../tui/terminal";
|
|
95
|
+
import { clearLine, cursorUp, toColumn, truncate as truncateAnsi, size as terminalSize, resetMouseTracking, clearScreen, clearToEnd } from "../tui/terminal";
|
|
95
96
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
* fully independent, so a second `jeo --tmux` never attaches to (and mirrors) the first.
|
|
189
|
-
*/
|
|
190
|
-
export function tmuxSessionName(cwd: string, branch: string, flags: LaunchFlags): string {
|
|
191
|
-
const dirTag = `${tmuxSafeNamePart(path.basename(cwd) || "root", 16)}-${hashString(cwd)}`;
|
|
192
|
-
const base = branch ? `jeo-${branch}-${dirTag}` : `jeo-${dirTag}`;
|
|
193
|
-
return base + tmuxRuntimeSuffix(flags);
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* Count uncommitted git entries for the `⑂ <branch> ?N` footer dirty flag (gjc parity).
|
|
198
|
-
* One `git status --porcelain` spawn per CALL; callers invoke it once per turn start, not
|
|
199
|
-
* per render. Returns undefined when not a repo / git absent / clean.
|
|
200
|
-
*/
|
|
201
|
-
export function gitDirtyCount(cwd: string): number | undefined {
|
|
202
|
-
try {
|
|
203
|
-
const res = Bun.spawnSync(["git", "status", "--porcelain"], { cwd, stdout: "pipe", stderr: "ignore" });
|
|
204
|
-
if (res.exitCode !== 0) return undefined;
|
|
205
|
-
const n = res.stdout.toString().split("\n").filter(l => l.trim().length > 0).length;
|
|
206
|
-
return n || undefined;
|
|
207
|
-
} catch {
|
|
208
|
-
return undefined;
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/**
|
|
213
|
-
* Allocate + create an INDEPENDENT tmux session from a base name. Each separate,
|
|
214
|
-
* concurrent `jeo --tmux` invocation gets its OWN session instead of attaching to (and
|
|
215
|
-
* mirroring) one another process already created: try `base`, then `base-2`, `base-3`, …
|
|
216
|
-
* The create itself is the guard, so this is race-safe — two processes starting at the
|
|
217
|
-
* same instant can't both win `base`. `tryCreate` must attempt to create the named session
|
|
218
|
-
* and return `"ok"` (created — it's ours), `"taken"` (name already live / lost the race →
|
|
219
|
-
* try the next suffix), or `"error:<msg>"` (a real failure → abort). Sessions die with
|
|
220
|
-
* their jeo process, so a sequential re-run reuses the clean base; only live overlap is
|
|
221
|
-
* suffixed.
|
|
222
|
-
*/
|
|
223
|
-
export type TmuxCreateResult = "ok" | "taken" | `error:${string}`;
|
|
224
|
-
export function allocateTmuxSession(
|
|
225
|
-
base: string,
|
|
226
|
-
tryCreate: (name: string) => TmuxCreateResult,
|
|
227
|
-
): { name: string } | { error: string } {
|
|
228
|
-
for (let n = 1; n <= 1000; n++) {
|
|
229
|
-
const candidate = n === 1 ? base : `${base}-${n}`;
|
|
230
|
-
const result = tryCreate(candidate);
|
|
231
|
-
if (result === "ok") return { name: candidate };
|
|
232
|
-
if (result === "taken") continue;
|
|
233
|
-
return { error: result.slice("error:".length) };
|
|
234
|
-
}
|
|
235
|
-
return { error: `could not allocate a free tmux session name for ${base} (1000 already live?)` };
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
function shellQuote(arg: string): string {
|
|
239
|
-
return `'${arg.replace(/'/g, `'\\''`)}'`;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* True when `jeo --tmux` runs INSIDE an existing tmux session and should enable
|
|
244
|
-
* session-scoped mouse mode for the CURRENT session: no jeo-owned session is created
|
|
245
|
-
* on this path, so without `mouse on` tmux ignores the wheel entirely and the
|
|
246
|
-
* mid-turn scrollback (ledger lines flushed above the live frame) is unreachable.
|
|
247
|
-
* Skipped for jeo-spawned sessions (JEO_TMUX_LAUNCHED=1 — the creator already set
|
|
248
|
-
* it) and when JEO_TMUX_MOUSE=0 opts out.
|
|
249
|
-
*/
|
|
250
|
-
export function shouldEnableCurrentTmuxMouse(env: Record<string, string | undefined>): boolean {
|
|
251
|
-
return !!env.TMUX
|
|
252
|
-
&& (env.JEO_TMUX_LAUNCHED ?? env.JEO_TMUX_LAUNCHED) !== "1"
|
|
253
|
-
&& (env.JEO_TMUX_MOUSE ?? env.JEO_TMUX_MOUSE) !== "0";
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/**
|
|
257
|
-
* The runnable command for the INNER `jeo launch` a `--tmux` session executes.
|
|
258
|
-
* Pure — testable. Three runtime shapes:
|
|
259
|
-
* - compiled standalone binary: `argv[1]` is a Bun VIRTUAL path (`/$bunfs/…`)
|
|
260
|
-
* that does not exist on disk; the binary itself (`execPath`) is the
|
|
261
|
-
* entrypoint. Passing the virtual path made the inner command crash on
|
|
262
|
-
* spawn, so `tmux new-session` died instantly and the follow-up attach
|
|
263
|
-
* failed with "can't find session".
|
|
264
|
-
* - source run (`bun src/cli.ts`): re-run the script through the runtime.
|
|
265
|
-
* - anything else (a shim/binary path on disk): run it directly.
|
|
266
|
-
*/
|
|
267
|
-
export function tmuxLaunchCommand(argv1: string | undefined, execPath: string, cwd: string): string[] {
|
|
268
|
-
const entrypoint = argv1 ?? "";
|
|
269
|
-
if (entrypoint === "" || entrypoint.startsWith("/$bunfs/") || entrypoint.startsWith("B:\\~BUN\\")) {
|
|
270
|
-
return [execPath];
|
|
271
|
-
}
|
|
272
|
-
const resolved = path.isAbsolute(entrypoint) ? entrypoint : path.resolve(cwd, entrypoint);
|
|
273
|
-
if (/\.(ts|js|mjs)$/.test(entrypoint)) return [execPath, resolved];
|
|
274
|
-
return [resolved];
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
/** One tmux configuration step applied to a jeo-owned session after creation. */
|
|
278
|
-
export interface TmuxProfileCommand {
|
|
279
|
-
description: string;
|
|
280
|
-
args: string[];
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
/**
|
|
284
|
-
* gjc-parity tmux profile for jeo-OWNED sessions (mirrors gjc's
|
|
285
|
-
* `buildGjcTmuxProfileCommands`). Applied right after `new-session`, before attach:
|
|
286
|
-
* - `mouse on` (session-scoped): wheel-up enters copy-mode over the REAL pane
|
|
287
|
-
* history — this is what makes the mid-turn scrollback (ledger lines flushed
|
|
288
|
-
* above the inline live frame) reachable with the mouse wheel. Wheel-down at
|
|
289
|
-
* the bottom drops back out. `JEO_TMUX_MOUSE=0` opts out.
|
|
290
|
-
* - ownership/identity markers (`@jeo-profile`, `@jeo-branch`, `@jeo-project`):
|
|
291
|
-
* lets tooling tell jeo-owned sessions apart from user sessions (gjc parity
|
|
292
|
-
* with `@gjc-*`). Never applied to foreign sessions.
|
|
293
|
-
* - `set-clipboard on` + a readable copy-mode `mode-style`: text selected while
|
|
294
|
-
* wheel-scrolled back is visibly highlighted and lands on the system clipboard
|
|
295
|
-
* (OSC52). `JEO_TMUX_PROFILE=0` opts out of these cosmetic extras while keeping
|
|
296
|
-
* mouse + markers.
|
|
297
|
-
*/
|
|
298
|
-
export function tmuxProfileCommands(
|
|
299
|
-
target: string,
|
|
300
|
-
env: Record<string, string | undefined>,
|
|
301
|
-
meta: { branch?: string; project?: string } = {},
|
|
302
|
-
): TmuxProfileCommand[] {
|
|
303
|
-
// Exact-name session target. `=name:` (explicit session:window form), NOT bare
|
|
304
|
-
// `=name`: tmux 3.6 rejects bare `=name` for set-option/show-options with
|
|
305
|
-
// "no such session" even while the session is live (has-session/attach accept
|
|
306
|
-
// it). The trailing colon makes cmd-find parse `=name` as the session part —
|
|
307
|
-
// exact-matched, never prefix-matched — and resolves the session's current
|
|
308
|
-
// window for set-window-option. This was the silent failure that left
|
|
309
|
-
// `mouse on` unset, killing wheel-up scrollback in jeo-owned sessions.
|
|
310
|
-
const t = `=${target}:`;
|
|
311
|
-
const commands: TmuxProfileCommand[] = [];
|
|
312
|
-
if ((env.JEO_TMUX_MOUSE ?? env.JEO_TMUX_MOUSE) !== "0") {
|
|
313
|
-
commands.push({
|
|
314
|
-
description: "enable tmux mouse scrolling (wheel-up → copy-mode over real history)",
|
|
315
|
-
args: ["set-option", "-t", t, "mouse", "on"],
|
|
316
|
-
});
|
|
317
|
-
}
|
|
318
|
-
commands.push({
|
|
319
|
-
description: "mark jeo tmux ownership",
|
|
320
|
-
args: ["set-option", "-t", t, "@jeo-profile", "1"],
|
|
321
|
-
});
|
|
322
|
-
if (meta.branch) {
|
|
323
|
-
commands.push({
|
|
324
|
-
description: "record jeo branch identity",
|
|
325
|
-
args: ["set-option", "-t", t, "@jeo-branch", meta.branch],
|
|
326
|
-
});
|
|
327
|
-
}
|
|
328
|
-
if (meta.project) {
|
|
329
|
-
commands.push({
|
|
330
|
-
description: "record jeo project identity",
|
|
331
|
-
args: ["set-option", "-t", t, "@jeo-project", meta.project],
|
|
332
|
-
});
|
|
333
|
-
}
|
|
334
|
-
if ((env.JEO_TMUX_PROFILE ?? env.JEO_TMUX_PROFILE) !== "0") {
|
|
335
|
-
commands.push(
|
|
336
|
-
{
|
|
337
|
-
description: "enable tmux clipboard integration",
|
|
338
|
-
args: ["set-option", "-t", t, "set-clipboard", "on"],
|
|
339
|
-
},
|
|
340
|
-
{
|
|
341
|
-
description: "make copy-mode selection readable",
|
|
342
|
-
args: ["set-window-option", "-t", t, "mode-style", "fg=colour231,bg=colour60"],
|
|
343
|
-
},
|
|
344
|
-
);
|
|
345
|
-
}
|
|
346
|
-
return commands;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
/**
|
|
350
|
-
* A `process.stdout` view whose visible-output methods become no-ops while `gated()` is
|
|
351
|
-
* true. Used as readline's `output` so that, while the boxed slash-preview footer is armed,
|
|
352
|
-
* readline's OWN prompt/echo is suppressed and only our box is visible — no duplicated raw
|
|
353
|
-
* `jeo>` line. The previous approach monkeypatched `rl._writeToOutput`, a Node internal Bun
|
|
354
|
-
* does not expose (so on Bun both inputs showed at once). Gating the shared `output` stream
|
|
355
|
-
* works on both runtimes. Our footer is written straight to `process.stdout`, never through
|
|
356
|
-
* this proxy, so it always renders. Geometry/everything else is forwarded unchanged.
|
|
357
|
-
*/
|
|
358
|
-
const GATED_OUTPUT_METHODS = new Set(["write", "cursorTo", "moveCursor", "clearLine", "clearScreenDown", "_write", "_writev"]);
|
|
359
|
-
export function gatedStdout(real: NodeJS.WriteStream, gated: () => boolean): NodeJS.WriteStream {
|
|
360
|
-
return new Proxy(real, {
|
|
361
|
-
get(target, prop, _receiver) {
|
|
362
|
-
if (typeof prop === "string" && GATED_OUTPUT_METHODS.has(prop)) {
|
|
363
|
-
return (...args: any[]) => {
|
|
364
|
-
if (gated()) {
|
|
365
|
-
const cb = args[args.length - 1]; // honor readline's write callback so it never stalls
|
|
366
|
-
if (typeof cb === "function") cb();
|
|
367
|
-
return true;
|
|
368
|
-
}
|
|
369
|
-
return (target as any)[prop](...args);
|
|
370
|
-
};
|
|
371
|
-
}
|
|
372
|
-
const value = Reflect.get(target, prop, target);
|
|
373
|
-
return typeof value === "function" ? value.bind(target) : value;
|
|
374
|
-
},
|
|
375
|
-
}) as unknown as NodeJS.WriteStream;
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
function firstOutputLine(output: string | undefined): string {
|
|
379
|
-
if (!output) return "";
|
|
380
|
-
const line = String(output)
|
|
381
|
-
.split("\n")
|
|
382
|
-
.map(l => l.trim())
|
|
383
|
-
.find(l => l.length > 0);
|
|
384
|
-
return line ? line.replace(/\s+/g, " ").slice(0, 140) : "";
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
function streamResultSuffix(tool: string, ok: boolean, output: string | undefined): string {
|
|
388
|
-
const summary = firstOutputLine(output);
|
|
389
|
-
if (!summary) return "";
|
|
390
|
-
if (!ok || tool === "task") return ` — ${summary}`;
|
|
391
|
-
return "";
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
export function formatTaskSubEvent(e: TaskSubEvent): string {
|
|
395
|
-
const role = e.role || "subagent";
|
|
396
|
-
const roleLabel = e.index && e.total ? `${role.toUpperCase()}[${e.index}/${e.total}]` : role.toUpperCase();
|
|
397
|
-
const tokTag = e.tokens ? ` (${e.tokens.input + e.tokens.output} tok)` : "";
|
|
398
|
-
const detail = firstOutputLine(e.detail);
|
|
399
|
-
const summary = e.summary ? ` — ${e.summary}` : "";
|
|
400
|
-
// No ` step N/M` marker — step counters carry no meaning under the dynamic
|
|
401
|
-
// budget (user feedback); a tree prefix makes nested subagent activity scan as
|
|
402
|
-
// one readable branch in plain logs and TUI scrollback.
|
|
403
|
-
const badge = categoryBadge("subagent");
|
|
404
|
-
if (e.kind === "start") return `${badge} ${chalk.magenta(`▸ ${roleLabel}`)} · ${detail}`.slice(0, 240);
|
|
405
|
-
if (e.kind === "step") return ` ${badge} ${chalk.cyan(`├─ ${roleLabel}`)} · ${detail || "working"}`;
|
|
406
|
-
if (e.kind === "tool") return ` ${badge} ${e.success === false ? chalk.red("├─") : chalk.green("├─")} ${roleLabel} ${e.success === false ? chalk.red("✗") : chalk.green("✓")} ${detail || "tool"}${summary}`;
|
|
407
|
-
if (e.kind === "error") return ` ${badge} ${chalk.red("├─")} ${roleLabel} ${chalk.red("✗")} ${detail || "error"}`;
|
|
408
|
-
return `${badge} ${e.success === false ? chalk.red("└─") : chalk.green("└─")} ${roleLabel} done${tokTag}${e.success === false ? " (incomplete)" : ""}${detail ? `: ${detail}` : ""}`;
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
function logTaskSubEvent(e: TaskSubEvent, log: (line: string) => void = (s: string) => console.log(s)): void {
|
|
412
|
-
log(formatTaskSubEvent(e));
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
/**
|
|
417
|
-
* Plain (non-TTY / `--no-tui`) progress sink — the cmd-mode equivalent of the live TUI, and
|
|
418
|
-
* the gjc-parity fix for "I typed a request but saw no steps/results". The old sink only
|
|
419
|
-
* logged tool RESULTS, so a turn that finished without a tool call (or before the first
|
|
420
|
-
* result) printed nothing but the final reply. This surfaces every STEP, the tool it is about
|
|
421
|
-
* to run (with the real file/command target via `summarizeForgeInvocation`), and each result —
|
|
422
|
-
* tracking the current step + pending invocation across the engine's
|
|
423
|
-
* onStep → onAssistant → onToolResult sequence.
|
|
424
|
-
*/
|
|
425
|
-
export function createStreamEvents(
|
|
426
|
-
_maxSteps: number,
|
|
427
|
-
log: (line: string) => void = (s: string) => console.log(s),
|
|
428
|
-
now: () => number = Date.now,
|
|
429
|
-
): AgentLoopEvents {
|
|
430
|
-
let pending = "";
|
|
431
|
-
let latestUsage: { inputTokens: number; outputTokens: number } | undefined;
|
|
432
|
-
const startTime = now();
|
|
433
|
-
|
|
434
|
-
return {
|
|
435
|
-
// Lazy header: nothing is printed until the tool call is known (onAssistant).
|
|
436
|
-
// A `done` / invalid reply therefore emits no progress line at all. The step
|
|
437
|
-
// NUMBER itself is never shown — meaningless under the dynamic budget.
|
|
438
|
-
onStep: () => {},
|
|
439
|
-
onAssistant: (_raw: string, invocation: { tool?: string; arguments?: unknown } | null) => {
|
|
440
|
-
const tool = typeof invocation?.tool === "string" ? invocation.tool.trim() : "";
|
|
441
|
-
if (!tool || tool === "done") return;
|
|
442
|
-
pending = summarizeForgeInvocation(tool, invocation?.arguments).title;
|
|
443
|
-
// gjc-style live status unit: step header + tool target + elapsed + token usage.
|
|
444
|
-
const elapsedMs = now() - startTime;
|
|
445
|
-
let suffix = "";
|
|
446
|
-
if (elapsedMs >= 1000) suffix += ` · ${formatDuration(elapsedMs)}`;
|
|
447
|
-
if (latestUsage) suffix += ` · ${formatUsage(latestUsage)}`;
|
|
448
|
-
log(`${categoryBadge("progress")} ${pending}${suffix ? chalk.dim(suffix) : ""}`);
|
|
449
|
-
},
|
|
450
|
-
onToolResult: (tool: string, ok: boolean, output?: string) => {
|
|
451
|
-
const label = pending || tool;
|
|
452
|
-
const mark = ok ? chalk.green("✓") : chalk.red("✗");
|
|
453
|
-
log(` ${categoryBadge(ok ? "done" : "error")} ${mark} ${label}${streamResultSuffix(tool, ok, output)}`);
|
|
454
|
-
pending = "";
|
|
455
|
-
},
|
|
456
|
-
onNotice: (msg: string) => log(` ${categoryBadge("progress")} ${chalk.yellow(msg)}`),
|
|
457
|
-
onBudget: (_limit: number, reason: string) => {
|
|
458
|
-
log(` ${categoryBadge("progress")} ${chalk.yellow(reason)}`);
|
|
459
|
-
},
|
|
460
|
-
onUsage: (usage: { inputTokens: number; outputTokens: number }) => {
|
|
461
|
-
latestUsage = usage;
|
|
462
|
-
},
|
|
463
|
-
};
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
export function shouldUseOneShotTui(noTui: boolean): boolean {
|
|
467
|
-
return LaunchTui.usable(noTui);
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
export interface InFlightAbortHarness {
|
|
471
|
-
controller: AbortController;
|
|
472
|
-
handleSigint(): void;
|
|
473
|
-
handleData(chunk: string | Uint8Array): void;
|
|
474
|
-
dispose(): void;
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
interface AbortHarnessOptions {
|
|
478
|
-
controller?: AbortController;
|
|
479
|
-
captureEsc?: boolean;
|
|
480
|
-
stdin?: {
|
|
481
|
-
isTTY?: boolean;
|
|
482
|
-
isRaw?: boolean;
|
|
483
|
-
setRawMode?(raw: boolean): void;
|
|
484
|
-
resume?(): void;
|
|
485
|
-
on(event: "data", listener: (chunk: string | Uint8Array) => void): unknown;
|
|
486
|
-
off(event: "data", listener: (chunk: string | Uint8Array) => void): unknown;
|
|
487
|
-
};
|
|
488
|
-
onAbortNotice?: (message: string) => void;
|
|
489
|
-
onHardExit?: () => void;
|
|
490
|
-
/** Invoked when stray escape-sequence noise (wheel scroll etc.) arrives mid-turn. */
|
|
491
|
-
onNoise?: () => void;
|
|
492
|
-
/** Invoked when Ctrl+O (\u000f) is pressed mid-turn — the detail-view binding.
|
|
493
|
-
* Without this hook the byte would be swallowed into the buffered input queue,
|
|
494
|
-
* which is why Ctrl+O historically "did nothing" while the TUI owned stdin. */
|
|
495
|
-
onDetailKey?: () => void;
|
|
496
|
-
/** Invoked when an arrow / PageUp / PageDown key arrives mid-turn — scrolls the
|
|
497
|
-
* open Ctrl+O detail panel. dir -1 = up/back, +1 = down/forward; page = full jump. */
|
|
498
|
-
onScrollKey?: (dir: -1 | 1, page: boolean) => void;
|
|
499
|
-
/** Invoked with printable keyboard input received while the live turn owns stdin. */
|
|
500
|
-
onBufferedInput?: (chunk: string) => void;
|
|
501
|
-
/** True while the input queue is inside a bracketed paste (mid-paste chunks
|
|
502
|
-
* carry no marker and must keep routing to the queue, not the noise path). */
|
|
503
|
-
pasteActive?: () => boolean;
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
/** Bracketed-paste markers (DECSET 2004): terminals wrap pasted text in these so
|
|
507
|
-
* an app can treat the paste as DATA instead of keystrokes — the prompt_toolkit
|
|
508
|
-
* paste contract. jeo enables the mode for the REPL TTY so a multi-line paste
|
|
509
|
-
* arrives atomically and executes one command per line, in order. */
|
|
510
|
-
export const PASTE_START = "\u001b[200~";
|
|
511
|
-
export const PASTE_END = "\u001b[201~";
|
|
512
|
-
|
|
513
|
-
/** True when a stdin chunk is ONLY backspace bytes (DEL 0x7f or BS 0x08) — i.e. a
|
|
514
|
-
* standalone Backspace keystroke with nothing else. A backspace on an EMPTY input
|
|
515
|
-
* line is a no-op edit, but some Bun readline builds turn it into a spurious `close`
|
|
516
|
-
* event, which the REPL would treat as a hard exit ("Backspace quits jeo"). The
|
|
517
|
-
* input filter swallows these when the line buffer is already empty so the byte never
|
|
518
|
-
* reaches readline and the close can't fire. */
|
|
519
|
-
export function isStandaloneBackspace(chunk: string): boolean {
|
|
520
|
-
return chunk.length > 0 && /^[\x7f\b]+$/.test(chunk);
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
/** gjc-parity slash-command aliases, applied once before dispatch so each real command
|
|
524
|
-
* keeps a SINGLE handler: `/login`→`/provider login`, `/settings`→`/config`,
|
|
525
|
-
* `/subagent(s)`→`/agents`. Pure rewrite that preserves any trailing args. */
|
|
97
|
+
import {
|
|
98
|
+
type LaunchFlags,
|
|
99
|
+
parseFlags,
|
|
100
|
+
matchSkillGlob,
|
|
101
|
+
filterToolMap,
|
|
102
|
+
buildToolProtocol,
|
|
103
|
+
TOOL_DESCRIPTIONS,
|
|
104
|
+
fastThinkingLevelForModel,
|
|
105
|
+
isProviderName,
|
|
106
|
+
isThinkingLevel,
|
|
107
|
+
} from "./launch/flags";
|
|
108
|
+
import {
|
|
109
|
+
tmuxSessionName,
|
|
110
|
+
gitDirtyCount,
|
|
111
|
+
allocateTmuxSession,
|
|
112
|
+
shouldEnableCurrentTmuxMouse,
|
|
113
|
+
tmuxLaunchCommand,
|
|
114
|
+
tmuxProfileCommands,
|
|
115
|
+
resolveWorktree,
|
|
116
|
+
shellQuote,
|
|
117
|
+
type TmuxCreateResult,
|
|
118
|
+
type TmuxProfileCommand,
|
|
119
|
+
} from "./launch/tmux";
|
|
120
|
+
import {
|
|
121
|
+
type InFlightAbortHarness,
|
|
122
|
+
type PromptInputQueue,
|
|
123
|
+
PASTE_START,
|
|
124
|
+
PASTE_END,
|
|
125
|
+
isStandaloneBackspace,
|
|
126
|
+
CURSOR_COMBO_REWRITES,
|
|
127
|
+
matchCursorCombo,
|
|
128
|
+
rewriteCursorCombos,
|
|
129
|
+
queuePromptInputChunk,
|
|
130
|
+
captureLivePromptInputChunk,
|
|
131
|
+
restoreQueuedLinesToPrefill,
|
|
132
|
+
createInFlightAbortHarness,
|
|
133
|
+
} from "./launch/input";
|
|
134
|
+
import {
|
|
135
|
+
gatedStdout,
|
|
136
|
+
formatTaskSubEvent,
|
|
137
|
+
logTaskSubEvent,
|
|
138
|
+
createStreamEvents,
|
|
139
|
+
shouldUseOneShotTui,
|
|
140
|
+
} from "./launch/stream";
|
|
141
|
+
import {
|
|
142
|
+
WORKFLOW_NAMES,
|
|
143
|
+
isWorkflowSkill,
|
|
144
|
+
runWorkflowEngine,
|
|
145
|
+
} from "./launch/workflow";
|
|
146
|
+
|
|
147
|
+
export {
|
|
148
|
+
type LaunchFlags,
|
|
149
|
+
parseFlags,
|
|
150
|
+
matchSkillGlob,
|
|
151
|
+
filterToolMap,
|
|
152
|
+
buildToolProtocol,
|
|
153
|
+
TOOL_DESCRIPTIONS,
|
|
154
|
+
fastThinkingLevelForModel,
|
|
155
|
+
isProviderName,
|
|
156
|
+
isThinkingLevel,
|
|
157
|
+
tmuxSessionName,
|
|
158
|
+
gitDirtyCount,
|
|
159
|
+
allocateTmuxSession,
|
|
160
|
+
shouldEnableCurrentTmuxMouse,
|
|
161
|
+
tmuxLaunchCommand,
|
|
162
|
+
tmuxProfileCommands,
|
|
163
|
+
resolveWorktree,
|
|
164
|
+
shellQuote,
|
|
165
|
+
type TmuxCreateResult,
|
|
166
|
+
type TmuxProfileCommand,
|
|
167
|
+
type InFlightAbortHarness,
|
|
168
|
+
type PromptInputQueue,
|
|
169
|
+
PASTE_START,
|
|
170
|
+
PASTE_END,
|
|
171
|
+
isStandaloneBackspace,
|
|
172
|
+
CURSOR_COMBO_REWRITES,
|
|
173
|
+
matchCursorCombo,
|
|
174
|
+
rewriteCursorCombos,
|
|
175
|
+
queuePromptInputChunk,
|
|
176
|
+
captureLivePromptInputChunk,
|
|
177
|
+
restoreQueuedLinesToPrefill,
|
|
178
|
+
createInFlightAbortHarness,
|
|
179
|
+
|
|
180
|
+
gatedStdout,
|
|
181
|
+
formatTaskSubEvent,
|
|
182
|
+
logTaskSubEvent,
|
|
183
|
+
createStreamEvents,
|
|
184
|
+
shouldUseOneShotTui,
|
|
185
|
+
WORKFLOW_NAMES,
|
|
186
|
+
isWorkflowSkill,
|
|
187
|
+
runWorkflowEngine,
|
|
188
|
+
};
|
|
526
189
|
export function normalizeSlashAlias(input: string): string {
|
|
527
190
|
if (input === "/login" || input.startsWith("/login ")) return `/provider login${input.slice("/login".length)}`;
|
|
528
191
|
if (input === "/settings") return "/config";
|
|
@@ -532,539 +195,12 @@ export function normalizeSlashAlias(input: string): string {
|
|
|
532
195
|
return input;
|
|
533
196
|
}
|
|
534
197
|
|
|
535
|
-
|
|
536
|
-
pendingLines: string[];
|
|
537
|
-
partial: string;
|
|
538
|
-
/** Complete lines that arrived inside a bracketed PASTE: intentional batch
|
|
539
|
-
* commands, served one per prompt in order. Never folded into the typed-line
|
|
540
|
-
* prefill (that contract is for keystrokes typed during a live turn). */
|
|
541
|
-
pastedLines: string[];
|
|
542
|
-
/** True while a bracketed paste spans chunks (between \x1b[200~ and \x1b[201~). */
|
|
543
|
-
inPaste: boolean;
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
/** Typed (non-paste) keystrokes: printable chars build the partial, Enter promotes
|
|
547
|
-
* it to pendingLines, backspace edits — ESC/ctrl noise segments are rejected. */
|
|
548
|
-
function feedTypedSegment(state: PromptInputQueue, segment: string): boolean {
|
|
549
|
-
if (!segment || segment.includes("\u001b") || segment.includes("\u0003")) return false;
|
|
550
|
-
let accepted = false;
|
|
551
|
-
const normalized = segment.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
552
|
-
for (const ch of Array.from(normalized)) {
|
|
553
|
-
if (ch === "\n") {
|
|
554
|
-
if (state.partial.length > 0) accepted = true;
|
|
555
|
-
state.pendingLines.push(state.partial);
|
|
556
|
-
state.partial = "";
|
|
557
|
-
} else if (ch === "\u007f" || ch === "\b") {
|
|
558
|
-
const chars = Array.from(state.partial);
|
|
559
|
-
chars.pop();
|
|
560
|
-
state.partial = chars.join("");
|
|
561
|
-
accepted = true;
|
|
562
|
-
} else if (ch === "\t" || ch >= " ") {
|
|
563
|
-
state.partial += ch;
|
|
564
|
-
accepted = true;
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
return accepted;
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
/** Pasted body: pure DATA — newlines split commands into pastedLines, the trailing
|
|
571
|
-
* partial stays editable, and control bytes (incl. any stray ESC from copied ANSI
|
|
572
|
-
* text) are dropped instead of being interpreted as keystrokes. */
|
|
573
|
-
function feedPasteBody(state: PromptInputQueue, body: string): boolean {
|
|
574
|
-
if (!body) return false;
|
|
575
|
-
let accepted = false;
|
|
576
|
-
const normalized = body.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
577
|
-
for (const ch of Array.from(normalized)) {
|
|
578
|
-
if (ch === "\n") {
|
|
579
|
-
state.pastedLines.push(state.partial);
|
|
580
|
-
state.partial = "";
|
|
581
|
-
accepted = true;
|
|
582
|
-
} else if (ch === "\t" || ch >= " ") {
|
|
583
|
-
state.partial += ch;
|
|
584
|
-
accepted = true;
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
return accepted;
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
export function queuePromptInputChunk(state: PromptInputQueue, chunk: string): boolean {
|
|
591
|
-
if (!chunk) return false;
|
|
592
|
-
let accepted = false;
|
|
593
|
-
let rest = chunk;
|
|
594
|
-
while (rest.length > 0) {
|
|
595
|
-
if (state.inPaste) {
|
|
596
|
-
const end = rest.indexOf(PASTE_END);
|
|
597
|
-
const body = end === -1 ? rest : rest.slice(0, end);
|
|
598
|
-
if (end !== -1) state.inPaste = false;
|
|
599
|
-
rest = end === -1 ? "" : rest.slice(end + PASTE_END.length);
|
|
600
|
-
if (feedPasteBody(state, body)) accepted = true;
|
|
601
|
-
} else {
|
|
602
|
-
const start = rest.indexOf(PASTE_START);
|
|
603
|
-
const plain = start === -1 ? rest : rest.slice(0, start);
|
|
604
|
-
if (start !== -1) state.inPaste = true;
|
|
605
|
-
rest = start === -1 ? "" : rest.slice(start + PASTE_START.length);
|
|
606
|
-
if (feedTypedSegment(state, plain)) accepted = true;
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
return accepted;
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
/** Live-turn prompt capture: printable input edits the SAME next-prompt line the
|
|
613
|
-
* idle footer will show after the turn finishes. Enter does NOT promote a hidden
|
|
614
|
-
* queue entry; it merely marks the current line as ready, so the existing input
|
|
615
|
-
* box stays the single source of truth and the user presses Enter once more at
|
|
616
|
-
* the real prompt to run it. */
|
|
617
|
-
function feedLivePromptSegment(state: PromptInputQueue, segment: string): boolean {
|
|
618
|
-
if (!segment || segment.includes("\u001b") || segment.includes("\u0003")) return false;
|
|
619
|
-
let accepted = false;
|
|
620
|
-
const normalized = segment.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
621
|
-
for (const ch of Array.from(normalized)) {
|
|
622
|
-
if (ch === "\n") {
|
|
623
|
-
accepted = true;
|
|
624
|
-
} else if (ch === "\u007f" || ch === "\b") {
|
|
625
|
-
const chars = Array.from(state.partial);
|
|
626
|
-
chars.pop();
|
|
627
|
-
state.partial = chars.join("");
|
|
628
|
-
accepted = true;
|
|
629
|
-
} else if (ch === "\t" || ch >= " ") {
|
|
630
|
-
state.partial += ch;
|
|
631
|
-
accepted = true;
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
return accepted;
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
function feedLivePromptPasteBody(state: PromptInputQueue, body: string): boolean {
|
|
638
|
-
if (!body) return false;
|
|
639
|
-
const normalized = body.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
640
|
-
const flattened = normalized.split("\n").map(part => part.trim()).filter(Boolean).join(" ");
|
|
641
|
-
if (!flattened) return false;
|
|
642
|
-
state.partial = state.partial ? `${state.partial} ${flattened}` : flattened;
|
|
643
|
-
return true;
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
export function captureLivePromptInputChunk(state: PromptInputQueue, chunk: string): boolean {
|
|
647
|
-
if (!chunk) return false;
|
|
648
|
-
let accepted = false;
|
|
649
|
-
let rest = chunk;
|
|
650
|
-
while (rest.length > 0) {
|
|
651
|
-
if (state.inPaste) {
|
|
652
|
-
const end = rest.indexOf(PASTE_END);
|
|
653
|
-
const body = end === -1 ? rest : rest.slice(0, end);
|
|
654
|
-
if (end !== -1) state.inPaste = false;
|
|
655
|
-
rest = end === -1 ? "" : rest.slice(end + PASTE_END.length);
|
|
656
|
-
if (feedLivePromptPasteBody(state, body)) accepted = true;
|
|
657
|
-
} else {
|
|
658
|
-
const start = rest.indexOf(PASTE_START);
|
|
659
|
-
const plain = start === -1 ? rest : rest.slice(0, start);
|
|
660
|
-
if (start !== -1) state.inPaste = true;
|
|
661
|
-
rest = start === -1 ? "" : rest.slice(start + PASTE_START.length);
|
|
662
|
-
if (feedLivePromptSegment(state, plain)) accepted = true;
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
return accepted;
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
/**
|
|
669
|
-
* TTY "new input first" contract: fold any queued FULL lines (stray
|
|
670
|
-
* Enter-terminated buffer noise, or older persisted queues) into the editable
|
|
671
|
-
* prompt prefill instead of leaving them to auto-execute as the next prompt.
|
|
672
|
-
* Without this, stale queued lines ran BEFORE the user's fresh input — jeo
|
|
673
|
-
* appeared to "continue the previous work first". Returns the number of lines
|
|
674
|
-
* folded. Pure over the queue object — piped/non-TTY callers must NOT use this
|
|
675
|
-
* (scripted stdin relies on in-order line execution).
|
|
676
|
-
*/
|
|
677
|
-
export function restoreQueuedLinesToPrefill(state: PromptInputQueue): number {
|
|
678
|
-
const lines = state.pendingLines.splice(0, state.pendingLines.length).map(l => l.trim()).filter(Boolean);
|
|
679
|
-
if (lines.length === 0) return 0;
|
|
680
|
-
const restored = lines.join(" ");
|
|
681
|
-
state.partial = state.partial ? `${restored} ${state.partial}`.trim() : restored;
|
|
682
|
-
return lines.length;
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
export function createInFlightAbortHarness(opts: AbortHarnessOptions = {}): InFlightAbortHarness {
|
|
686
|
-
const controller = opts.controller ?? new AbortController();
|
|
687
|
-
const stdin = opts.stdin ?? process.stdin;
|
|
688
|
-
const captureEsc = opts.captureEsc === true && !!stdin.isTTY;
|
|
689
|
-
const wasRaw = !!stdin.isRaw;
|
|
690
|
-
let rawChanged = false;
|
|
691
|
-
|
|
692
|
-
const abortNow = (message: string) => {
|
|
693
|
-
if (controller.signal.aborted) return false;
|
|
694
|
-
opts.onAbortNotice?.(message);
|
|
695
|
-
controller.abort();
|
|
696
|
-
return true;
|
|
697
|
-
};
|
|
698
|
-
|
|
699
|
-
const handleSigint = () => {
|
|
700
|
-
// Ctrl+C is a hard terminal break. Older jeo softened the first press into
|
|
701
|
-
// "abort current run; press again to exit", which left users trapped in raw
|
|
702
|
-
// TTY/TUI states when they expected the terminal to stop. Abort the controller
|
|
703
|
-
// for cleanup observers, then invoke the hard-exit hook immediately.
|
|
704
|
-
if (!controller.signal.aborted) controller.abort();
|
|
705
|
-
opts.onHardExit?.();
|
|
706
|
-
};
|
|
707
|
-
|
|
708
|
-
const handleData = (chunk: string | Uint8Array) => {
|
|
709
|
-
if (!captureEsc || controller.signal.aborted) return;
|
|
710
|
-
const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
|
|
711
|
-
// Bracketed paste is DATA, not keystrokes (the prompt_toolkit contract):
|
|
712
|
-
// marker-carrying and mid-paste chunks go straight to the input queue BEFORE
|
|
713
|
-
// any ESC interpretation — the markers themselves contain ESC, and a paste
|
|
714
|
-
// must never be classified as cancel/noise.
|
|
715
|
-
if (text.includes(PASTE_START) || opts.pasteActive?.()) {
|
|
716
|
-
opts.onBufferedInput?.(text);
|
|
717
|
-
return;
|
|
718
|
-
}
|
|
719
|
-
// Ctrl+O — detail view. Exact-match like the lone-ESC cancel: a raw \u000f
|
|
720
|
-
// keystroke arrives as its own chunk; embedded \u000f inside pasted/streamed
|
|
721
|
-
// data must NOT trigger the view.
|
|
722
|
-
if (text === "\u000f") {
|
|
723
|
-
opts.onDetailKey?.();
|
|
724
|
-
return;
|
|
725
|
-
}
|
|
726
|
-
// Arrow / PageUp / PageDown — scroll the open Ctrl+O detail panel. Exact-match
|
|
727
|
-
// (whole chunk) like Ctrl+O so embedded sequences in pasted/streamed data don't
|
|
728
|
-
// trigger; bracketed paste already returned above. When the panel is closed the
|
|
729
|
-
// hook is a no-op, so these keys stay inert mid-turn as before.
|
|
730
|
-
if (text === "\u001b[A") { opts.onScrollKey?.(-1, false); return; }
|
|
731
|
-
if (text === "\u001b[B") { opts.onScrollKey?.(1, false); return; }
|
|
732
|
-
if (text === "\u001b[5~") { opts.onScrollKey?.(-1, true); return; }
|
|
733
|
-
if (text === "\u001b[6~") { opts.onScrollKey?.(1, true); return; }
|
|
734
|
-
const escAt = text.indexOf("\u001b");
|
|
735
|
-
const sigintAt = text.indexOf("\u0003");
|
|
736
|
-
const controlAt =
|
|
737
|
-
escAt === -1 ? sigintAt :
|
|
738
|
-
sigintAt === -1 ? escAt :
|
|
739
|
-
Math.min(escAt, sigintAt);
|
|
740
|
-
if (controlAt >= 0) {
|
|
741
|
-
const printablePrefix = text.slice(0, controlAt);
|
|
742
|
-
if (printablePrefix) opts.onBufferedInput?.(printablePrefix);
|
|
743
|
-
if (text[controlAt] === "\u0003") {
|
|
744
|
-
handleSigint();
|
|
745
|
-
return;
|
|
746
|
-
}
|
|
747
|
-
if (text === "\u001b") {
|
|
748
|
-
abortNow("ESC pressed — cancelling current run…");
|
|
749
|
-
return;
|
|
750
|
-
}
|
|
751
|
-
opts.onNoise?.();
|
|
752
|
-
return;
|
|
753
|
-
}
|
|
754
|
-
opts.onBufferedInput?.(text);
|
|
755
|
-
};
|
|
756
|
-
|
|
757
|
-
process.on("SIGINT", handleSigint);
|
|
758
|
-
if (captureEsc) {
|
|
759
|
-
stdin.on("data", handleData);
|
|
760
|
-
if (stdin.setRawMode && !wasRaw) {
|
|
761
|
-
stdin.setRawMode(true);
|
|
762
|
-
rawChanged = true;
|
|
763
|
-
}
|
|
764
|
-
stdin.resume?.();
|
|
765
|
-
}
|
|
198
|
+
const PROVIDER_DEFAULT: Record<ProviderName, string> = { anthropic: "sonnet", openai: "gpt-5.5", gemini: "flash", antigravity: "antigravity/gemini-3-pro-high", ollama: "fast" };
|
|
766
199
|
|
|
767
|
-
return {
|
|
768
|
-
controller,
|
|
769
|
-
handleSigint,
|
|
770
|
-
handleData,
|
|
771
|
-
dispose() {
|
|
772
|
-
process.removeListener("SIGINT", handleSigint);
|
|
773
|
-
if (captureEsc) {
|
|
774
|
-
stdin.off("data", handleData);
|
|
775
|
-
if (rawChanged) stdin.setRawMode?.(false);
|
|
776
|
-
}
|
|
777
|
-
},
|
|
778
|
-
};
|
|
779
|
-
}
|
|
780
200
|
|
|
781
|
-
/** The exact resume command printed on REPL exit (and testable in isolation) —
|
|
782
|
-
* same convention as the `--list` handler's hint. */
|
|
783
201
|
export function formatResumeHint(sessionId: string): string {
|
|
784
202
|
return `Resume with: jeo launch --resume ${sessionId}`;
|
|
785
203
|
}
|
|
786
|
-
export function parseFlags(args: string[], cwd: string = process.cwd()): LaunchFlags {
|
|
787
|
-
// maxSteps 0 = dynamic: the engine's process-driven budget extends itself while the
|
|
788
|
-
// turn shows progress instead of stopping at a hardcoded count (old default: 100).
|
|
789
|
-
const flags: LaunchFlags = { list: false, resume: false, noSession: false, noTui: false, maxSteps: 0, message: "", tmux: false, errors: [], print: false, noSkills: false, noTools: false };
|
|
790
|
-
const rest: string[] = [];
|
|
791
|
-
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
792
|
-
for (let i = 0; i < args.length; i++) {
|
|
793
|
-
const a = args[i];
|
|
794
|
-
if (a === "--") {
|
|
795
|
-
rest.push(...args.slice(i + 1));
|
|
796
|
-
break;
|
|
797
|
-
}
|
|
798
|
-
if (a === "--list") {
|
|
799
|
-
flags.list = true;
|
|
800
|
-
} else if (a === "-p" || a === "--print") {
|
|
801
|
-
flags.print = true;
|
|
802
|
-
flags.noTui = true;
|
|
803
|
-
} else if (a === "--tmux") {
|
|
804
|
-
flags.tmux = true;
|
|
805
|
-
} else if (a === "--worktree") {
|
|
806
|
-
const next = args[i + 1];
|
|
807
|
-
if (next && !next.startsWith("-")) {
|
|
808
|
-
flags.worktree = next;
|
|
809
|
-
i++;
|
|
810
|
-
}
|
|
811
|
-
} else if (a.startsWith("--worktree=")) {
|
|
812
|
-
flags.worktree = a.slice("--worktree=".length);
|
|
813
|
-
} else if (a === "--no-session") {
|
|
814
|
-
flags.noSession = true;
|
|
815
|
-
} else if (a === "--no-tui") {
|
|
816
|
-
flags.noTui = true;
|
|
817
|
-
} else if (a === "--max-steps") {
|
|
818
|
-
const n = parseInt(args[i + 1] ?? "", 10);
|
|
819
|
-
if (Number.isFinite(n) && n > 0) {
|
|
820
|
-
flags.maxSteps = n;
|
|
821
|
-
i++;
|
|
822
|
-
}
|
|
823
|
-
} else if (a.startsWith("--max-steps=")) {
|
|
824
|
-
const n = parseInt(a.slice(12), 10);
|
|
825
|
-
if (Number.isFinite(n) && n > 0) flags.maxSteps = n;
|
|
826
|
-
} else if (a === "--model") {
|
|
827
|
-
const { value, nextIndex } = takeValue(args, i, "--model=");
|
|
828
|
-
if (value) flags.model = value;
|
|
829
|
-
else flags.errors.push("--model requires a value");
|
|
830
|
-
i = nextIndex;
|
|
831
|
-
} else if (a.startsWith("--model=")) {
|
|
832
|
-
const { value } = takeValue(args, i, "--model=");
|
|
833
|
-
if (value) flags.model = value;
|
|
834
|
-
else flags.errors.push("--model requires a value");
|
|
835
|
-
} else if (a === "--provider") {
|
|
836
|
-
const { value, nextIndex } = takeValue(args, i, "--provider=");
|
|
837
|
-
const normalized = value?.toLowerCase();
|
|
838
|
-
if (isProviderName(normalized)) flags.provider = normalized;
|
|
839
|
-
else flags.errors.push("--provider must be one of: anthropic, openai, gemini, ollama");
|
|
840
|
-
i = nextIndex;
|
|
841
|
-
} else if (a.startsWith("--provider=")) {
|
|
842
|
-
const { value } = takeValue(args, i, "--provider=");
|
|
843
|
-
const normalized = value?.toLowerCase();
|
|
844
|
-
if (isProviderName(normalized)) flags.provider = normalized;
|
|
845
|
-
else flags.errors.push("--provider must be one of: anthropic, openai, gemini, ollama");
|
|
846
|
-
} else if (a === "--thinking") {
|
|
847
|
-
const { value, nextIndex } = takeValue(args, i, "--thinking=");
|
|
848
|
-
const normalized = value?.toLowerCase();
|
|
849
|
-
if (isThinkingLevel(normalized)) flags.thinking = normalized;
|
|
850
|
-
else flags.errors.push("--thinking must be one of: minimal, low, medium, high, xhigh");
|
|
851
|
-
i = nextIndex;
|
|
852
|
-
} else if (a.startsWith("--thinking=")) {
|
|
853
|
-
const { value } = takeValue(args, i, "--thinking=");
|
|
854
|
-
const normalized = value?.toLowerCase();
|
|
855
|
-
if (isThinkingLevel(normalized)) flags.thinking = normalized;
|
|
856
|
-
else flags.errors.push("--thinking must be one of: minimal, low, medium, high, xhigh");
|
|
857
|
-
} else if (a === "--smol" || a === "--slow" || a === "--plan") {
|
|
858
|
-
flags.modelRole = a.slice(2) as ModelRole;
|
|
859
|
-
} else if (a === "--resume" || a === "--continue" || a === "-c") {
|
|
860
|
-
flags.resume = true;
|
|
861
|
-
const next = args[i + 1];
|
|
862
|
-
if (next && UUID_REGEX.test(next)) {
|
|
863
|
-
flags.resumeId = next;
|
|
864
|
-
i++;
|
|
865
|
-
}
|
|
866
|
-
} else if (a.startsWith("--resume=") || a.startsWith("--continue=") || a.startsWith("-c=")) {
|
|
867
|
-
flags.resume = true;
|
|
868
|
-
const eqIdx = a.indexOf("=");
|
|
869
|
-
const val = a.slice(eqIdx + 1);
|
|
870
|
-
if (UUID_REGEX.test(val)) {
|
|
871
|
-
flags.resumeId = val;
|
|
872
|
-
} else {
|
|
873
|
-
rest.push(val);
|
|
874
|
-
}
|
|
875
|
-
} else if (a === "--append-system-prompt") {
|
|
876
|
-
const { value, nextIndex } = takeValue(args, i, "--append-system-prompt=");
|
|
877
|
-
if (value) {
|
|
878
|
-
flags.appendSystemPromptRaw = value;
|
|
879
|
-
} else {
|
|
880
|
-
flags.errors.push("--append-system-prompt requires a value");
|
|
881
|
-
}
|
|
882
|
-
i = nextIndex;
|
|
883
|
-
} else if (a.startsWith("--append-system-prompt=")) {
|
|
884
|
-
const { value } = takeValue(args, i, "--append-system-prompt=");
|
|
885
|
-
if (value) {
|
|
886
|
-
flags.appendSystemPromptRaw = value;
|
|
887
|
-
} else {
|
|
888
|
-
flags.errors.push("--append-system-prompt requires a value");
|
|
889
|
-
}
|
|
890
|
-
} else if (a === "--no-skills") {
|
|
891
|
-
flags.noSkills = true;
|
|
892
|
-
} else if (a === "--skills") {
|
|
893
|
-
const { value, nextIndex } = takeValue(args, i, "--skills=");
|
|
894
|
-
if (value) flags.skills = value;
|
|
895
|
-
else flags.errors.push("--skills requires a value");
|
|
896
|
-
i = nextIndex;
|
|
897
|
-
} else if (a.startsWith("--skills=")) {
|
|
898
|
-
const { value } = takeValue(args, i, "--skills=");
|
|
899
|
-
if (value) flags.skills = value;
|
|
900
|
-
else flags.errors.push("--skills requires a value");
|
|
901
|
-
} else if (a === "--no-tools") {
|
|
902
|
-
flags.noTools = true;
|
|
903
|
-
} else if (a === "--tools") {
|
|
904
|
-
const { value, nextIndex } = takeValue(args, i, "--tools=");
|
|
905
|
-
if (value) flags.tools = value;
|
|
906
|
-
else flags.errors.push("--tools requires a value");
|
|
907
|
-
i = nextIndex;
|
|
908
|
-
} else if (a.startsWith("--tools=")) {
|
|
909
|
-
const { value } = takeValue(args, i, "--tools=");
|
|
910
|
-
if (value) flags.tools = value;
|
|
911
|
-
else flags.errors.push("--tools requires a value");
|
|
912
|
-
} else if (a === "--system-prompt") {
|
|
913
|
-
const { value, nextIndex } = takeValue(args, i, "--system-prompt=");
|
|
914
|
-
if (value) flags.systemPromptRaw = value;
|
|
915
|
-
else flags.errors.push("--system-prompt requires a value");
|
|
916
|
-
i = nextIndex;
|
|
917
|
-
} else if (a.startsWith("--system-prompt=")) {
|
|
918
|
-
const { value } = takeValue(args, i, "--system-prompt=");
|
|
919
|
-
if (value) flags.systemPromptRaw = value;
|
|
920
|
-
else flags.errors.push("--system-prompt requires a value");
|
|
921
|
-
} else {
|
|
922
|
-
rest.push(a);
|
|
923
|
-
}
|
|
924
|
-
}
|
|
925
|
-
flags.message = rest.join(" ").trim();
|
|
926
|
-
|
|
927
|
-
if (flags.print && !flags.message) {
|
|
928
|
-
flags.errors.push("-p/--print requires a message argument");
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
if (flags.appendSystemPromptRaw) {
|
|
932
|
-
if (flags.appendSystemPromptRaw.startsWith("@")) {
|
|
933
|
-
const filePath = flags.appendSystemPromptRaw.slice(1);
|
|
934
|
-
const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
|
|
935
|
-
try {
|
|
936
|
-
flags.appendSystemPrompt = fs.readFileSync(absPath, "utf8");
|
|
937
|
-
} catch (err) {
|
|
938
|
-
flags.errors.push(`failed to read system prompt file: ${(err as Error).message}`);
|
|
939
|
-
}
|
|
940
|
-
} else {
|
|
941
|
-
flags.appendSystemPrompt = flags.appendSystemPromptRaw;
|
|
942
|
-
}
|
|
943
|
-
}
|
|
944
|
-
if (flags.systemPromptRaw) {
|
|
945
|
-
if (flags.systemPromptRaw.startsWith("@")) {
|
|
946
|
-
const filePath = flags.systemPromptRaw.slice(1);
|
|
947
|
-
const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
|
|
948
|
-
try {
|
|
949
|
-
flags.systemPrompt = fs.readFileSync(absPath, "utf8");
|
|
950
|
-
} catch (err) {
|
|
951
|
-
flags.errors.push(`failed to read system prompt file: ${(err as Error).message}`);
|
|
952
|
-
}
|
|
953
|
-
} else {
|
|
954
|
-
flags.systemPrompt = flags.systemPromptRaw;
|
|
955
|
-
}
|
|
956
|
-
}
|
|
957
|
-
|
|
958
|
-
return flags;
|
|
959
|
-
}
|
|
960
|
-
export function matchSkillGlob(pattern: string, name: string): boolean {
|
|
961
|
-
const p = pattern.toLowerCase();
|
|
962
|
-
const n = name.toLowerCase();
|
|
963
|
-
if (!p.includes("*")) {
|
|
964
|
-
return p === n;
|
|
965
|
-
}
|
|
966
|
-
const escaped = p.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
967
|
-
const regexStr = "^" + escaped.replace(/\*/g, ".*") + "$";
|
|
968
|
-
const regex = new RegExp(regexStr);
|
|
969
|
-
return regex.test(n);
|
|
970
|
-
}
|
|
971
|
-
|
|
972
|
-
export function filterToolMap(
|
|
973
|
-
tools: Record<string, any>,
|
|
974
|
-
allowlist: string[]
|
|
975
|
-
): Record<string, any> {
|
|
976
|
-
const result: Record<string, any> = {};
|
|
977
|
-
for (const name of allowlist) {
|
|
978
|
-
if (name in tools) {
|
|
979
|
-
result[name] = tools[name];
|
|
980
|
-
}
|
|
981
|
-
}
|
|
982
|
-
return result;
|
|
983
|
-
}
|
|
984
|
-
export const TOOL_DESCRIPTIONS: Record<string, string> = {
|
|
985
|
-
read: "read {filePath, lineRange?, raw?} — read a file; lines are prefixed `LINEhh|` (hh = 2-char content anchor; the | is a separator, not file bytes)",
|
|
986
|
-
write: "write {filePath, content} — create/overwrite a file",
|
|
987
|
-
edit: "edit {filePath, editBlock} — ≔A..B replace lines (append read anchors for safety: ≔12ab..15cd — rejected with fresh content if the lines changed); ≔A+ insert after line A; ≔$ append EOF (payload on next line). NEVER copy the `LINEhh|` prefixes into SEARCH blocks or payloads",
|
|
988
|
-
bash: "bash {command, timeoutMs?, cwd?, env?} — run a shell command (cwd: subdir; env: extra vars)",
|
|
989
|
-
find: "find {globPattern} — find files by name",
|
|
990
|
-
search: "search {pattern, globPattern?, ignoreCase?, context?, maxMatches?} — grep (context: N lines around each match)",
|
|
991
|
-
ls: "ls {dirPath} — list a directory's entries (dirs first)",
|
|
992
|
-
};
|
|
993
|
-
|
|
994
|
-
export function buildToolProtocol(allowedTools: Set<string>): string {
|
|
995
|
-
const lines: string[] = ["You have these tools (call exactly ONE per step):"];
|
|
996
|
-
let num = 1;
|
|
997
|
-
for (const name of ["read", "write", "edit", "bash", "find", "search", "ls"]) {
|
|
998
|
-
if (allowedTools.has(name)) {
|
|
999
|
-
lines.push(`${num}. ${TOOL_DESCRIPTIONS[name]}`);
|
|
1000
|
-
num++;
|
|
1001
|
-
}
|
|
1002
|
-
}
|
|
1003
|
-
lines.push(`${num}. done {reason?} — call when the task is fully implemented AND verified`);
|
|
1004
|
-
lines.push("");
|
|
1005
|
-
lines.push("Reply with STRICT JSON only — no code fences. You MAY include an optional leading");
|
|
1006
|
-
lines.push('"reasoning" string (one short sentence on your plan, shown live to the user) before "tool":');
|
|
1007
|
-
lines.push('{ "reasoning": "<one short sentence>", "tool": "<name>", "arguments": { ... } }');
|
|
1008
|
-
lines.push("Tool calibration: scale calls to difficulty — one for a known fact, a few for a normal task, more only when evidence is genuinely missing. Locate before you open: search/find first, then read the hit, instead of guessing paths.");
|
|
1009
|
-
return lines.join("\n");
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
/**
|
|
1013
|
-
* Resolve a git worktree path (gjc `--worktree <path>` parity). If the path
|
|
1014
|
-
* already exists it is reused as-is; otherwise a new worktree is created on a
|
|
1015
|
-
* branch derived from the path basename. Returns the absolute worktree path.
|
|
1016
|
-
*/
|
|
1017
|
-
function resolveWorktree(cwd: string, wt: string): string {
|
|
1018
|
-
const abs = path.isAbsolute(wt) ? wt : path.resolve(cwd, wt);
|
|
1019
|
-
if (fs.existsSync(abs)) return abs;
|
|
1020
|
-
if (!Bun.which("git")) {
|
|
1021
|
-
console.error("error: --worktree requires git on PATH");
|
|
1022
|
-
process.exit(1);
|
|
1023
|
-
}
|
|
1024
|
-
const branch = (path.basename(abs).replace(/[^a-zA-Z0-9_-]/g, "-") || "jeo-wt");
|
|
1025
|
-
const withBranch = Bun.spawnSync(["git", "worktree", "add", "-b", branch, abs], {
|
|
1026
|
-
cwd,
|
|
1027
|
-
stdout: "pipe",
|
|
1028
|
-
stderr: "pipe",
|
|
1029
|
-
});
|
|
1030
|
-
if (withBranch.exitCode !== 0) {
|
|
1031
|
-
// Branch may already exist; retry attaching the existing branch.
|
|
1032
|
-
const plain = Bun.spawnSync(["git", "worktree", "add", abs], {
|
|
1033
|
-
cwd,
|
|
1034
|
-
stdout: "pipe",
|
|
1035
|
-
stderr: "pipe",
|
|
1036
|
-
});
|
|
1037
|
-
if (plain.exitCode !== 0) {
|
|
1038
|
-
console.error(
|
|
1039
|
-
`error: failed to create git worktree at ${abs}: ${withBranch.stderr.toString().trim()}`,
|
|
1040
|
-
);
|
|
1041
|
-
process.exit(1);
|
|
1042
|
-
}
|
|
1043
|
-
}
|
|
1044
|
-
return abs;
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
/** The bundled workflow skills that run through a dedicated engine (deep-interview /
|
|
1048
|
-
* ralplan / team / ultragoal), not the ordinary agent loop. Single source of truth —
|
|
1049
|
-
* the menu listing, the dispatch guards, and the engine switch all read from here. */
|
|
1050
|
-
export const WORKFLOW_NAMES = ["deep-interview", "ralplan", "team", "ultragoal"] as const;
|
|
1051
|
-
|
|
1052
|
-
/** True when a skill name is one of the bundled workflow engines. */
|
|
1053
|
-
export function isWorkflowSkill(name: string): boolean {
|
|
1054
|
-
return (WORKFLOW_NAMES as readonly string[]).includes(name);
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
|
-
/** Dispatch a bundled workflow by name to its engine. Keeps the name→engine mapping in
|
|
1058
|
-
* ONE place so the one-shot and interactive skill runners can't drift apart. */
|
|
1059
|
-
export function runWorkflowEngine(
|
|
1060
|
-
name: string,
|
|
1061
|
-
opts: DeepInterviewEngineOptions & RalplanEngineOptions & TeamEngineOptions & UltragoalEngineOptions,
|
|
1062
|
-
): Promise<{ ok: boolean; reason?: string }> {
|
|
1063
|
-
if (name === "deep-interview") return runDeepInterviewEngine(opts);
|
|
1064
|
-
if (name === "ralplan") return runRalplanEngine(opts);
|
|
1065
|
-
if (name === "team") return runTeamEngine(opts);
|
|
1066
|
-
return runUltragoalEngine(opts);
|
|
1067
|
-
}
|
|
1068
204
|
|
|
1069
205
|
export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
1070
206
|
let cwd = process.cwd();
|
|
@@ -1549,14 +685,48 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1549
685
|
// Todos checklist used to end a finished turn stuck at "✓0 ◐1 ·4 / 5"
|
|
1550
686
|
// because nothing ever forced the model to update item statuses).
|
|
1551
687
|
let turnTodos: { title: string; status: string }[] = [];
|
|
1552
|
-
const onBeforeDone = (): string | null => {
|
|
688
|
+
const onBeforeDone = async (reason: string): Promise<string | null> => {
|
|
1553
689
|
const unfinished = turnTodos.filter(t => t.status !== "done");
|
|
1554
|
-
if (turnTodos.length
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
690
|
+
if (turnTodos.length > 0 && unfinished.length > 0) {
|
|
691
|
+
return (
|
|
692
|
+
`Your todo list still shows ${unfinished.length} unfinished item(s): ${unfinished.map(t => `"${t.title}"`).join(", ")}. ` +
|
|
693
|
+
`Reconcile the plan first — call the todo tool resending the FULL list with every actually-completed item marked "done" ` +
|
|
694
|
+
`(drop items that no longer apply), then call done again.`
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const goalState = await readGoalState(cwd);
|
|
699
|
+
if (goalState && goalState.condition) {
|
|
700
|
+
const reBlockCount = goalState.verdicts.filter(v => v.verdict === "NOT_MET" || v.verdict === "IMPOSSIBLE").length;
|
|
701
|
+
const MAX_RE_BLOCKS = 2;
|
|
702
|
+
|
|
703
|
+
if (reBlockCount >= MAX_RE_BLOCKS) {
|
|
704
|
+
if (tui) tui.events().onNotice?.(`[Goal Verifier] Re-block cap of ${MAX_RE_BLOCKS} reached. Auto-allowing done.`);
|
|
705
|
+
else console.log(`[Goal Verifier] Re-block cap of ${MAX_RE_BLOCKS} reached. Auto-allowing done.`);
|
|
706
|
+
return null;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
if (tui) tui.events().onNotice?.("[Goal Verifier] Running goal verification...");
|
|
710
|
+
const verdict = await verifyGoal(goalState.condition, history, sessionModel);
|
|
711
|
+
|
|
712
|
+
goalState.verdicts.push({
|
|
713
|
+
at: Date.now(),
|
|
714
|
+
verdict: verdict.verdict,
|
|
715
|
+
gap: verdict.reason,
|
|
716
|
+
});
|
|
717
|
+
await writeGoalState(goalState, cwd);
|
|
718
|
+
|
|
719
|
+
if (verdict.verdict === "NOT_MET" || verdict.verdict === "IMPOSSIBLE") {
|
|
720
|
+
const prefix = verdict.verdict === "IMPOSSIBLE" ? "[IMPOSSIBLE] " : "";
|
|
721
|
+
return (
|
|
722
|
+
`Goal verifier check failed: The goal "${goalState.condition}" is not yet met.\n` +
|
|
723
|
+
`Reason: ${prefix}${verdict.reason}\n` +
|
|
724
|
+
`Please address the remaining gaps and call done again.`
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
return null;
|
|
1560
730
|
};
|
|
1561
731
|
const fullTools = {
|
|
1562
732
|
...DEFAULT_TOOLS,
|
|
@@ -1836,6 +1006,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1836
1006
|
accent: accentPaint(welcomeTheme),
|
|
1837
1007
|
accentShadow: accentShadowPaint(welcomeTheme),
|
|
1838
1008
|
};
|
|
1009
|
+
// gjc-style fresh-start clear so the banner opens atop a clean screen. TTY only,
|
|
1010
|
+
// never mid-turn (scrollback flood). ponytail: add an opt-out env if anyone misses their scrollback.
|
|
1011
|
+
if (process.stdout.isTTY) process.stdout.write(clearScreen());
|
|
1839
1012
|
// Launch sweep: the DNA Claw's gradient loops seamlessly (default 2 full
|
|
1840
1013
|
// cycles, JEO_WELCOME_ANIM_CYCLES overrides), ending on the static banner.
|
|
1841
1014
|
// Truecolor TTYs only; JEO_NO_WELCOME_ANIM=1 opts out.
|
|
@@ -2090,6 +1263,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2090
1263
|
if (data.startsWith(seq, i)) { out += SENTINEL; i += seq.length; matched = true; break; }
|
|
2091
1264
|
}
|
|
2092
1265
|
if (matched) continue;
|
|
1266
|
+
// Normalize macOS/fixterms combo cursor keys readline ignores (Option/Cmd+arrow,
|
|
1267
|
+
// Option/Cmd+Backspace) into the canonical control bytes it DOES act on.
|
|
1268
|
+
const combo = matchCursorCombo(data, i);
|
|
1269
|
+
if (combo) { out += combo[1]; i += combo[0].length; continue; }
|
|
2093
1270
|
if (loneLfShiftEnter && data[i] === "\n") { out += SENTINEL; i += 1; continue; } // lone LF = Shift+Enter (opt-in)
|
|
2094
1271
|
out += data[i]; i += 1;
|
|
2095
1272
|
}
|
|
@@ -2190,7 +1367,12 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2190
1367
|
pasteLineFired = true;
|
|
2191
1368
|
return;
|
|
2192
1369
|
}
|
|
2193
|
-
|
|
1370
|
+
// A select picker (/model, /agents, …) reads keypresses directly while NO
|
|
1371
|
+
// rl.question is active, so its filter-text + selecting Enter fire an ORPHAN
|
|
1372
|
+
// `line` event here. Queuing that would auto-serve the leftover filter as the
|
|
1373
|
+
// next prompt (the "/model · /agents leaves typed text behind" bug). Drop it:
|
|
1374
|
+
// the picker owns those keystrokes and clears the readline buffer on exit.
|
|
1375
|
+
if (!interactiveTurnActive && !pickerActive) pendingStdinLines.push(l);
|
|
2194
1376
|
});
|
|
2195
1377
|
let stdinClosed = false;
|
|
2196
1378
|
let notifyStdinClosed: (() => void) | undefined;
|
|
@@ -2367,6 +1549,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2367
1549
|
// Row (within the reservation) where the real cursor was last parked; the next
|
|
2368
1550
|
// drawFooter/disarmPreview must hop back to the top from here before painting.
|
|
2369
1551
|
let footerParkedRow = 0;
|
|
1552
|
+
// The footer's last painted lines (padded to the reservation height). The resize
|
|
1553
|
+
// relayout uses these to find the frame's physical top at the live width — a full-width
|
|
1554
|
+
// line painted at an older, wider geometry reflows onto extra rows after a width shrink.
|
|
1555
|
+
let lastDrawnLines: string[] = [];
|
|
2370
1556
|
const padToFooter = (lines: string[]): string[] => {
|
|
2371
1557
|
if (lines.length >= footerRows) return lines.slice(0, footerRows);
|
|
2372
1558
|
return [...lines, ...new Array(footerRows - lines.length).fill("")];
|
|
@@ -2404,6 +1590,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2404
1590
|
s += toColumn(1) + "\x1b[?25h";
|
|
2405
1591
|
out.write(s);
|
|
2406
1592
|
footerRendered = 0;
|
|
1593
|
+
lastDrawnLines = [];
|
|
2407
1594
|
} else {
|
|
2408
1595
|
out.write("\x1b[?25h");
|
|
2409
1596
|
}
|
|
@@ -2473,7 +1660,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2473
1660
|
const caret = rli.line === line && typeof rli.cursor === "number" ? rli.cursor : line.length;
|
|
2474
1661
|
const { accent: boxAccent, shadow: boxShadow } = boxAccents(line);
|
|
2475
1662
|
const frame = renderInputFrame(expandSentinel(line), {
|
|
2476
|
-
cols
|
|
1663
|
+
// Cap the input box at 120 cols to match the live-turn box (renderLiveInputBox)
|
|
1664
|
+
// and the user-card width, so the box doesn't visibly jump width on the
|
|
1665
|
+
// idle→live transition on wide terminals. The status bar below stays full-width.
|
|
1666
|
+
cols: Math.min(120, cols),
|
|
2477
1667
|
color: true,
|
|
2478
1668
|
unicode: true,
|
|
2479
1669
|
accent: boxAccent,
|
|
@@ -2543,6 +1733,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2543
1733
|
// and no row can spill past it — the bug fix that kept `@folder<more text>`
|
|
2544
1734
|
// typing from scrolling the input box (and prior output) off the top.
|
|
2545
1735
|
const padded = padToFooter(lines);
|
|
1736
|
+
// Remember the exact painted frame so resize/disarm can clear its real physical
|
|
1737
|
+
// footprint at the (possibly changed) live width instead of trusting logical rows.
|
|
1738
|
+
lastDrawnLines = padded;
|
|
2546
1739
|
// Pure caret moves (arrow keys) change no content — include the caret cell in
|
|
2547
1740
|
// the repaint key so they still reposition the terminal cursor.
|
|
2548
1741
|
const tRow = lines.length ? Math.min(footerCursor.row, footerRendered - 1) : 0;
|
|
@@ -3121,16 +2314,83 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
3121
2314
|
// Idle-prompt resize: re-reserve the footer at the new terminal height so the
|
|
3122
2315
|
// fixed reservation stays accurate (otherwise the next paint would target the
|
|
3123
2316
|
// old row count and either over-shoot or under-paint the reserved region).
|
|
3124
|
-
|
|
2317
|
+
// gjc-style responsiveness: re-reserve the footer IMMEDIATELY on the first resize
|
|
2318
|
+
// event (leading edge → no lag), then cap re-reservations to ~30fps during a
|
|
2319
|
+
// drag-resize, with a trailing settle so the FINAL height is always reserved exactly.
|
|
2320
|
+
// Skip same-geometry events; each re-reserve disarms→arms so it must not run per-event.
|
|
2321
|
+
const RESIZE_THROTTLE_MS = 33;
|
|
2322
|
+
let idleResizeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
2323
|
+
let lastIdleAt = 0;
|
|
2324
|
+
let lastIdleCols = process.stdout.columns ?? 80;
|
|
2325
|
+
let lastIdleRows = process.stdout.rows ?? 24;
|
|
2326
|
+
const reReserveFooter = () => {
|
|
3125
2327
|
if (!previewArmed) return;
|
|
2328
|
+
const cols = process.stdout.columns ?? 80;
|
|
2329
|
+
const rows = process.stdout.rows ?? 24;
|
|
2330
|
+
if (cols === lastIdleCols && rows === lastIdleRows) return;
|
|
2331
|
+
lastIdleCols = cols;
|
|
2332
|
+
lastIdleRows = rows;
|
|
3126
2333
|
try {
|
|
3127
|
-
|
|
3128
|
-
|
|
2334
|
+
// Resolution-safe relayout, anchored to the CURSOR (not the screen bottom). The
|
|
2335
|
+
// previous frame was painted at the OLD width; on resize the terminal reflows its
|
|
2336
|
+
// full-width rows AND repositions the frame — a width shrink wraps lines and floats
|
|
2337
|
+
// the frame UP by its blank tail, while a height grow leaves the frame high while
|
|
2338
|
+
// adding rows at the bottom. In every case the terminal keeps the REAL cursor on
|
|
2339
|
+
// the input caret's cell, so the caret is the one stable anchor. The old logical
|
|
2340
|
+
// disarm→arm cycle ignored this, clearing the wrong rows and scrolling the stray
|
|
2341
|
+
// frame into scrollback (the stacked status-bar corruption).
|
|
2342
|
+
//
|
|
2343
|
+
// Hop UP from the caret to the frame's physical top — `footerParkedRow` logical
|
|
2344
|
+
// rows of content sit above the caret; recompute their PHYSICAL height at the new
|
|
2345
|
+
// width (wrapped lines count for more) — then clear-to-end-of-display in one op.
|
|
2346
|
+
// This wipes the whole stray frame regardless of which way it drifted, and is
|
|
2347
|
+
// banner-safe: the hop stops exactly at the frame top (just below the static
|
|
2348
|
+
// content). ED is safe here — this path emits no bottom-margin scroll, so (unlike
|
|
2349
|
+
// the mid-turn ledger flush) tmux never copies the erased rows into history.
|
|
2350
|
+
const c = Math.max(1, cols);
|
|
2351
|
+
let abovePhysical = 0;
|
|
2352
|
+
for (let i = 0; i < footerParkedRow && i < lastDrawnLines.length; i++) {
|
|
2353
|
+
abovePhysical += Math.max(1, Math.ceil(Math.max(1, visibleWidth(lastDrawnLines[i]!)) / c));
|
|
2354
|
+
}
|
|
2355
|
+
// The caret may sit on a LOWER physical sub-row of its own (now-wrapped) line: a
|
|
2356
|
+
// long input wraps and the terminal keeps the cursor on its cell, so add the
|
|
2357
|
+
// caret's sub-row offset (caret column / width) or the hop stops below the top.
|
|
2358
|
+
const caretSubRow = Math.floor(Math.max(0, footerCursor.col - 1) / c);
|
|
2359
|
+
const hopUp = abovePhysical + caretSubRow;
|
|
2360
|
+
let s = (hopUp > 0 ? cursorUp(hopUp) : "") + toColumn(1) + clearToEnd();
|
|
2361
|
+
// Re-pin a clean reservation to the screen bottom and repaint. ED already blanked
|
|
2362
|
+
// from the frame top down to the bottom, so just position at the bottom region.
|
|
2363
|
+
footerRows = previewRowsFor(rows);
|
|
2364
|
+
s += `\x1b[${Math.max(1, rows)};1H`;
|
|
2365
|
+
if (footerRows > 1) s += cursorUp(footerRows - 1);
|
|
2366
|
+
s += toColumn(1);
|
|
2367
|
+
out.write(s);
|
|
2368
|
+
footerRendered = footerRows;
|
|
2369
|
+
footerParkedRow = 0;
|
|
2370
|
+
lastFooterKey = "";
|
|
3129
2371
|
drawFooter(promptHistoryLines ? historyPreviewLines(promptHistoryLines) : previewLines(typedLine, navIdx));
|
|
3130
2372
|
} catch { /* ignore resize render races */ }
|
|
3131
2373
|
};
|
|
2374
|
+
const idleResizeHandler = () => {
|
|
2375
|
+
if (!previewArmed) return;
|
|
2376
|
+
const now = Date.now();
|
|
2377
|
+
if (now - lastIdleAt >= RESIZE_THROTTLE_MS) {
|
|
2378
|
+
lastIdleAt = now;
|
|
2379
|
+
reReserveFooter();
|
|
2380
|
+
return;
|
|
2381
|
+
}
|
|
2382
|
+
if (idleResizeTimer) clearTimeout(idleResizeTimer);
|
|
2383
|
+
idleResizeTimer = setTimeout(() => {
|
|
2384
|
+
idleResizeTimer = undefined;
|
|
2385
|
+
lastIdleAt = Date.now();
|
|
2386
|
+
reReserveFooter();
|
|
2387
|
+
}, RESIZE_THROTTLE_MS);
|
|
2388
|
+
};
|
|
3132
2389
|
process.stdout.on("resize", idleResizeHandler);
|
|
3133
|
-
promptListenerCleanups.push(() =>
|
|
2390
|
+
promptListenerCleanups.push(() => {
|
|
2391
|
+
process.stdout.off("resize", idleResizeHandler);
|
|
2392
|
+
if (idleResizeTimer) clearTimeout(idleResizeTimer);
|
|
2393
|
+
});
|
|
3134
2394
|
}
|
|
3135
2395
|
|
|
3136
2396
|
while (true) {
|
|
@@ -3244,7 +2504,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
3244
2504
|
// scrollback, and re-render the welcome banner so /clear looks like a fresh launch.
|
|
3245
2505
|
if (process.stdout.isTTY) {
|
|
3246
2506
|
disarmPreview();
|
|
3247
|
-
process.stdout.write(
|
|
2507
|
+
process.stdout.write(clearScreen()); // clear screen + scrollback + cursor home
|
|
3248
2508
|
console.log(renderWelcome(welcomeData).join("\n"));
|
|
3249
2509
|
}
|
|
3250
2510
|
console.log("(history cleared — back to the start screen)");
|
|
@@ -3349,7 +2609,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
3349
2609
|
// so the resumed view reads like a fresh, intact screen.
|
|
3350
2610
|
if (process.stdout.isTTY) {
|
|
3351
2611
|
disarmPreview();
|
|
3352
|
-
process.stdout.write(
|
|
2612
|
+
process.stdout.write(clearScreen());
|
|
3353
2613
|
console.log(renderWelcome(welcomeData).join("\n"));
|
|
3354
2614
|
}
|
|
3355
2615
|
const sep = "─".repeat(Math.min(48, Math.max(20, (process.stdout.columns ?? 80) - 1)));
|
|
@@ -3548,6 +2808,32 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
3548
2808
|
}
|
|
3549
2809
|
continue;
|
|
3550
2810
|
}
|
|
2811
|
+
if (input === "/goal" || input.startsWith("/goal ")) {
|
|
2812
|
+
const arg = input.substring(5).trim();
|
|
2813
|
+
if (!arg) {
|
|
2814
|
+
const current = await readGoalState(cwd);
|
|
2815
|
+
if (current) {
|
|
2816
|
+
console.log(`Current goal: "${current.condition}" (set at ${new Date(current.setAt).toLocaleTimeString()})`);
|
|
2817
|
+
} else {
|
|
2818
|
+
console.log("Usage: /goal <condition> (set a natural language stop condition for the session)");
|
|
2819
|
+
console.log(" /goal clear (clear the current goal)");
|
|
2820
|
+
}
|
|
2821
|
+
continue;
|
|
2822
|
+
}
|
|
2823
|
+
if (arg === "clear" || arg === "none") {
|
|
2824
|
+
await clearGoalState(cwd);
|
|
2825
|
+
console.log("Goal cleared.");
|
|
2826
|
+
continue;
|
|
2827
|
+
}
|
|
2828
|
+
const state = {
|
|
2829
|
+
condition: arg,
|
|
2830
|
+
setAt: Date.now(),
|
|
2831
|
+
verdicts: [],
|
|
2832
|
+
};
|
|
2833
|
+
await writeGoalState(state, cwd);
|
|
2834
|
+
console.log(`Goal set to: "${arg}"`);
|
|
2835
|
+
continue;
|
|
2836
|
+
}
|
|
3551
2837
|
// ---- gjc-parity inspection commands ------------------------------------
|
|
3552
2838
|
if (input === "/usage") {
|
|
3553
2839
|
const total = sessionUsage.inputTokens + sessionUsage.outputTokens;
|
|
@@ -3632,8 +2918,17 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
3632
2918
|
}
|
|
3633
2919
|
if (input.startsWith("/provider") && (input === "/provider" || input[9] === " ")) {
|
|
3634
2920
|
const tokens = input.substring(9).trim().split(/\s+/).filter(Boolean);
|
|
3635
|
-
|
|
3636
|
-
|
|
2921
|
+
const name = (tokens[0] ?? "").toLowerCase();
|
|
2922
|
+
// gjc-parity (semantic): /provider is ONBOARDING ONLY — set up OAuth credentials
|
|
2923
|
+
// or an API-compatible endpoint. Switching the active provider/model lives in /model.
|
|
2924
|
+
const providerOnboardingUsage = (): string[] => [
|
|
2925
|
+
"Provider onboarding — set up credentials or an API-compatible endpoint:",
|
|
2926
|
+
" OAuth / subscription : /provider login [anthropic|openai|gemini|antigravity] (alias: /login)",
|
|
2927
|
+
" API-compatible : /provider add --base-url <url> [--model <model>] [--compat openai] (reads OPENAI_API_KEY)",
|
|
2928
|
+
" show current / clear: /provider add · /provider add clear",
|
|
2929
|
+
" Logout : /logout <provider>",
|
|
2930
|
+
"Switch the active model or provider with /model.",
|
|
2931
|
+
];
|
|
3637
2932
|
// `/provider login|auth [name]` → run OAuth login from the REPL.
|
|
3638
2933
|
if (name === "login" || name === "auth") {
|
|
3639
2934
|
const cloud = ["anthropic", "openai", "gemini", "antigravity"] as const;
|
|
@@ -3643,14 +2938,13 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
3643
2938
|
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
3644
2939
|
target = await pickCloudProvider(statuses);
|
|
3645
2940
|
} else {
|
|
3646
|
-
// No provider given → show current status and let the user pick.
|
|
3647
2941
|
console.log("Log in to which provider?");
|
|
3648
2942
|
cloud.forEach((p, i) => {
|
|
3649
2943
|
const st = statuses.find(s => s.name === p);
|
|
3650
2944
|
console.log(` ${i + 1}) ${p.padEnd(10)} ${st?.ready ? `✓ ${st.label}` : "· not ready"}`);
|
|
3651
2945
|
});
|
|
3652
|
-
const ans = (await promptInput(
|
|
3653
|
-
const byNum: Record<string, string> =
|
|
2946
|
+
const ans = (await promptInput(`Choose [1-${cloud.length}] or name (blank to cancel): `)).trim().toLowerCase();
|
|
2947
|
+
const byNum: Record<string, string> = Object.fromEntries(cloud.map((p, i) => [String(i + 1), p]));
|
|
3654
2948
|
target = byNum[ans] ?? ((cloud as readonly string[]).includes(ans) ? ans : undefined);
|
|
3655
2949
|
}
|
|
3656
2950
|
if (!target) {
|
|
@@ -3661,131 +2955,107 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
3661
2955
|
console.log(`Starting OAuth login for ${target}…`);
|
|
3662
2956
|
try {
|
|
3663
2957
|
const { email } = await interactiveOAuthLogin(target as AuthProvider, rl);
|
|
3664
|
-
|
|
3665
|
-
// browser prompts / "waiting…" lines that clutter scrollback. Clear the
|
|
3666
|
-
// screen + scrollback and re-render the welcome (same path as /clear),
|
|
3667
|
-
// then a single concise confirmation. lastPickIndex is still seeded so
|
|
3668
|
-
// `/model #N` works; the verbose live-model dump is dropped (it cluttered).
|
|
2958
|
+
console.log(`[SUCCESS] OAuth login complete for ${target}${email ? ` (${email})` : ""}. Tokens saved to ~/.jeo/config.json.`);
|
|
3669
2959
|
const live = await refreshLiveModelsCache();
|
|
3670
2960
|
const after = (await describeAllProviders()).find(s => s.name === target);
|
|
2961
|
+
if (after) console.log(` status → ${after.name}: ${after.ready ? `✓ ${after.label}` : after.label}`);
|
|
3671
2962
|
const forProvider = live.filter(r => r.provider === target);
|
|
3672
|
-
if (forProvider.some(r => r.ok && r.models.length > 0))
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
{
|
|
3680
|
-
|
|
3681
|
-
write: line => console.log(line),
|
|
3682
|
-
},
|
|
3683
|
-
{
|
|
3684
|
-
isTty: process.stdout.isTTY === true,
|
|
3685
|
-
provider: target,
|
|
3686
|
-
email,
|
|
3687
|
-
ready: after?.ready ?? false,
|
|
3688
|
-
label: after?.label,
|
|
3689
|
-
welcomeLines: renderWelcome(welcomeData),
|
|
3690
|
-
},
|
|
3691
|
-
);
|
|
2963
|
+
if (forProvider.some(r => r.ok && r.models.length > 0)) {
|
|
2964
|
+
lastPickIndex = flattenModels(forProvider);
|
|
2965
|
+
const viaCatalog = forProvider.some(r => r.fallback);
|
|
2966
|
+
console.log(` ${viaCatalog ? "catalog" : "live"} ${target} models → /model #N${viaCatalog ? " (live list endpoint rejected this token; showing known models)" : ""}`);
|
|
2967
|
+
logLines(formatPickListWithCapabilities(lastPickIndex, { cap: 12 }));
|
|
2968
|
+
} else {
|
|
2969
|
+
const failed = forProvider.find(r => !r.ok);
|
|
2970
|
+
if (failed?.error) console.log(` live ${target} models unavailable: ${failed.error}`);
|
|
2971
|
+
}
|
|
3692
2972
|
} catch (err) {
|
|
3693
2973
|
console.log(`[FAILED] ${(err as Error).message} — or set ${target.toUpperCase()}_API_KEY.`);
|
|
2974
|
+
} finally {
|
|
2975
|
+
// The OAuth manual-code prompt opens an rl.question that is aborted the
|
|
2976
|
+
// instant the browser callback (or a failure) settles the flow. Bun leaves
|
|
2977
|
+
// that abandoned question's buffer behind, so the next prompt would adopt
|
|
2978
|
+
// it as residual prefill and resurrect the submitted "/provider login" in
|
|
2979
|
+
// the input box. Reset the readline line buffer so the prompt starts empty.
|
|
2980
|
+
const rli = rl as unknown as { line?: string; cursor?: number };
|
|
2981
|
+
rli.line = "";
|
|
2982
|
+
rli.cursor = 0;
|
|
3694
2983
|
}
|
|
3695
2984
|
continue;
|
|
3696
2985
|
}
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
label: c.label,
|
|
3707
|
-
hint: c.hint,
|
|
3708
|
-
group: c.group,
|
|
3709
|
-
}));
|
|
3710
|
-
const picked = await pickFromOptions("Select a provider ↑↓ move · Enter switch · Esc cancel", items);
|
|
3711
|
-
if (!picked) { console.log("(switch cancelled)"); continue; }
|
|
3712
|
-
name = picked.toLowerCase(); // fall through to the switch logic below
|
|
3713
|
-
} else {
|
|
3714
|
-
console.log("Providers (credential · base URL):");
|
|
3715
|
-
logLines(formatProviderPanel(statuses));
|
|
3716
|
-
console.log("Switch with: /provider <name> [model] · choose models: /model");
|
|
2986
|
+
// `/provider add --base-url <url> [--model <m>] [--compat openai]` → register an
|
|
2987
|
+
// OpenAI-compatible endpoint (sets openaiBaseUrl). gjc flag style; bare positional
|
|
2988
|
+
// URL/model still accepted for leniency. `/provider add clear` removes it.
|
|
2989
|
+
if (name === "add") {
|
|
2990
|
+
const rest = tokens.slice(1);
|
|
2991
|
+
if ((rest[0] ?? "").toLowerCase() === "clear") {
|
|
2992
|
+
await saveConfigPatch(() => ({ openaiBaseUrl: undefined }));
|
|
2993
|
+
await refreshLiveModelsCache();
|
|
2994
|
+
console.log("OpenAI-compatible base URL cleared — saved to ~/.jeo/config.json.");
|
|
3717
2995
|
continue;
|
|
3718
2996
|
}
|
|
3719
|
-
|
|
3720
|
-
|
|
3721
|
-
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
3725
|
-
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
let target = explicitModel ?? PROVIDER_DEFAULT[name];
|
|
3734
|
-
if (!explicitModel && providerPick.length && process.stdin.isTTY && process.stdout.isTTY) {
|
|
3735
|
-
const picked = await pickLiveProviderModel(name, providerPick, currentResolved, st && !st.ready && !selectableThoughNotReady(st) ? [name] : []);
|
|
3736
|
-
if (!picked) {
|
|
3737
|
-
console.log("(cancelled)");
|
|
2997
|
+
let baseUrl: string | undefined;
|
|
2998
|
+
let modelArg: string | undefined;
|
|
2999
|
+
let compat: string | undefined;
|
|
3000
|
+
for (let i = 0; i < rest.length; i++) {
|
|
3001
|
+
const t = rest[i]!;
|
|
3002
|
+
if (t === "--base-url" || t === "--url") baseUrl = rest[++i];
|
|
3003
|
+
else if (t === "--model") modelArg = rest[++i];
|
|
3004
|
+
else if (t === "--compat") compat = (rest[++i] ?? "").toLowerCase();
|
|
3005
|
+
else if (t === "--api-key-env" || t === "--provider") { i++; /* gjc-only: jeo has no named-provider registry — ignored */ }
|
|
3006
|
+
else if (!t.startsWith("--") && baseUrl === undefined) baseUrl = t; // positional url
|
|
3007
|
+
else if (!t.startsWith("--") && modelArg === undefined) modelArg = t; // positional model
|
|
3008
|
+
}
|
|
3009
|
+
if (compat && compat !== "openai") {
|
|
3010
|
+
console.log(`Only --compat openai is supported (got '${compat}') — jeo registers a single OpenAI-compatible endpoint.`);
|
|
3738
3011
|
continue;
|
|
3739
3012
|
}
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
if (sel.kind === "index" || sel.kind === "match") {
|
|
3745
|
-
target = qualifyModelId(sel.entry.model, sel.entry.provider);
|
|
3746
|
-
if (st && !st.ready) {
|
|
3747
|
-
if (selectableThoughNotReady(st)) {
|
|
3748
|
-
console.log(notReadyWarning(st));
|
|
3749
|
-
} else {
|
|
3750
|
-
console.log(`Cannot select ${sel.entry.model}: ${name} is not ready (${st.label}). Set ${st.envVar ?? "the provider key"} first.`);
|
|
3751
|
-
continue;
|
|
3752
|
-
}
|
|
3753
|
-
}
|
|
3754
|
-
} else if (sel.kind === "ambiguous") {
|
|
3755
|
-
console.log(`'${explicitModel}' matches ${sel.matches.length} ${name} models — be more specific:`);
|
|
3756
|
-
for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
|
|
3013
|
+
if (!baseUrl) {
|
|
3014
|
+
const cur = (await readGlobalConfig()).openaiBaseUrl;
|
|
3015
|
+
console.log(cur ? `OpenAI-compatible base URL: ${cur}` : "No OpenAI-compatible base URL set.");
|
|
3016
|
+
console.log("Set one with: /provider add --base-url <url> [--model <model>] · clear with: /provider add clear");
|
|
3757
3017
|
continue;
|
|
3758
|
-
}
|
|
3759
|
-
|
|
3018
|
+
}
|
|
3019
|
+
const url = normalizeBaseUrl(baseUrl, "");
|
|
3020
|
+
if (!url) {
|
|
3021
|
+
console.log("Provide a base URL, e.g. /provider add --base-url http://localhost:1234/v1");
|
|
3760
3022
|
continue;
|
|
3761
3023
|
}
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3024
|
+
await saveConfigPatch(() => ({ openaiBaseUrl: url }));
|
|
3025
|
+
if (modelArg) {
|
|
3026
|
+
const qualified = qualifyModelId(modelArg, "openai");
|
|
3027
|
+
sessionModel = qualified;
|
|
3028
|
+
await saveConfigPatch(raw => rememberModelPatch(raw, qualified));
|
|
3029
|
+
console.log(`OpenAI-compatible endpoint set: ${url} · default model ${qualified} — saved to ~/.jeo/config.json.`);
|
|
3030
|
+
} else {
|
|
3031
|
+
console.log(`OpenAI-compatible endpoint set: ${url} — saved to ~/.jeo/config.json.`);
|
|
3032
|
+
}
|
|
3033
|
+
const live = await refreshLiveModelsCache();
|
|
3034
|
+
const forProvider = live.filter(r => r.provider === "openai");
|
|
3035
|
+
const pick = flattenModels(forProvider);
|
|
3036
|
+
if (pick.length) {
|
|
3037
|
+
lastPickIndex = pick;
|
|
3038
|
+
console.log("Discovered models at this endpoint — select with /model #N:");
|
|
3039
|
+
logLines(formatPickListWithCapabilities(pick, { cap: 12 }));
|
|
3040
|
+
} else {
|
|
3041
|
+
const failed = forProvider.find(r => !r.ok);
|
|
3042
|
+
console.log(failed?.error ? ` could not list models: ${failed.error}` : " no models discovered yet — the endpoint may need a key (set OPENAI_API_KEY).");
|
|
3043
|
+
}
|
|
3770
3044
|
continue;
|
|
3771
3045
|
}
|
|
3772
|
-
|
|
3773
|
-
|
|
3046
|
+
// Bare `/provider` (or `help`): show readiness + the onboarding usage. No switching.
|
|
3047
|
+
if (!name || name === "help") {
|
|
3048
|
+
const cfgNow = await readGlobalConfig();
|
|
3049
|
+
const statuses = await describeAllProviders(cfgNow);
|
|
3050
|
+
console.log("Providers (credential · base URL):");
|
|
3051
|
+
logLines(formatProviderPanel(statuses));
|
|
3052
|
+
for (const line of providerOnboardingUsage()) console.log(line);
|
|
3774
3053
|
continue;
|
|
3775
3054
|
}
|
|
3776
|
-
|
|
3777
|
-
//
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
console.log(`Model set to ${formatModelLine({ label: target, resolved, provider, ready: st?.ready })} — saved as default`);
|
|
3781
|
-
// Show the provider's live, credentialed catalog so the user can pick a concrete id.
|
|
3782
|
-
if (providerPick.length) {
|
|
3783
|
-
lastPickIndex = providerPick;
|
|
3784
|
-
if (!pickedFromPicker) {
|
|
3785
|
-
console.log(`Live ${name} models — select with /model #N, /provider ${name} #N, or rerun /provider ${name} and use arrows+Enter:`);
|
|
3786
|
-
logLines(formatPickListWithCapabilities(lastPickIndex, { current: resolved }));
|
|
3787
|
-
}
|
|
3788
|
-
}
|
|
3055
|
+
// Anything else (a provider name) used to switch the active provider — that moved
|
|
3056
|
+
// to /model (gjc semantics: /provider onboards, /model selects).
|
|
3057
|
+
console.log(`'/provider ${name}' no longer switches providers — provider/model selection moved to /model.`);
|
|
3058
|
+
console.log("Run /model to pick any provider's model (or /model <id|#N>). /provider now only onboards — see /provider help.");
|
|
3789
3059
|
continue;
|
|
3790
3060
|
}
|
|
3791
3061
|
if (input.startsWith("/logout") && (input === "/logout" || input[7] === " ")) {
|
|
@@ -3964,11 +3234,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
3964
3234
|
for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
|
|
3965
3235
|
continue;
|
|
3966
3236
|
} else if (sel.kind === "out-of-range") {
|
|
3967
|
-
console.log(`#${modelArg.slice(1)} is out of range (1-${sel.max}). Use /model
|
|
3237
|
+
console.log(`#${modelArg.slice(1)} is out of range (1-${sel.max}). Use /model first.`);
|
|
3968
3238
|
continue;
|
|
3969
3239
|
}
|
|
3970
3240
|
} else if (modelArg.startsWith("#")) {
|
|
3971
|
-
console.log("Use /model
|
|
3241
|
+
console.log("Use /model first to build the numbered live model list.");
|
|
3972
3242
|
continue;
|
|
3973
3243
|
}
|
|
3974
3244
|
// Persist a per-role model override to ~/.jeo/config.json (consumed by 'jeo team').
|
|
@@ -4038,11 +3308,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
4038
3308
|
for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
|
|
4039
3309
|
continue;
|
|
4040
3310
|
} else if (sel.kind === "out-of-range") {
|
|
4041
|
-
console.log(`#${chosenModel.slice(1)} is out of range (1-${sel.max}). Use /model
|
|
3311
|
+
console.log(`#${chosenModel.slice(1)} is out of range (1-${sel.max}). Use /model first.`);
|
|
4042
3312
|
continue;
|
|
4043
3313
|
}
|
|
4044
3314
|
} else if (chosenModel.startsWith("#")) {
|
|
4045
|
-
console.log("Use /model
|
|
3315
|
+
console.log("Use /model first to build the numbered live model list.");
|
|
4046
3316
|
continue;
|
|
4047
3317
|
}
|
|
4048
3318
|
await saveConfigPatch(raw => ({ roles: { ...(raw.roles ?? {}), [tier]: chosenModel } }));
|
|
@@ -4123,12 +3393,12 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
4123
3393
|
for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
|
|
4124
3394
|
continue;
|
|
4125
3395
|
} else if (sel.kind === "out-of-range") {
|
|
4126
|
-
console.log(`#${toSave.slice(1)} is out of range (1-${sel.max}). Use /model
|
|
3396
|
+
console.log(`#${toSave.slice(1)} is out of range (1-${sel.max}). Use /model first.`);
|
|
4127
3397
|
continue;
|
|
4128
3398
|
}
|
|
4129
3399
|
// kind "none" → treat `toSave` as a literal model id/alias.
|
|
4130
3400
|
} else if (toSave.startsWith("#")) {
|
|
4131
|
-
console.log("Use /model
|
|
3401
|
+
console.log("Use /model first to build the numbered list.");
|
|
4132
3402
|
continue;
|
|
4133
3403
|
}
|
|
4134
3404
|
// Fall back to the FRESH on-disk default (not the stale session-start snapshot) so a
|
|
@@ -4196,11 +3466,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
4196
3466
|
for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
|
|
4197
3467
|
continue;
|
|
4198
3468
|
} else if (sel.kind === "out-of-range") {
|
|
4199
|
-
console.log(`#${roleModelArg.slice(1)} is out of range (1-${sel.max}). Use /model
|
|
3469
|
+
console.log(`#${roleModelArg.slice(1)} is out of range (1-${sel.max}). Use /model first.`);
|
|
4200
3470
|
continue;
|
|
4201
3471
|
}
|
|
4202
3472
|
} else if (roleModelArg.startsWith("#")) {
|
|
4203
|
-
console.log("Use /model
|
|
3473
|
+
console.log("Use /model first to build the numbered list.");
|
|
4204
3474
|
continue;
|
|
4205
3475
|
}
|
|
4206
3476
|
if (roleModelArg) {
|
|
@@ -4251,12 +3521,12 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
4251
3521
|
for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
|
|
4252
3522
|
continue;
|
|
4253
3523
|
} else if (sel.kind === "out-of-range") {
|
|
4254
|
-
console.log(`#${arg.slice(1)} is out of range (1-${sel.max}). Use /model
|
|
3524
|
+
console.log(`#${arg.slice(1)} is out of range (1-${sel.max}). Use /model first.`);
|
|
4255
3525
|
continue;
|
|
4256
3526
|
}
|
|
4257
3527
|
// kind "none" → fall through and treat `arg` as a literal model id/alias.
|
|
4258
3528
|
} else if (arg.startsWith("#")) {
|
|
4259
|
-
console.log("Use /model
|
|
3529
|
+
console.log("Use /model first to build the numbered list.");
|
|
4260
3530
|
continue;
|
|
4261
3531
|
}
|
|
4262
3532
|
const label = arg || (sessionModel || defaultModel);
|
|
@@ -4282,7 +3552,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
4282
3552
|
}
|
|
4283
3553
|
}
|
|
4284
3554
|
if (arg && liveModelsCache && resolved === label && !liveModelKnown(liveModelsCache, resolved)) {
|
|
4285
|
-
console.log(` (note: '${resolved}' is not in the live ${provider} catalog — use /model
|
|
3555
|
+
console.log(` (note: '${resolved}' is not in the live ${provider} catalog — use /model to pick a valid id)`);
|
|
4286
3556
|
}
|
|
4287
3557
|
const meta = catalogMetadata(resolved);
|
|
4288
3558
|
if (meta) console.log(` ${formatCapabilityLine(meta)}`);
|
|
@@ -4450,7 +3720,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
4450
3720
|
if (m.length) {
|
|
4451
3721
|
for (const line of formatSlashCommandList(input, skillSlashDetails)) console.log(line);
|
|
4452
3722
|
} else {
|
|
4453
|
-
|
|
3723
|
+
const cmds = [...completionContext().slashCommands];
|
|
3724
|
+
const near = suggestSlashCommands(input, cmds);
|
|
3725
|
+
console.log(near.length ? `Unknown command '${input}'. Did you mean ${near.join(", ")}?` : `Unknown command '${input}'. Try /help.`);
|
|
4454
3726
|
}
|
|
4455
3727
|
continue;
|
|
4456
3728
|
}
|