jeo-code 0.4.5 → 0.4.7

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.
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Built-in provider registration (the registry bootstrap).
3
+ *
4
+ * Importing this module for its side effect registers every bundled LLM adapter
5
+ * into the shared `providerRegistry`. `model-manager` then resolves adapters
6
+ * through the registry alone — it no longer imports, or even names, concrete
7
+ * providers. To add a new built-in provider, register it HERE only; nothing in
8
+ * `model-manager` changes.
9
+ */
10
+ import { providerRegistry } from "./provider-registry";
11
+ import { anthropicAdapter } from "./providers/anthropic";
12
+ import { openaiAdapter } from "./providers/openai";
13
+ import { geminiAdapter } from "./providers/gemini";
14
+ import { ollamaAdapter } from "./providers/ollama";
15
+ import { antigravityAdapter } from "./providers/antigravity";
16
+
17
+ providerRegistry.register("anthropic", anthropicAdapter);
18
+ providerRegistry.register("openai", openaiAdapter);
19
+ providerRegistry.register("gemini", geminiAdapter);
20
+ providerRegistry.register("antigravity", antigravityAdapter);
21
+ providerRegistry.register("ollama", ollamaAdapter);
package/src/ai/types.ts CHANGED
@@ -43,6 +43,10 @@ export interface CallOptions {
43
43
  /** Notified before each auto-retry backoff wait (rate limits / transient errors).
44
44
  * NOT forwarded to provider adapters — consumed by the manager's retry layer. */
45
45
  onRetry?: (attempt: number, err: unknown, delayMs: number) => void;
46
+ /** Streaming sink for native model reasoning/thinking text deltas (separate from the
47
+ * answer text). Surfaced as a transient dimmed view; absent for models that emit no
48
+ * thought text. */
49
+ onReasoning?: (delta: string) => void;
46
50
  }
47
51
 
48
52
  export interface ProviderAdapter {
package/src/cli/runner.ts CHANGED
@@ -172,15 +172,6 @@ export const COMMANDS: readonly CommandSpec[] = [
172
172
  return args => m.runUpdateCommand(args);
173
173
  },
174
174
  },
175
- {
176
- name: "gjc",
177
- summary: "Run the gjc workflow skill as an autonomous build loop (plan → implement → verify).",
178
- usage: "gjc \"<intent>\"",
179
- loader: async () => {
180
- const m = await import("../commands/gjc");
181
- return args => m.runGjcCommand(args);
182
- },
183
- },
184
175
  {
185
176
  name: "ooo-seed",
186
177
  summary: "Generate an immutable ooo seed from a specification (spec-first automation).",
@@ -3,6 +3,8 @@ import { runAgentLoop, executorSystemPrompt, DEFAULT_TOOLS, TOOL_PROTOCOL, WORKI
3
3
  import { initialDynamicStepLimit } from "../agent/step-budget";
4
4
  import { memoryPromptSection, spawnDetachedDistill } from "../agent/memory";
5
5
  import { createTaskTool, taskToolProtocolLine, type TaskSubEvent } from "../agent/task-tool";
6
+ import { createSubagentTool, SUBAGENT_TOOL_PROTOCOL_LINE } from "../agent/subagent-tool";
7
+ import { SubagentRegistry } from "../agent/subagent-registry";
6
8
  import { createTodoTool, TODO_TOOL_PROTOCOL_LINE } from "../agent/todo-tool";
7
9
  import { LaunchTui } from "../tui/app";
8
10
  import { runDeepInterviewEngine } from "./deep-interview";
@@ -19,7 +21,7 @@ import { staticCompletionContext, readlineCompleter, formatCompletionPreview, to
19
21
  import { EVOLUTION_STAGES, animateAsciiArt } from "../tui/components/ascii-art";
20
22
  import { getEvolutionTip } from "../tui/components/evolution";
21
23
  import { renderWelcome, playWelcomeSweep } from "../tui/components/welcome";
22
- import { checkForUpdate } from "../util/update-check";
24
+ import { checkForUpdate, readUpdateCache, writeUpdateCache } from "../util/update-check";
23
25
  import { jeoEnv } from "../util/env";
24
26
  import { renderUpdateBox } from "../tui/components/update-box";
25
27
  import { supportsUnicode } from "../tui/components/capability";
@@ -379,7 +381,8 @@ function streamResultSuffix(tool: string, ok: boolean, output: string | undefine
379
381
 
380
382
  export function formatTaskSubEvent(e: TaskSubEvent): string {
381
383
  const role = e.role || "subagent";
382
- const roleLabel = role.toUpperCase();
384
+ const roleLabel = e.index && e.total ? `${role.toUpperCase()}[${e.index}/${e.total}]` : role.toUpperCase();
385
+ const tokTag = e.tokens ? ` (${e.tokens.input + e.tokens.output} tok)` : "";
383
386
  const detail = firstOutputLine(e.detail);
384
387
  const summary = e.summary ? ` — ${e.summary}` : "";
385
388
  // No ` step N/M` marker — step counters carry no meaning under the dynamic
@@ -390,7 +393,7 @@ export function formatTaskSubEvent(e: TaskSubEvent): string {
390
393
  if (e.kind === "step") return ` ${badge} ${chalk.cyan(`├─ ${roleLabel}`)} · ${detail || "working"}`;
391
394
  if (e.kind === "tool") return ` ${badge} ${e.success === false ? chalk.red("├─") : chalk.green("├─")} ${roleLabel} ${e.success === false ? chalk.red("✗") : chalk.green("✓")} ${detail || "tool"}${summary}`;
392
395
  if (e.kind === "error") return ` ${badge} ${chalk.red("├─")} ${roleLabel} ${chalk.red("✗")} ${detail || "error"}`;
393
- return `${badge} ${e.success === false ? chalk.red("└─") : chalk.green("└─")} ${roleLabel} done${e.success === false ? " (incomplete)" : ""}${detail ? `: ${detail}` : ""}`;
396
+ return `${badge} ${e.success === false ? chalk.red("└─") : chalk.green("└─")} ${roleLabel} done${tokTag}${e.success === false ? " (incomplete)" : ""}${detail ? `: ${detail}` : ""}`;
394
397
  }
395
398
 
396
399
  function logTaskSubEvent(e: TaskSubEvent, log: (line: string) => void = (s: string) => console.log(s)): void {
@@ -1141,7 +1144,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1141
1144
  // pi-style: load project context (JEO.md / AGENTS.md / .jeo/context.md / CLAUDE.md) into the prompt.
1142
1145
  const contextFiles = await loadProjectContext(cwd);
1143
1146
 
1144
- const KNOWN_TOOLS = new Set(["read", "write", "edit", "bash", "find", "search", "ls", "task", "todo"]);
1147
+ const KNOWN_TOOLS = new Set(["read", "write", "edit", "bash", "find", "search", "ls", "task", "todo", "subagent"]);
1145
1148
  let allowedTools = new Set(KNOWN_TOOLS);
1146
1149
 
1147
1150
  if (flags.noTools) {
@@ -1196,6 +1199,8 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1196
1199
  (allowedTools.has("task") ? "\n\nDelegation: " + taskToolProtocolLine(cfg) +
1197
1200
  " Call task with {\"role\": <one of the advertised roles>, \"task\": <assignment>, \"context\": <optional>} to hand a focused slice to a subagent." : "") +
1198
1201
  (allowedTools.has("todo") ? "\n\nPlanning: " + TODO_TOOL_PROTOCOL_LINE : "") +
1202
+ (allowedTools.has("subagent") ? "\n\nDetached subagents: " + SUBAGENT_TOOL_PROTOCOL_LINE +
1203
+ " Launch background work with task {\"detached\": true, \"role\": <role>, \"task\": <assignment>}; it returns a subagent id immediately so you can keep working and collect the result later." : "") +
1199
1204
  (effectiveNoSkills ? "" :
1200
1205
  "\n\nJEO workflow routing:\n" +
1201
1206
  "- Answer the user's request DIRECTLY. Never reply with a catalog, list, or summary of skills unless the user explicitly asks what skills exist.\n" +
@@ -1297,6 +1302,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1297
1302
  // box during a running turn so typed text stays in the same query surface
1298
1303
  // instead of a separate queued row.
1299
1304
  let queueBusySnapshot: (() => { text: string }) | undefined;
1305
+ // Clears the live next-prompt draft — used after a mid-turn Enter is lifted into
1306
+ // the steering inbox so the consumed line does not also become the next prompt.
1307
+ let queueBusyClear: (() => void) | undefined;
1300
1308
  let interactiveTurnActive = false;
1301
1309
 
1302
1310
 
@@ -1311,42 +1319,61 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1311
1319
  const activeModel = sessionModel || turnConfig.defaultModel;
1312
1320
  const contextTokens = catalogMetadata(activeModel)?.contextTokens;
1313
1321
 
1314
- const compRes = await maybeCompact(history, {
1315
- model: sessionModel,
1316
- contextTokens,
1317
- });
1318
-
1319
- if (compRes.error) {
1320
- throw new Error(compRes.error);
1321
- }
1322
-
1323
- if (compRes.compacted && sessionId && compRes.replacesThrough !== undefined) {
1324
- const touchedNote = compRes.touchedFiles?.length ? ` Files touched: ${compRes.touchedFiles.join(", ")}.` : "";
1325
- const summaryText = compRes.summary ?? `[Earlier conversation omitted: ${compRes.removed} messages — summary unavailable.${touchedNote}]`;
1326
- await appendCompaction(sessionId, ++compactionSeq, summaryText, compRes.replacesThrough, cwd);
1327
- }
1328
-
1329
- const beforeLen = history.length;
1330
- if (images?.length && catalogMetadata(activeModel)?.images === false) {
1331
- console.log(`! ${activeModel} does not advertise image input — sending the attachment anyway.`);
1332
- }
1333
- history.push(images?.length ? { role: "user", content: userInput, images } : { role: "user", content: userInput });
1334
-
1335
- // `turnConfig` was read before compaction so both the compactor and delegated
1336
- // task tool see mid-session config changes (e.g. `/agents <role> <model>`).
1337
- const { provider: activeProvider } = await describeModel(activeModel);
1338
- // Dirty count is recomputed at each turn start (gjc parity P1.B5: per-turn, not
1339
- // per-render) so `?N` grows as the agent edits files; one spawn/turn, not per frame.
1322
+ // Resolve provider + dirty count up front — both are cheap and feed the live
1323
+ // frame's footer. `turnConfig` is reused so describeModel does NOT re-read the
1324
+ // config file. (gjc parity P1.B5: dirty count per-turn, not per-render.)
1325
+ const { provider: activeProvider } = await describeModel(activeModel, turnConfig);
1340
1326
  const turnDirtyCount = branch ? gitDirtyCount(cwd) : undefined;
1341
1327
  const tui = useTui ? new LaunchTui({ model: activeModel, provider: activeProvider, sessionId, maxSteps: initialStepLimit, cwd, branch, dirtyCount: turnDirtyCount, thinking: sessionThinking }) : null;
1342
- tui?.setContextUsage(historyTokens(history), contextTokens);
1343
1328
  tui?.setTurnTitle(userInput); // gjc-parity turn title → HUD + tmux pane title (no LLM call)
1329
+ // `beforeLen` marks where this turn's appended messages start; it is re-read
1330
+ // AFTER compaction (which mutates history) and consumed by the post-turn
1331
+ // persistence block below.
1332
+ let beforeLen = history.length;
1344
1333
  let result;
1345
1334
  try {
1335
+ // Paint the live frame + spinner the INSTANT the turn is accepted, BEFORE the
1336
+ // potentially slow / LLM-driven compaction below. Otherwise the gap between the
1337
+ // submitted prompt and the first feedback reads as a dead "no response" window
1338
+ // (the reported symptom). All remaining preflight runs UNDER the spinner now.
1346
1339
  if (tui) {
1347
1340
  interactiveTurnActive = true;
1348
1341
  tui.start();
1349
1342
  }
1343
+ const compRes = await maybeCompact(history, {
1344
+ model: sessionModel,
1345
+ contextTokens,
1346
+ });
1347
+ if (compRes.error) {
1348
+ throw new Error(compRes.error);
1349
+ }
1350
+ if (compRes.compacted && sessionId && compRes.replacesThrough !== undefined) {
1351
+ const touchedNote = compRes.touchedFiles?.length ? ` Files touched: ${compRes.touchedFiles.join(", ")}.` : "";
1352
+ const summaryText = compRes.summary ?? `[Earlier conversation omitted: ${compRes.removed} messages — summary unavailable.${touchedNote}]`;
1353
+ await appendCompaction(sessionId, ++compactionSeq, summaryText, compRes.replacesThrough, cwd);
1354
+ tui?.events().onNotice?.(`(compacted ${compRes.removed} older message${compRes.removed === 1 ? "" : "s"})`);
1355
+ }
1356
+ beforeLen = history.length;
1357
+ if (images?.length && catalogMetadata(activeModel)?.images === false) {
1358
+ const warn = `! ${activeModel} does not advertise image input — sending the attachment anyway.`;
1359
+ if (tui) tui.events().onNotice?.(warn);
1360
+ else console.log(warn);
1361
+ }
1362
+ history.push(images?.length ? { role: "user", content: userInput, images } : { role: "user", content: userInput });
1363
+ // Keep the submitted query in scrollback: the prompt that STARTS a turn shows
1364
+ // only as the transient HUD turn-title otherwise, which vanishes when the live
1365
+ // frame clears at turn-end — so the conversation transcript lost every user
1366
+ // prompt. Flush a `user` card (same surface as a mid-turn steer) so it persists.
1367
+ if (tui && userInput.trim()) tui.flushUserCard(userInput);
1368
+ tui?.setContextUsage(historyTokens(history), contextTokens);
1369
+
1370
+ // Per-turn steering inbox (gjc parity): additional queries typed mid-turn land
1371
+ // here and the engine drains them at each step boundary; createTaskTool forwards
1372
+ // the same drain so a single running subagent receives them live. Unconsumed
1373
+ // messages are folded into the next prompt in the finally block (race safety).
1374
+ const steerInbox: string[] = [];
1375
+ const steeringEnabled = !!tui && jeoEnv("NO_STEER") !== "1";
1376
+ const drainSteer = () => steerInbox.splice(0, steerInbox.length);
1350
1377
  const harness = createInFlightAbortHarness({
1351
1378
  captureEsc: !!tui,
1352
1379
  onNoise: () => tui?.repaint(),
@@ -1368,11 +1395,38 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1368
1395
  },
1369
1396
  onBufferedInput: chunk => {
1370
1397
  if (!tui) return;
1398
+ // gjc-style mid-turn steering: a typed Enter (outside a bracketed paste)
1399
+ // lifts the current draft into the steering inbox so the RUNNING turn picks
1400
+ // it up at the next step, instead of only becoming the next prompt.
1401
+ // JEO_NO_STEER=1 restores the legacy draft-only behavior.
1402
+ const typedEnter =
1403
+ steeringEnabled &&
1404
+ !(queueBusyPasteActive?.() ?? false) &&
1405
+ /[\r\n]/.test(chunk) &&
1406
+ !chunk.includes(PASTE_START) &&
1407
+ !chunk.includes(PASTE_END);
1371
1408
  const captured = queueBusyInput?.(chunk) ?? false;
1372
- // Keep the SAME query input box visible during a live turn. Printable
1373
- // keystrokes edit the next prompt draft; Enter does not create a hidden
1374
- // queue entry, so there is no separate "queued input" surface.
1375
- if (captured) tui.setLivePromptInput(queueBusySnapshot?.().text ?? "");
1409
+ if (typedEnter) {
1410
+ const line = (queueBusySnapshot?.().text ?? "").trim();
1411
+ if (line) {
1412
+ steerInbox.push(line);
1413
+ queueBusyClear?.();
1414
+ tui.setLivePromptInput("");
1415
+ // Surface the steered query as a `user` card in scrollback so it reads
1416
+ // as an accepted input that started work — not just a transient notice.
1417
+ tui.flushSteerCard(line);
1418
+ return;
1419
+ }
1420
+ }
1421
+ // Mid-turn additional input is committed (and shown) ONLY on Enter (above):
1422
+ // the running turn does NOT echo half-typed text per keystroke. Captured
1423
+ // printable input accumulates silently in the draft buffer and surfaces as a
1424
+ // `user` card the moment Enter lifts it into the steering inbox (or folds into
1425
+ // the next prompt if the turn ends first). JEO_LIVE_DRAFT=1 restores the
1426
+ // legacy live per-keystroke echo in the input box.
1427
+ if (captured && jeoEnv("LIVE_DRAFT") === "1") {
1428
+ tui.setLivePromptInput(queueBusySnapshot?.().text ?? "");
1429
+ }
1376
1430
  },
1377
1431
  onAbortNotice: msg => {
1378
1432
  if (tui) tui.events().onNotice?.(msg);
@@ -1384,6 +1438,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1384
1438
  },
1385
1439
  });
1386
1440
  const ac = harness.controller;
1441
+ // #9: per-turn registry for DETACHED subagents (task{detached:true}); the
1442
+ // `subagent` tool controls them and cancelAll() in finally prevents orphans.
1443
+ const subagentRegistry = new SubagentRegistry();
1387
1444
  try {
1388
1445
  // Per-turn todo snapshot: drives the done-time reconciliation gate (the
1389
1446
  // Todos checklist used to end a finished turn stuck at "✓0 ◐1 ·4 / 5"
@@ -1403,11 +1460,14 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1403
1460
  task: createTaskTool({
1404
1461
  config: { ...turnConfig, defaultModel: activeModel },
1405
1462
  signal: ac.signal,
1463
+ steer: drainSteer,
1464
+ registry: subagentRegistry,
1406
1465
  onEvent: useTui
1407
1466
  ? (e => tui?.onSubagentEvent(e))
1408
1467
  : (e => logTaskSubEvent(e)),
1409
1468
  }),
1410
1469
  todo: createTodoTool({ onChange: items => { turnTodos = items; tui?.setTodos(items); } }),
1470
+ subagent: createSubagentTool(subagentRegistry),
1411
1471
  };
1412
1472
  const tools = filterToolMap(fullTools, Array.from(allowedTools));
1413
1473
  result = await runAgentLoop(history, {
@@ -1417,6 +1477,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1417
1477
  model: sessionModel,
1418
1478
  maxTokens: sessionThinking ? thinkingMaxTokens(sessionThinking) : undefined,
1419
1479
  signal: ac.signal,
1480
+ steer: drainSteer,
1420
1481
  events: { ...withToolDetailCapture(tui ? tui.events() : streamEvents), onBeforeDone },
1421
1482
  });
1422
1483
  if (result.done && looksLikeSkillEcho(result.doneReason ?? "", resolvedSkills)) {
@@ -1434,6 +1495,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1434
1495
  model: sessionModel,
1435
1496
  maxTokens: sessionThinking ? thinkingMaxTokens(sessionThinking) : undefined,
1436
1497
  signal: ac.signal,
1498
+ steer: drainSteer,
1437
1499
  events: withToolDetailCapture(tui ? tui.events() : streamEvents),
1438
1500
  });
1439
1501
  const usage =
@@ -1447,6 +1509,14 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1447
1509
  }
1448
1510
  } finally {
1449
1511
  harness.dispose();
1512
+ subagentRegistry.cancelAll(); // #9: no detached run leaks past the turn
1513
+ // Steering typed but never drained (e.g. entered just after the final step)
1514
+ // must not be lost — fold it into the next prompt draft so it runs next.
1515
+ const leftover = steerInbox.splice(0, steerInbox.length).map(s => s.trim()).filter(Boolean);
1516
+ if (leftover.length) {
1517
+ const merged = [queuedPromptInput.partial, ...leftover].filter(Boolean).join(" ");
1518
+ queuedPromptInput.partial = merged;
1519
+ }
1450
1520
  }
1451
1521
  } catch (err) {
1452
1522
  if (tui) {
@@ -1612,6 +1682,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1612
1682
 
1613
1683
  // INTERACTIVE mode
1614
1684
  const updatePromise = checkForUpdate({ timeoutMs: 2500 });
1685
+ // Refresh the on-disk update cache for the NEXT launch regardless of whether
1686
+ // this launch's bounded wait below catches the result. Screen-safe: writes
1687
+ // only, never renders (rendering after the prompt is armed would corrupt the
1688
+ // boxed input footer).
1689
+ void updatePromise.then(u => { if (u) void writeUpdateCache(u.latest); }).catch(() => {});
1615
1690
  // Terminal hygiene BEFORE anything renders: a previous program (or stale tmux
1616
1691
  // pane) can leave xterm mouse-tracking ON, so the terminal reports clicks and
1617
1692
  // motion as escape sequences from the very first prompt — the "starts out
@@ -1647,8 +1722,18 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1647
1722
  if (sweepable) await playWelcomeSweep(welcomeData, { cycles: sweepCycles });
1648
1723
  else console.log(renderWelcome(welcomeData).join("\n"));
1649
1724
 
1650
- const upd = await Promise.race([updatePromise, new Promise<null>(r => setTimeout(() => r(null), 1200))]);
1651
- if (upd?.updateAvailable) console.log(renderUpdateBox(upd.current, upd.latest).join("\n"));
1725
+ // Surface the "New version" banner reliably: render ONCE from the on-disk cache
1726
+ // instantly (no network wait, works offline — the common path after the first
1727
+ // successful check), and ALSO from a bounded live check so a first run / version
1728
+ // bump still shows it this launch. Both must run BEFORE the prompt is armed.
1729
+ let updateBannerShown = false;
1730
+ const showUpdateBanner = (u: { current: string; latest: string; updateAvailable: boolean } | null): void => {
1731
+ if (updateBannerShown || !u?.updateAvailable) return;
1732
+ updateBannerShown = true;
1733
+ console.log(renderUpdateBox(u.current, u.latest).join("\n"));
1734
+ };
1735
+ showUpdateBanner(await readUpdateCache(pkg.version));
1736
+ showUpdateBanner(await Promise.race([updatePromise, new Promise<null>(r => setTimeout(() => r(null), 1200))]));
1652
1737
  if (!LaunchTui.usable(flags.noTui)) console.log("(plain output)");
1653
1738
 
1654
1739
  const useTui = LaunchTui.usable(flags.noTui);
@@ -1800,6 +1885,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1800
1885
  };
1801
1886
  };
1802
1887
  let previewArmed = false;
1888
+ // True while a prompt line is being awaited. Folded into the readline output
1889
+ // gate so native echo is suppressed for the WHOLE await window — including the
1890
+ // brief gap between turn-end and armPreview() — so no keystroke can leak into
1891
+ // scrollback before the boxed footer takes over.
1892
+ let promptActive = false;
1803
1893
  let pickerActive = false;
1804
1894
  const rl = createInterface({
1805
1895
  input: process.stdin,
@@ -1810,7 +1900,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1810
1900
  // previously OPENED the gate and let readline echo typed filter characters
1811
1901
  // (CJK wide chars especially) straight onto the picker frame — the
1812
1902
  // "stacked input-box borders" corruption.
1813
- output: gatedStdout(process.stdout, () => previewArmed || pickerActive || interactiveTurnActive),
1903
+ output: gatedStdout(process.stdout, () => previewArmed || promptActive || pickerActive || interactiveTurnActive),
1814
1904
  completer: (line: string) => readlineCompleter(line, completionContext()),
1815
1905
  });
1816
1906
  const promptStdin = process.stdin as typeof process.stdin & { isRaw?: boolean; setRawMode?(raw: boolean): void };
@@ -1839,6 +1929,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1839
1929
  queueBusySnapshot = () => ({
1840
1930
  text: queuedPromptInput.partial,
1841
1931
  });
1932
+ queueBusyClear = () => { queuedPromptInput.partial = ""; };
1842
1933
  // Bracketed-paste line routing at the PROMPT: readline strips the 2004 markers
1843
1934
  // and replays pasted lines as synthetic keypresses, emitting paste-start /
1844
1935
  // paste-end around them. Lines submitted INSIDE that window are intentional
@@ -1897,6 +1988,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1897
1988
  if (hardExitOnLoopEnd || process.stdin.isTTY || process.stdout.isTTY) forceExitFromCtrlC();
1898
1989
  return "/exit";
1899
1990
  }
1991
+ promptActive = true;
1900
1992
  try {
1901
1993
  return await Promise.race([
1902
1994
  rl.question(prompt),
@@ -1908,6 +2000,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1908
2000
  }),
1909
2001
  ]);
1910
2002
  } finally {
2003
+ promptActive = false;
1911
2004
  notifyStdinClosed = undefined;
1912
2005
  }
1913
2006
  };
@@ -1955,10 +2048,16 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1955
2048
  const MAX_PREVIEW_ROWS = 12;
1956
2049
  const MIN_PREVIEW_ROWS = 7; // status bar (1) + spacer (1) + input box (3 rows) + 2 preview rows
1957
2050
  const previewRowsFor = (rows: number): number => Math.max(MIN_PREVIEW_ROWS, Math.min(MAX_PREVIEW_ROWS, rows - 6));
2051
+ // Enable the boxed input footer for ANY interactive TTY. It paints inside a
2052
+ // reserved bottom region (never scrollback) and only commits the line on
2053
+ // Enter, so typed characters never leak into the conversation history while
2054
+ // typing. previewRowsFor() clamps the reservation height for short terminals,
2055
+ // so even small panes get the box instead of the raw `jeo>` echo fallback
2056
+ // (which echoes every keystroke straight into scrollback). The raw fallback is
2057
+ // now reserved for non-TTY/piped input, where live history echo is moot.
1958
2058
  const previewEnabled =
1959
2059
  process.stdin.isTTY &&
1960
- jeoEnv("NO_SLASH_PREVIEW") !== "1" &&
1961
- (process.stdout.rows ?? 24) >= MIN_PREVIEW_ROWS + 6; // box + ≥6 scrollable content rows
2060
+ jeoEnv("NO_SLASH_PREVIEW") !== "1";
1962
2061
  // Footer height reserved by the CURRENTLY armed region; disarm/draw must use the
1963
2062
  // same value the arm computed, even if the terminal was resized in between.
1964
2063
  let footerRows = MAX_PREVIEW_ROWS;
@@ -2112,7 +2211,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2112
2211
  };
2113
2212
  const historyPreviewLines = (detail: string[]): string[] => {
2114
2213
  const cols = Math.max(24, (process.stdout.columns ?? 80) - 1);
2115
- const title = `${chalk.cyan.bold("history")} ${chalk.gray("· Ctrl+O closes")}`;
2214
+ const title = `${uiAccent("history")} ${chalk.dim("· Ctrl+O closes")}`;
2116
2215
  const budget = Math.max(0, footerRows - 2);
2117
2216
  const physical = detail.flatMap(line => line.split("\n")).map(line => truncateAnsi(line, cols));
2118
2217
  let body = physical;
@@ -2833,29 +2932,34 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2833
2932
  console.log(chalk.dim(`(restored ${folded} queued input line${folded > 1 ? "s" : ""} into the prompt — Enter to run, Esc to discard)`));
2834
2933
  }
2835
2934
  }
2836
- // Refresh the status bar's dirty flag once per prompt (one git spawn, not per frame).
2837
- idleDirtyCount = branch ? gitDirtyCount(cwd) : undefined;
2838
2935
  const prefilledLine = queuedPromptInput.partial;
2839
2936
  queuedPromptInput.partial = "";
2840
- armPreview();
2841
- // Render the boxed input immediately so the prompt is visible even though
2842
- // readline's own echo is suppressed. If the user typed while the previous
2843
- // live turn/subagent was still running, seed that text into readline and the
2844
- // box instead of dropping it as "noise". A pasted batch's TRAILING partial
2845
- // (no final newline) survives in readline's own buffer — adopt it as the
2846
- // visible typed line so the box never hides editable input.
2937
+ // Resolve the visible input text FIRST (cheap, no I/O): the queued prefill, else
2938
+ // any partial the user typed while the previous live turn/subagent was running
2939
+ // (that text survives in readline's own buffer adopt it so the box never hides
2940
+ // editable input). A pasted batch's TRAILING partial is recovered the same way.
2847
2941
  const rli = rl as unknown as { line?: string; cursor?: number; _refreshLine?: () => void };
2848
2942
  const residualPartial = !prefilledLine && typeof rli.line === "string" && rli.line.length > 0 && !/\x1b/.test(rli.line)
2849
2943
  ? rli.line
2850
2944
  : "";
2851
2945
  typedLine = prefilledLine || residualPartial;
2946
+ navMatches = [];
2947
+ navIdx = -1;
2948
+ // Reserve + paint the boxed input IMMEDIATELY — WITH its real text — so the
2949
+ // prompt is visible the instant the turn ends, BEFORE the git spawn below.
2950
+ // Otherwise the dirty-flag spawn (slow on a large / just-edited repo) runs in a
2951
+ // window where the box is gone and keystrokes echo nowhere (the "no response
2952
+ // after the result" gap). readline's own echo stays gated while armed.
2953
+ armPreview();
2852
2954
  if (prefilledLine) {
2853
2955
  rli.line = prefilledLine;
2854
2956
  rli.cursor = prefilledLine.length;
2855
2957
  rli._refreshLine?.();
2856
2958
  }
2857
- navMatches = [];
2858
- navIdx = -1;
2959
+ drawFooter(previewLines(typedLine));
2960
+ // Refresh the status bar's dirty flag once per prompt (one git spawn, not per
2961
+ // frame); the second drawFooter repaints only if the count actually changed.
2962
+ idleDirtyCount = branch ? gitDirtyCount(cwd) : undefined;
2859
2963
  drawFooter(previewLines(typedLine));
2860
2964
  // Box mode: NO raw `jeo>` prompt at all — the boxed footer IS the input UI
2861
2965
  // (gating already suppresses readline echo, the empty prompt guarantees no
@@ -3171,6 +3275,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3171
3275
  for (const line of TOOL_PROTOCOL.split("\n")) console.log(` ${line}`);
3172
3276
  console.log(` ${taskToolProtocolLine(await readGlobalConfig())}`);
3173
3277
  console.log(` ${TODO_TOOL_PROTOCOL_LINE}`);
3278
+ console.log(` ${SUBAGENT_TOOL_PROTOCOL_LINE}`);
3174
3279
  continue;
3175
3280
  }
3176
3281
  if (input === "/hotkeys") {
@@ -403,7 +403,8 @@ async function executeTaskWithAgent(ctx: RalphSubagentPromptContext & { cwd: str
403
403
  // LLM summary failure does not halt team
404
404
  }
405
405
 
406
- let mutationsOk = 0; // round-8 parent audit: successful write/edit/bash count
406
+ let fileMutations = 0; // round-8 parent audit: successful write/edit/mkdir/delete
407
+ let bashRuns = 0; // bash counted apart so read-only bash isn't edit evidence
407
408
  const result = await runAgentLoop(history, {
408
409
  cwd: ctx.cwd,
409
410
  model,
@@ -429,7 +430,10 @@ async function executeTaskWithAgent(ctx: RalphSubagentPromptContext & { cwd: str
429
430
  }
430
431
  },
431
432
  onToolResult: (tool, ok) => {
432
- if (ok && (tool === "write" || tool === "edit" || tool === "bash")) mutationsOk++;
433
+ if (ok) {
434
+ if (tool === "write" || tool === "edit" || tool === "mkdir" || tool === "delete") fileMutations++;
435
+ else if (tool === "bash") bashRuns++;
436
+ }
433
437
  console.log(formatRalphStreamEvent(ok ? "complete" : "error", `tool ${tool}`, renderOpts));
434
438
  },
435
439
  onNotice: msg => console.log(formatRalphStreamEvent("step", msg, renderOpts)),
@@ -454,11 +458,14 @@ async function executeTaskWithAgent(ctx: RalphSubagentPromptContext & { cwd: str
454
458
  return false;
455
459
  }
456
460
 
457
- if (!role.readOnly && mutationsOk === 0) {
458
- // Round-8: a mutating role finished without ONE successful mutation — the
461
+ if (!role.readOnly && fileMutations === 0) {
462
+ // Round-8: a mutating role finished without a successful file mutation — the
459
463
  // task may be legitimately read-only, but its "Changed Files:" claim is
460
- // unverified; warn instead of silently trusting the report.
461
- console.log(formatRalphStreamEvent("error", `${role.title} completed WITHOUT any successful write/edit/bash — treat its changed-files claim as unverified.`, renderOpts));
464
+ // unverified. bash is tracked apart: an only-bash run MIGHT have mutated.
465
+ const msg = bashRuns === 0
466
+ ? `${role.title} completed WITHOUT any successful write/edit/bash — treat its changed-files claim as unverified.`
467
+ : `${role.title} completed with only bash (no write/edit) — verify its changed-files claim independently.`;
468
+ console.log(formatRalphStreamEvent("error", msg, renderOpts));
462
469
  }
463
470
  console.log(formatRalphStreamEvent("complete", `${role.title} finished task`, renderOpts));
464
471
  return true;
@@ -3,7 +3,6 @@ import deepDiveSkillRaw from "../prompts/skills/deep-dive/SKILL.md" with { type:
3
3
  import ralplanSkillRaw from "../prompts/skills/ralplan/SKILL.md" with { type: "text" };
4
4
  import teamSkillRaw from "../prompts/skills/team/SKILL.md" with { type: "text" };
5
5
  import ultragoalSkillRaw from "../prompts/skills/ultragoal/SKILL.md" with { type: "text" };
6
- import gjcSkillRaw from "../prompts/skills/gjc/SKILL.md" with { type: "text" };
7
6
 
8
7
  const MAX_SKILL_SUMMARY_CHARS = 180;
9
8
  const MAX_SKILL_DETAILS_CHARS = 8_000;
@@ -29,7 +28,6 @@ export const SKILLS: SkillDoc[] = [
29
28
  parseSkillMarkdown("ralplan", ralplanSkillRaw),
30
29
  parseSkillMarkdown("team", teamSkillRaw),
31
30
  parseSkillMarkdown("ultragoal", ultragoalSkillRaw),
32
- parseSkillMarkdown("gjc", gjcSkillRaw),
33
31
  ];
34
32
  export const BUILTIN_SKILL_NAMES = SKILLS.map(s => s.name.toLowerCase());
35
33