lazyclaw 6.3.0 → 6.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +5 -1
- package/README.md +17 -13
- package/agents.mjs +54 -4
- package/channels-discord/index.mjs +5 -1
- package/channels-email/index.mjs +4 -3
- package/channels-whatsapp/index.mjs +3 -2
- package/chat_window.mjs +11 -2
- package/cli.mjs +103 -3
- package/commands/agents.mjs +6 -1
- package/commands/auth_nodes.mjs +19 -1
- package/commands/automation.mjs +20 -5
- package/commands/channels.mjs +46 -3
- package/commands/chat.mjs +95 -37
- package/commands/chat_hardening.mjs +23 -0
- package/commands/daemon.mjs +99 -1
- package/commands/gateway.mjs +123 -4
- package/commands/mcp.mjs +150 -0
- package/commands/misc.mjs +20 -0
- package/commands/sessions.mjs +68 -8
- package/commands/setup.mjs +44 -8
- package/commands/setup_channels.mjs +219 -47
- package/commands/skills.mjs +19 -1
- package/commands/workflow_named.mjs +137 -0
- package/config-validate.mjs +10 -1
- package/config_features.mjs +45 -0
- package/cron.mjs +19 -8
- package/daemon/lib/auth.mjs +25 -0
- package/daemon/lib/cost.mjs +36 -0
- package/daemon/lib/respond.mjs +20 -1
- package/daemon/lib/team_inbound.mjs +69 -0
- package/daemon/route_table.mjs +5 -1
- package/daemon/routes/_deps.mjs +2 -2
- package/daemon/routes/config.mjs +11 -6
- package/daemon/routes/conversation.mjs +103 -40
- package/daemon/routes/events.mjs +45 -0
- package/daemon/routes/meta.mjs +38 -7
- package/daemon/routes/providers.mjs +26 -6
- package/daemon/routes/workflows.mjs +30 -0
- package/daemon.mjs +26 -2
- package/dotenv_min.mjs +15 -3
- package/gateway/challenge_registry.mjs +90 -0
- package/gateway/device_auth.mjs +71 -97
- package/gateway/http_gateway.mjs +29 -3
- package/lib/args.mjs +69 -3
- package/lib/config.mjs +85 -3
- package/lib/render.mjs +43 -0
- package/lib/service_install.mjs +15 -1
- package/mas/agent_turn.mjs +61 -12
- package/mas/confidence.mjs +20 -1
- package/mas/embedder.mjs +100 -0
- package/mas/events.mjs +57 -0
- package/mas/index_db.mjs +103 -10
- package/mas/learning.mjs +96 -72
- package/mas/mention_router.mjs +48 -90
- package/mas/prompt_stack.mjs +60 -3
- package/mas/recall_blend.mjs +56 -0
- package/mas/redact.mjs +55 -0
- package/mas/router_posting.mjs +96 -0
- package/mas/router_termination.mjs +63 -0
- package/mas/scrub_env.mjs +21 -6
- package/mas/skill_synth.mjs +7 -33
- package/mas/tool_runner.mjs +3 -2
- package/mas/tools/bash.mjs +9 -2
- package/mas/tools/coding.mjs +28 -5
- package/mas/tools/delegation.mjs +34 -4
- package/mas/tools/git.mjs +19 -9
- package/mas/tools/ha.mjs +2 -0
- package/mas/tools/learning.mjs +55 -0
- package/mas/tools/media.mjs +21 -3
- package/mas/tools/os.mjs +70 -16
- package/mas/tools/recall.mjs +28 -2
- package/mcp/client.mjs +8 -1
- package/mcp/server_spawn.mjs +5 -1
- package/memory.mjs +24 -0
- package/package.json +3 -1
- package/providers/anthropic.mjs +101 -6
- package/providers/cache.mjs +9 -1
- package/providers/claude_cli.mjs +104 -22
- package/providers/claude_cli_session.mjs +166 -0
- package/providers/cli_error.mjs +38 -0
- package/providers/cli_login.mjs +179 -0
- package/providers/codex_cli.mjs +66 -11
- package/providers/gemini.mjs +101 -3
- package/providers/gemini_cli.mjs +62 -15
- package/providers/model_catalogue.mjs +33 -2
- package/providers/ollama.mjs +104 -3
- package/providers/openai.mjs +110 -8
- package/providers/openai_compat.mjs +97 -6
- package/providers/orchestrator.mjs +112 -12
- package/providers/registry.mjs +15 -9
- package/providers/retry.mjs +14 -2
- package/providers/tool_use/anthropic.mjs +17 -3
- package/providers/tool_use/claude_cli.mjs +72 -20
- package/providers/tool_use/gemini.mjs +18 -2
- package/providers/tool_use/openai.mjs +28 -5
- package/sandbox/confiners/seatbelt.mjs +37 -12
- package/sandbox/index.mjs +49 -0
- package/sandbox/local.mjs +7 -1
- package/sandbox/spawn.mjs +144 -0
- package/sandbox.mjs +5 -1
- package/scripts/loop-worker.mjs +25 -1
- package/sessions.mjs +0 -0
- package/skills/channel-style.md +20 -0
- package/skills/code-review.md +33 -0
- package/skills/commit-message.md +30 -0
- package/skills/concise.md +24 -0
- package/skills/debug-coach.md +25 -0
- package/skills/explain.md +24 -0
- package/skills/korean.md +25 -0
- package/skills/summarize.md +33 -0
- package/skills.mjs +24 -2
- package/skills_curator.mjs +6 -0
- package/skills_install.mjs +10 -2
- package/tasks.mjs +6 -1
- package/teams.mjs +78 -0
- package/tui/chat_mode_slash.mjs +59 -0
- package/tui/config_picker.mjs +1 -0
- package/tui/editor.mjs +178 -151
- package/tui/editor_keys.mjs +275 -0
- package/tui/hud.mjs +111 -0
- package/tui/login_flow.mjs +113 -0
- package/tui/modal_picker.mjs +10 -1
- package/tui/model_pick.mjs +287 -0
- package/tui/orchestrator_flow.mjs +164 -0
- package/tui/pickers.mjs +210 -95
- package/tui/repl.mjs +106 -44
- package/tui/repl_reset.mjs +37 -0
- package/tui/run_turn.mjs +228 -26
- package/tui/slash_args.mjs +159 -0
- package/tui/slash_commands.mjs +7 -5
- package/tui/slash_dispatcher.mjs +537 -344
- package/tui/slash_popup.mjs +5 -1
- package/tui/splash.mjs +15 -2
- package/tui/status_bar.mjs +26 -0
- package/tui/theme.mjs +28 -0
- package/web/avatars/01.png +0 -0
- package/web/avatars/02.png +0 -0
- package/web/avatars/03.png +0 -0
- package/web/avatars/04.png +0 -0
- package/web/avatars/05.png +0 -0
- package/web/avatars/06.png +0 -0
- package/web/avatars/07.png +0 -0
- package/web/avatars/08.png +0 -0
- package/web/avatars/09.png +0 -0
- package/web/avatars/10.png +0 -0
- package/web/avatars/11.png +0 -0
- package/web/avatars/12.png +0 -0
- package/web/avatars/13.png +0 -0
- package/web/avatars/14.png +0 -0
- package/web/avatars/15.png +0 -0
- package/web/avatars/16.png +0 -0
- package/web/avatars/17.png +0 -0
- package/web/avatars/18.png +0 -0
- package/web/avatars/19.png +0 -0
- package/web/avatars/20.png +0 -0
- package/web/dashboard.css +77 -0
- package/web/dashboard.html +29 -2
- package/web/dashboard.js +296 -33
- package/workflow/builtin_caps.mjs +94 -0
- package/workflow/declarative.mjs +101 -0
- package/workflow/named.mjs +71 -0
- package/workflow/named_cron.mjs +50 -0
- package/workflow/nodes.mjs +67 -0
- package/workflow/run_request.mjs +74 -0
- package/workflow/yaml_min.mjs +97 -0
package/tui/repl.mjs
CHANGED
|
@@ -38,8 +38,11 @@ import { Splash, renderSplashToString } from './splash.mjs';
|
|
|
38
38
|
import { Editor } from './editor.mjs';
|
|
39
39
|
import { SlashPopup, filterSlashCommands } from './slash_popup.mjs';
|
|
40
40
|
import { SLASH_COMMANDS } from './slash_commands.mjs';
|
|
41
|
+
import { argSpecFor } from './slash_args.mjs';
|
|
41
42
|
import { ModalPicker, filterModalItems, resolveModalPick } from './modal_picker.mjs';
|
|
42
43
|
import { theme } from './theme.mjs';
|
|
44
|
+
import { StatusBar } from './status_bar.mjs';
|
|
45
|
+
import { onConversationReset, clearTerminalScreen } from './repl_reset.mjs'; export { StatusBar };
|
|
43
46
|
|
|
44
47
|
// ─── Alt-buffer mount (DEC 1049) ─────────────────────────────────────────
|
|
45
48
|
//
|
|
@@ -212,7 +215,7 @@ export function consumeNextTurnFirstMessage(state) {
|
|
|
212
215
|
// - runTurnFactory(writeFn) → runTurn(text, signal) (sticky layout)
|
|
213
216
|
// - runTurn(text, signal) (legacy, stdout)
|
|
214
217
|
// Legacy mode is preserved verbatim for the existing cli.mjs callsite.
|
|
215
|
-
export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, onSlashCommand, statusInfo, getStatus, pickerRef }) {
|
|
218
|
+
export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, onSlashCommand, onArgComplete, onArgList, statusInfo, getStatus, pickerRef }) {
|
|
216
219
|
// statusInfo seeds the StatusBar's provider/model/ctx. getStatus (optional)
|
|
217
220
|
// returns the live values so the bar refreshes after a /provider or /model
|
|
218
221
|
// switch and after each turn (token/ctx gauge) — without it the bar would
|
|
@@ -255,7 +258,7 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
|
|
|
255
258
|
// sticky-bottom Editor actually pins. Non-alt mode keeps the legacy
|
|
256
259
|
// content-sized layout so existing tests + non-TTY fallbacks behave
|
|
257
260
|
// identically. Listen for SIGWINCH-driven resize events.
|
|
258
|
-
const { stdout } = useStdout();
|
|
261
|
+
const { stdout, write: writeStdout } = useStdout();
|
|
259
262
|
const [rows, setRows] = useState(() => (stdout && stdout.rows) || 24);
|
|
260
263
|
useEffect(() => {
|
|
261
264
|
if (!stdout) return undefined;
|
|
@@ -305,6 +308,7 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
|
|
|
305
308
|
// when the user hits Esc (onEscape aborts state.controller).
|
|
306
309
|
const result = await onSlashCommand(trimmed, controller.signal);
|
|
307
310
|
if (result === 'EXIT') { exit(); return; }
|
|
311
|
+
if (result === 'NEW') { clearTerminalScreen(writeStdout); setState((s) => onConversationReset(s)); refreshStatus(); return; } // /new: wipe screen + scrollback so it visually starts over
|
|
308
312
|
if (typeof result === 'string' && result.length > 0) {
|
|
309
313
|
setState((s) => onStreamChunk(s, { chunk: result }));
|
|
310
314
|
}
|
|
@@ -330,7 +334,7 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
|
|
|
330
334
|
reason: err && err.name === 'AbortError' ? 'aborted' : 'error',
|
|
331
335
|
}));
|
|
332
336
|
}
|
|
333
|
-
}, [exit, onSlashCommand, refreshStatus]);
|
|
337
|
+
}, [exit, onSlashCommand, refreshStatus, writeStdout]);
|
|
334
338
|
|
|
335
339
|
// Auto-submit queued mid-stream-interrupt message (spec §5.8). Read
|
|
336
340
|
// state.nextTurnFirstMessage so the effect re-fires when promoted.
|
|
@@ -347,6 +351,11 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
|
|
|
347
351
|
setState((s) => onEscape(s));
|
|
348
352
|
}, []);
|
|
349
353
|
|
|
354
|
+
// v6.4 — 2-stage Ctrl+C exit handler. First Ctrl+C reuses onEscapeKey
|
|
355
|
+
// (cancel in-flight turn + clear, same reducer as Esc); a second press
|
|
356
|
+
// within the Editor's window calls onExit. Active when exitOnCtrlC:false.
|
|
357
|
+
const onExitKey = useCallback(() => { exit(); }, [exit]);
|
|
358
|
+
|
|
350
359
|
// ─── Slash popup state (v5.4) ──────────────────────────────────────
|
|
351
360
|
// The editor reports its current buffer via onBufferChange; we derive
|
|
352
361
|
// the filtered command list from that and own the selection index.
|
|
@@ -357,44 +366,66 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
|
|
|
357
366
|
);
|
|
358
367
|
const [bufferPeek, setBufferPeek] = useState('');
|
|
359
368
|
const [selectedSuggestion, setSelectedSuggestion] = useState(0);
|
|
369
|
+
const activeLenRef = useRef(0); // length of whatever popup list is active (commands or arg candidates)
|
|
360
370
|
const filtered = useMemo(
|
|
361
371
|
() => filterSlashCommands(bufferPeek, catalog),
|
|
362
372
|
[bufferPeek, catalog]
|
|
363
373
|
);
|
|
364
|
-
// Reset selection whenever the match list changes length (typing
|
|
365
|
-
// narrows results, so highlight the first row again).
|
|
366
|
-
const lastLenRef = useRef(0);
|
|
367
|
-
useEffect(() => {
|
|
368
|
-
if (filtered.length !== lastLenRef.current) {
|
|
369
|
-
setSelectedSuggestion(0);
|
|
370
|
-
lastLenRef.current = filtered.length;
|
|
371
|
-
} else if (selectedSuggestion >= filtered.length) {
|
|
372
|
-
setSelectedSuggestion(Math.max(0, filtered.length - 1));
|
|
373
|
-
}
|
|
374
|
-
}, [filtered.length, selectedSuggestion]);
|
|
375
374
|
|
|
376
375
|
const handleBufferChange = useCallback((buf) => {
|
|
377
376
|
setBufferPeek(buf || '');
|
|
378
377
|
}, []);
|
|
379
378
|
const handleSlashMove = useCallback((delta) => {
|
|
380
379
|
setSelectedSuggestion((i) => {
|
|
381
|
-
const max = Math.max(0,
|
|
380
|
+
const max = Math.max(0, activeLenRef.current - 1);
|
|
382
381
|
const n = i + delta;
|
|
383
382
|
if (n < 0) return 0;
|
|
384
383
|
if (n > max) return max;
|
|
385
384
|
return n;
|
|
386
385
|
});
|
|
387
|
-
}, [
|
|
386
|
+
}, []);
|
|
388
387
|
const handleSlashDismiss = useCallback(() => {
|
|
389
388
|
setBufferPeek('');
|
|
390
389
|
setSelectedSuggestion(0);
|
|
391
390
|
}, []);
|
|
392
391
|
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
//
|
|
392
|
+
// ─── Slash-argument completion (v6.x) ──────────────────────────────
|
|
393
|
+
// argSpec resolves what (if anything) is completable after the command.
|
|
394
|
+
// kind 'inline' → candidates render in the popup (onArgList → argList);
|
|
395
|
+
// ↑/↓ select, Tab/Enter fill the token (fillArgToken).
|
|
396
|
+
// kind 'modal' → a "↹ pick" hint shows; Tab opens the drill-in picker
|
|
397
|
+
// (handleArgComplete → onArgComplete → argInject).
|
|
398
|
+
const argSpec = useMemo(
|
|
399
|
+
() => argSpecFor(bufferPeek, catalog),
|
|
400
|
+
[bufferPeek, catalog],
|
|
401
|
+
);
|
|
402
|
+
const [argList, setArgList] = useState([]);
|
|
403
|
+
useEffect(() => {
|
|
404
|
+
let cancelled = false;
|
|
405
|
+
if (argSpec && argSpec.kind === 'inline' && typeof onArgList === 'function') {
|
|
406
|
+
Promise.resolve(onArgList(bufferPeek))
|
|
407
|
+
.then((items) => { if (!cancelled) setArgList(Array.isArray(items) ? items : []); })
|
|
408
|
+
.catch(() => { if (!cancelled) setArgList([]); });
|
|
409
|
+
} else {
|
|
410
|
+
setArgList((prev) => (prev.length ? [] : prev));
|
|
411
|
+
}
|
|
412
|
+
return () => { cancelled = true; };
|
|
413
|
+
}, [bufferPeek, argSpec, onArgList]);
|
|
414
|
+
const argCompletable = !!argSpec && argSpec.kind === 'modal' && typeof onArgComplete === 'function';
|
|
415
|
+
const [argInject, setArgInject] = useState(null);
|
|
416
|
+
const argNonceRef = useRef(0);
|
|
417
|
+
const handleArgComplete = useCallback(async (buf) => {
|
|
418
|
+
if (typeof onArgComplete !== 'function') return;
|
|
419
|
+
let value = null;
|
|
420
|
+
try { value = await onArgComplete(buf); } catch { value = null; }
|
|
421
|
+
if (typeof value === 'string' && value) {
|
|
422
|
+
argNonceRef.current += 1;
|
|
423
|
+
setArgInject({ value, nonce: argNonceRef.current });
|
|
424
|
+
}
|
|
425
|
+
}, [onArgComplete]);
|
|
426
|
+
|
|
427
|
+
// Hide the command popup when the buffer already exactly matches the only
|
|
428
|
+
// remaining suggestion (so Enter submits /exit etc. instead of re-filling).
|
|
398
429
|
const _bufTrimmed = bufferPeek.replace(/\s+$/, '');
|
|
399
430
|
const _exactOnly =
|
|
400
431
|
filtered.length === 1 &&
|
|
@@ -434,6 +465,9 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
|
|
|
434
465
|
subtitle: opts.subtitle || '',
|
|
435
466
|
items: Array.isArray(opts.items) ? opts.items : [],
|
|
436
467
|
searchable: opts.searchable !== false,
|
|
468
|
+
// Carry the secret flag so a credential entry (api-key / token) masks
|
|
469
|
+
// the typed query — dropping it here echoed the secret in plaintext.
|
|
470
|
+
secret: !!opts.secret,
|
|
437
471
|
resolve,
|
|
438
472
|
});
|
|
439
473
|
});
|
|
@@ -478,16 +512,34 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
|
|
|
478
512
|
}, []);
|
|
479
513
|
const modalOpen = !!modal;
|
|
480
514
|
|
|
481
|
-
//
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
//
|
|
485
|
-
//
|
|
486
|
-
|
|
487
|
-
const showSlashPopup =
|
|
515
|
+
// Command popup (no space) vs inline-arg popup (has space + an inline spec) —
|
|
516
|
+
// mutually exclusive via the space. Both feed the same Editor/SlashPopup
|
|
517
|
+
// machinery; slashFillMode tells the Editor which fill to apply (whole
|
|
518
|
+
// command vs the arg token). Modal-kind args show a hint instead (Tab opens
|
|
519
|
+
// the drill-in picker). Suppressed entirely while a modal picker is up.
|
|
520
|
+
const cmdPopup =
|
|
488
521
|
!modalOpen &&
|
|
489
522
|
bufferPeek.startsWith('/') && bufferPeek.indexOf(' ') < 0 &&
|
|
490
523
|
filtered.length > 0 && !_exactOnly;
|
|
524
|
+
const argPopup = !modalOpen && !cmdPopup && !!argSpec && argSpec.kind === 'inline' && argList.length > 0;
|
|
525
|
+
const popupRows = cmdPopup
|
|
526
|
+
? filtered
|
|
527
|
+
: argPopup
|
|
528
|
+
? argList.map((i) => ({ cmd: i.value, help: i.desc || '' }))
|
|
529
|
+
: [];
|
|
530
|
+
const showSlashPopup = popupRows.length > 0;
|
|
531
|
+
const slashFillMode = argPopup ? 'arg' : 'command';
|
|
532
|
+
activeLenRef.current = popupRows.length;
|
|
533
|
+
// Reset / clamp the highlighted row when the active list changes.
|
|
534
|
+
const lastLenRef = useRef(0);
|
|
535
|
+
useEffect(() => {
|
|
536
|
+
if (popupRows.length !== lastLenRef.current) {
|
|
537
|
+
setSelectedSuggestion(0);
|
|
538
|
+
lastLenRef.current = popupRows.length;
|
|
539
|
+
} else if (selectedSuggestion >= popupRows.length) {
|
|
540
|
+
setSelectedSuggestion(Math.max(0, popupRows.length - 1));
|
|
541
|
+
}
|
|
542
|
+
}, [popupRows.length, selectedSuggestion]);
|
|
491
543
|
|
|
492
544
|
// Outer column height: pinned to rows-1 in alt-buffer mode so the
|
|
493
545
|
// Editor truly sticks to the bottom. Non-alt keeps content-sized layout
|
|
@@ -554,10 +606,21 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
|
|
|
554
606
|
showSlashPopup
|
|
555
607
|
? React.createElement(SlashPopup, {
|
|
556
608
|
buffer: bufferPeek,
|
|
557
|
-
commands:
|
|
609
|
+
commands: popupRows,
|
|
558
610
|
selectedIndex: selectedSuggestion,
|
|
611
|
+
forceChooser: slashFillMode === 'arg',
|
|
559
612
|
})
|
|
560
613
|
: null,
|
|
614
|
+
// 3a) Modal-kind arg hint — for 2-step specs (/model, /trainer set,
|
|
615
|
+
// /orchestrator planner) Tab opens the drill-in picker. Inline-kind
|
|
616
|
+
// args render their candidates in the popup above instead.
|
|
617
|
+
(!showSlashPopup && argSpec && argSpec.kind === 'modal' && !modalOpen)
|
|
618
|
+
? React.createElement(
|
|
619
|
+
Box,
|
|
620
|
+
{ paddingX: 1, key: 'arghint' },
|
|
621
|
+
React.createElement(Text, { dimColor: true }, `↹ pick ${argSpec.name}`)
|
|
622
|
+
)
|
|
623
|
+
: null,
|
|
561
624
|
// 3b) Modal picker (v5.4.3) — flex sibling above StatusBar, only
|
|
562
625
|
// visible while ReplApp's `modal` state is set. Suppresses the
|
|
563
626
|
// slash popup so the overlays don't stack.
|
|
@@ -569,6 +632,7 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
|
|
|
569
632
|
selectedIndex: modalIdx,
|
|
570
633
|
query: modalQuery,
|
|
571
634
|
searchable: modal.searchable,
|
|
635
|
+
secret: modal.secret,
|
|
572
636
|
})
|
|
573
637
|
: null,
|
|
574
638
|
// 4) Status bar (sticky, single row above input). flexShrink:0 so
|
|
@@ -579,6 +643,7 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
|
|
|
579
643
|
streaming: state.streaming,
|
|
580
644
|
ctxUsed: _status.ctxUsed,
|
|
581
645
|
ctxTotal: _status.ctxTotal,
|
|
646
|
+
hud: _status.hud,
|
|
582
647
|
}),
|
|
583
648
|
// 5) Editor — sticky bottom, content-sized. Wrapped in a flexShrink:0
|
|
584
649
|
// Box so Yoga doesn't squeeze the input row when scrollback fills.
|
|
@@ -590,10 +655,19 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
|
|
|
590
655
|
onSubmit: handleSubmit,
|
|
591
656
|
onEscape: onEscapeKey,
|
|
592
657
|
onBufferChange: handleBufferChange,
|
|
593
|
-
|
|
658
|
+
// v6.4 — 2-stage Ctrl+C wiring (active when exitOnCtrlC:false).
|
|
659
|
+
onInterrupt: onEscapeKey,
|
|
660
|
+
onExit: onExitKey,
|
|
661
|
+
slashSuggestions: showSlashPopup ? popupRows : null,
|
|
594
662
|
slashSelectedIndex: selectedSuggestion,
|
|
663
|
+
slashFillMode,
|
|
595
664
|
onSlashMove: handleSlashMove,
|
|
596
665
|
onSlashDismiss: handleSlashDismiss,
|
|
666
|
+
// v6.x slash-argument completion (inline fills the token; modal kind
|
|
667
|
+
// uses argCompletable+onArgComplete to open the drill-in picker).
|
|
668
|
+
argCompletable,
|
|
669
|
+
onArgComplete: handleArgComplete,
|
|
670
|
+
argInject,
|
|
597
671
|
// v5.4.3 — modal picker key contract. When modalOpen the
|
|
598
672
|
// Editor swallows all keys (no buffer mutation, no submit).
|
|
599
673
|
modalOpen,
|
|
@@ -640,20 +714,8 @@ export const ScrollbackItem = React.memo(function ScrollbackItem({ item }) {
|
|
|
640
714
|
return React.createElement(Text, { color: theme.fg }, item.text);
|
|
641
715
|
});
|
|
642
716
|
|
|
643
|
-
// StatusBar
|
|
644
|
-
//
|
|
645
|
-
// usage metrics flow into state.
|
|
646
|
-
export function StatusBar({ provider, model, streaming, ctxUsed, ctxTotal }) {
|
|
647
|
-
const ctx = (ctxUsed != null && ctxTotal != null) ? `${ctxUsed}/${ctxTotal}` : '--';
|
|
648
|
-
const indicator = streaming ? theme.accent('● streaming') : theme.dim('○ idle');
|
|
649
|
-
const prov = provider || '?';
|
|
650
|
-
const mdl = model || '?';
|
|
651
|
-
return React.createElement(
|
|
652
|
-
Box,
|
|
653
|
-
{ flexShrink: 0, paddingX: 1 },
|
|
654
|
-
React.createElement(Text, null, `${indicator} ${prov} · ${mdl} ctx ${ctx}`)
|
|
655
|
-
);
|
|
656
|
-
}
|
|
717
|
+
// StatusBar moved to ./status_bar.mjs (re-exported above) so the HUD row can
|
|
718
|
+
// grow without pushing repl.mjs over the file-size ratchet.
|
|
657
719
|
|
|
658
720
|
// Exported for tests that want to verify the splash snapshot without a TTY.
|
|
659
721
|
export function _renderSplashToString(splashProps) {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// tui/repl_reset.mjs — pure reducer for the `/new` conversation reset.
|
|
2
|
+
//
|
|
3
|
+
// Lives in its own file (not repl.mjs) only because repl.mjs is at its
|
|
4
|
+
// file-size ceiling (scripts/lint-file-size.mjs). It is the scrollback-clearing
|
|
5
|
+
// counterpart to the dispatcher's in-memory reset: /new wiped ctx.setMessages
|
|
6
|
+
// but left the <Static/> scrollback intact, so the old conversation stayed on
|
|
7
|
+
// screen. This empties the scrollback back to the splash item (the established
|
|
8
|
+
// fresh-start look) and drops any in-flight / live-region state.
|
|
9
|
+
|
|
10
|
+
export function onConversationReset(state) {
|
|
11
|
+
// Keep the splash header so a reset screen looks like a fresh launch, not a
|
|
12
|
+
// blank void. Everything else (user lines, assistant replies, live partial)
|
|
13
|
+
// is discarded.
|
|
14
|
+
const splash = state.scrollback.find((it) => it && it.kind === 'splash') || null;
|
|
15
|
+
return {
|
|
16
|
+
...state,
|
|
17
|
+
streaming: false,
|
|
18
|
+
controller: null,
|
|
19
|
+
pendingPrepend: null,
|
|
20
|
+
nextTurnFirstMessage: null,
|
|
21
|
+
liveAssistant: '',
|
|
22
|
+
history: [],
|
|
23
|
+
scrollback: splash ? [splash] : [],
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Erase the terminal screen + scrollback buffer. Resetting the React scrollback
|
|
28
|
+
// state alone cannot un-print Ink's <Static/> output (it is write-once and
|
|
29
|
+
// append-only), so /new must also wipe the physical terminal. `\x1b[3J` drops
|
|
30
|
+
// the scrollback buffer so the old conversation can't be scrolled back to.
|
|
31
|
+
// `write` is the Ink-provided useStdout().write (clears the live frame first).
|
|
32
|
+
export const CLEAR_TERMINAL = '\x1b[2J\x1b[3J\x1b[H';
|
|
33
|
+
export function clearTerminalScreen(write) {
|
|
34
|
+
if (typeof write === 'function') {
|
|
35
|
+
try { write(CLEAR_TERMINAL); } catch { /* swallow — stdout may be closed */ }
|
|
36
|
+
}
|
|
37
|
+
}
|
package/tui/run_turn.mjs
CHANGED
|
@@ -25,6 +25,160 @@
|
|
|
25
25
|
// turn-completion logic (ReplApp onTurnComplete, legacy `rl.prompt`)
|
|
26
26
|
// runs unconditionally.
|
|
27
27
|
|
|
28
|
+
import { Chalk } from 'chalk';
|
|
29
|
+
import { chatAgenticGet, chatPlanModeGet, effectiveChatTools } from '../config_features.mjs';
|
|
30
|
+
import { defaultSandboxSpec } from '../sandbox/index.mjs';
|
|
31
|
+
|
|
32
|
+
// Force ANSI on these turn-status markers regardless of stdout TTY detection:
|
|
33
|
+
// the Ink path routes them through React state (Ink preserves embedded SGR
|
|
34
|
+
// and decides display), and the legacy stdout path always targets a terminal
|
|
35
|
+
// for chat. Without forcing, chalk's auto-level strips color under the
|
|
36
|
+
// non-TTY test/pipe sink and the red error / dim abort marker vanish.
|
|
37
|
+
const _statusChalk = new Chalk({ level: 1 });
|
|
38
|
+
|
|
39
|
+
// Classify a provider/turn error into a one-line actionable hint (or '' when
|
|
40
|
+
// none applies). Checked auth → model → network so one hint at most. Advisory
|
|
41
|
+
// only — rendered dim under the red error line.
|
|
42
|
+
function _errorHint(err) {
|
|
43
|
+
const msg = String(err?.message || err || '');
|
|
44
|
+
const status = err?.status;
|
|
45
|
+
const code = err?.code;
|
|
46
|
+
if (status === 401 || status === 403 || /\b(401|403|unauthorized|forbidden|invalid[ _-]?api[ _-]?key|authentication|no api key|missing api key)\b/i.test(msg)) {
|
|
47
|
+
return 'hint: set a key with /provider, or `lazyclaw auth add <provider>`';
|
|
48
|
+
}
|
|
49
|
+
if (status === 404 || /\b(404|model[ _-]?not[ _-]?found|no such model|unknown model|model .* does not exist|not_found_error)\b/i.test(msg)) {
|
|
50
|
+
return 'hint: run /model to pick a valid model';
|
|
51
|
+
}
|
|
52
|
+
if (code === 'ENOTFOUND' || code === 'ECONNREFUSED' || code === 'ETIMEDOUT' || code === 'ECONNRESET' || code === 'EAI_AGAIN'
|
|
53
|
+
|| /\b(ENOTFOUND|ECONNREFUSED|ETIMEDOUT|ECONNRESET|EAI_AGAIN|network|fetch failed|socket hang up|getaddrinfo)\b/i.test(msg)) {
|
|
54
|
+
return 'hint: check your connection / base URL (/provider to review the endpoint)';
|
|
55
|
+
}
|
|
56
|
+
return '';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Plan-mode addendum — instructs the model to propose, not mutate. Appended
|
|
60
|
+
// to the synthetic chat agent's system prompt only while plan mode is ON.
|
|
61
|
+
const PLAN_MODE_ADDENDUM = 'You are in PLAN mode. Propose a plan; do not mutate '
|
|
62
|
+
+ 'anything. List the steps you would take and the tools you would use, then stop.';
|
|
63
|
+
|
|
64
|
+
// Per-turn recall injection (roadmap #7). The streaming chat path sends a fixed
|
|
65
|
+
// system prompt, so it never surfaced context relevant to THIS message (only the
|
|
66
|
+
// agentic path, which rebuilds the prompt stack per turn, did). Prepend a fresh
|
|
67
|
+
// recall layer to the CURRENT user message — not the system — because a warm
|
|
68
|
+
// claude-cli persistent session fixes its system at spawn, and every provider
|
|
69
|
+
// reads the user turn. Transient: returns a COPY for the send; the stored
|
|
70
|
+
// session keeps the original message. recallLayer is injected (lazy-imported by
|
|
71
|
+
// the caller) so better-sqlite3 stays off this module's static graph.
|
|
72
|
+
export function _injectRecall(messages, cfgDir, recallLayerFn) {
|
|
73
|
+
try {
|
|
74
|
+
if (typeof recallLayerFn !== 'function' || !Array.isArray(messages)) return messages;
|
|
75
|
+
let idx = -1;
|
|
76
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
77
|
+
if (messages[i] && messages[i].role === 'user') { idx = i; break; }
|
|
78
|
+
}
|
|
79
|
+
if (idx < 0) return messages;
|
|
80
|
+
const layer = recallLayerFn(cfgDir, String(messages[idx].content || ''), 5);
|
|
81
|
+
if (!layer || !String(layer).trim()) return messages;
|
|
82
|
+
return messages.map((m, i) => (i === idx ? { ...m, content: `${layer}\n\n${m.content}` } : m));
|
|
83
|
+
} catch { return messages; }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Fire the post-task learning loop on every successful chat turn (v5 Group A
|
|
87
|
+
// C1). Fire-and-forget via queueMicrotask so the next prompt is never blocked
|
|
88
|
+
// on trajectory write / synth. Shared by the streaming and agentic paths.
|
|
89
|
+
// Decide whether a chat turn is worth a learning pass. The hook otherwise
|
|
90
|
+
// spawns TWO extra `claude` processes (skill synth + user-model) on EVERY turn,
|
|
91
|
+
// tripling per-message spawns and competing with the user's next turn for the
|
|
92
|
+
// subscription. Skip greetings/acks, trivially short exchanges, and empty
|
|
93
|
+
// (aborted/failed) replies — none warrant a durable SKILL.md or a user model.
|
|
94
|
+
export function _shouldLearn(messages) {
|
|
95
|
+
const last2 = (messages || []).slice(-2);
|
|
96
|
+
const user = String(last2.find((m) => m && m.role === 'user')?.content || '').trim();
|
|
97
|
+
const reply = String(last2.find((m) => m && m.role !== 'user')?.content || '').trim();
|
|
98
|
+
if (!reply) return false;
|
|
99
|
+
if ((user + ' ' + reply).trim().length < 280) return false;
|
|
100
|
+
if (/^(hi|hey|hello|yo|thanks|thank you|thx|ty|ok|okay|k|sure|nice|cool|got it|ㅇㅇ|ㄱㅅ|ㄳ|고마워|감사|안녕|넵|네)\b/i.test(user)) return false;
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function _fireLearningHook(ctx, messages, activeProvName, activeModel, transcript) {
|
|
105
|
+
try {
|
|
106
|
+
if (!_shouldLearn(messages)) return;
|
|
107
|
+
queueMicrotask(() => {
|
|
108
|
+
import('../mas/learning.mjs').then((mod) => mod.runLearning('post-task', {
|
|
109
|
+
agent: { name: 'chat', provider: activeProvName, model: activeModel, role: '' },
|
|
110
|
+
task: {
|
|
111
|
+
id: ctx.getSessionId() || ctx.syntheticChatSessionId,
|
|
112
|
+
title: '(chat turn)',
|
|
113
|
+
turns: messages.slice(-2).map((m) => ({
|
|
114
|
+
agent: m.role === 'user' ? 'user' : 'chat',
|
|
115
|
+
text: m.content,
|
|
116
|
+
ts: new Date().toISOString(),
|
|
117
|
+
})),
|
|
118
|
+
},
|
|
119
|
+
configDir: ctx.cfgDir,
|
|
120
|
+
cfg: ctx.cfg,
|
|
121
|
+
transcript: String(transcript || '').slice(0, 8000),
|
|
122
|
+
})).catch(() => { /* learning loop is best-effort */ });
|
|
123
|
+
});
|
|
124
|
+
} catch { /* never let learning hook break the chat */ }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Drive one agentic chat turn through the MAS tool loop. Builds the synthetic
|
|
128
|
+
// chat agent record, renders compact tool-activity status lines + the final
|
|
129
|
+
// answer into writeChunk, and returns the final assistant text. The approval
|
|
130
|
+
// hook is taken from ctx.approve when present (Ink: _makeInkApprove, legacy:
|
|
131
|
+
// makeReadlineApprove) — when absent the fail-closed gate in tool_runner.mjs
|
|
132
|
+
// simply denies any sensitive tool (no silent ungated execution).
|
|
133
|
+
async function _runAgenticTurn({ ctx, messages, sysMsg, activeProvName, activeModel, planMode, writeChunk, signal }) {
|
|
134
|
+
const runAgentTurn = ctx.runAgentTurnImpl
|
|
135
|
+
|| (await import('../mas/agent_turn.mjs')).runAgentTurn;
|
|
136
|
+
const tools = effectiveChatTools(ctx.cfg, { planMode });
|
|
137
|
+
let role = (sysMsg && sysMsg.content) || '';
|
|
138
|
+
if (planMode) role = role ? `${role}\n\n${PLAN_MODE_ADDENDUM}` : PLAN_MODE_ADDENDUM;
|
|
139
|
+
const agent = { name: 'chat', provider: activeProvName, model: activeModel, role, tools };
|
|
140
|
+
// History excludes the just-pushed user message and the system slot (the
|
|
141
|
+
// latter is threaded via agent.role); runAgentTurn appends userMessage.
|
|
142
|
+
const history = messages
|
|
143
|
+
.slice(0, -1)
|
|
144
|
+
.filter((m) => m.role !== 'system')
|
|
145
|
+
.map((m) => ({ role: m.role, content: m.content }));
|
|
146
|
+
const userMessage = messages[messages.length - 1]?.content || '';
|
|
147
|
+
let result;
|
|
148
|
+
try {
|
|
149
|
+
result = await runAgentTurn({
|
|
150
|
+
agent, userMessage, history,
|
|
151
|
+
configDir: ctx.cfgDir,
|
|
152
|
+
apiKey: ctx.resolveAuthKey(activeProvName),
|
|
153
|
+
approve: ctx.approve,
|
|
154
|
+
security: ctx.cfg?.security,
|
|
155
|
+
// Default-on isolation: an explicit --sandbox spec wins; otherwise confine
|
|
156
|
+
// by default (cwd-confined fs, secrets blocked, net allowed). Opt out via
|
|
157
|
+
// cfg.sandbox.confine=false → defaultSandboxSpec returns null (bare).
|
|
158
|
+
sandbox: ctx.sandboxSpec || defaultSandboxSpec(ctx.cfg, { cwd: process.cwd(), configDir: ctx.cfgDir }),
|
|
159
|
+
cache: true,
|
|
160
|
+
usePromptStack: false,
|
|
161
|
+
signal,
|
|
162
|
+
});
|
|
163
|
+
} catch (err) {
|
|
164
|
+
// No silent catch — surface as a status line; return empty so the caller
|
|
165
|
+
// still completes the turn (persist + learning) without a half reply.
|
|
166
|
+
try { writeChunk(`· agentic turn failed: ${err?.message || String(err)}\n`); } catch { /* sink */ }
|
|
167
|
+
return '';
|
|
168
|
+
}
|
|
169
|
+
// Compact tool-activity status lines (dim, not the red provider-error style).
|
|
170
|
+
for (const c of (result.toolCalls || [])) {
|
|
171
|
+
const ok = c.ok ? '✓' : '⚠';
|
|
172
|
+
try { writeChunk(`· ${ok} ${c.name}\n`); } catch { /* sink */ }
|
|
173
|
+
}
|
|
174
|
+
if (result.stoppedBy === 'budget') {
|
|
175
|
+
try { writeChunk(`· stopped after ${result.iterations} tool step(s)\n`); } catch { /* sink */ }
|
|
176
|
+
}
|
|
177
|
+
const text = result.text || '';
|
|
178
|
+
if (text) { try { writeChunk(text); } catch { /* sink */ } }
|
|
179
|
+
return text;
|
|
180
|
+
}
|
|
181
|
+
|
|
28
182
|
/**
|
|
29
183
|
* @typedef {Object} RunTurnCtx
|
|
30
184
|
* @property {Object} cfg Active config.
|
|
@@ -79,17 +233,72 @@ export function makeRunTurn({ ctx, writeFn }) {
|
|
|
79
233
|
const prov = ctx.getProv();
|
|
80
234
|
const activeProvName = ctx.getActiveProvName();
|
|
81
235
|
const activeModel = ctx.getActiveModel();
|
|
236
|
+
// Group 1 — agentic REPL. When cfg.chat.agentic (or plan mode) is ON,
|
|
237
|
+
// route the turn through the MAS tool loop (runAgentTurn) instead of
|
|
238
|
+
// the streaming sendMessage path. Plan mode forces agentic-on for the
|
|
239
|
+
// turn (read-only). Everything else (persistTurn, the post-task
|
|
240
|
+
// learning hook below) is shared. When OFF — the default — the
|
|
241
|
+
// streaming path below runs UNCHANGED.
|
|
242
|
+
const planMode = chatPlanModeGet(ctx.cfg);
|
|
243
|
+
if (chatAgenticGet(ctx.cfg) || planMode) {
|
|
244
|
+
const acc2 = await _runAgenticTurn({
|
|
245
|
+
ctx, messages, sysMsg, activeProvName, activeModel, planMode,
|
|
246
|
+
writeChunk: _writeChunk, signal,
|
|
247
|
+
});
|
|
248
|
+
if (_writeTimer) clearTimeout(_writeTimer);
|
|
249
|
+
_flush();
|
|
250
|
+
try { writeFn('\n'); } catch { /* sink failure must not kill the turn */ }
|
|
251
|
+
messages.push({ role: 'assistant', content: acc2 });
|
|
252
|
+
ctx.persistTurn('assistant', acc2);
|
|
253
|
+
_fireLearningHook(ctx, messages, activeProvName, activeModel, acc2);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
// Configurable max-output-tokens (config.json `maxTokens`). Only
|
|
257
|
+
// thread it through when it's a positive finite number; otherwise
|
|
258
|
+
// leave opts.maxTokens unset so each provider's DEFAULT_MAX_TOKENS
|
|
259
|
+
// applies.
|
|
260
|
+
const cfgMaxTokens = ctx.cfg?.maxTokens;
|
|
261
|
+
const maxTokens = (typeof cfgMaxTokens === 'number' && Number.isFinite(cfgMaxTokens) && cfgMaxTokens > 0)
|
|
262
|
+
? cfgMaxTokens
|
|
263
|
+
: undefined;
|
|
264
|
+
// Roadmap #7 — per-turn recall: surface context relevant to THIS message
|
|
265
|
+
// by prepending a fresh recall layer to the current user turn (off via
|
|
266
|
+
// cfg.chat.recall=false). Best-effort + lazy-imported so an empty/missing
|
|
267
|
+
// index never blocks a reply and better-sqlite3 stays off the static graph.
|
|
268
|
+
let sendMessages = messages;
|
|
269
|
+
if (ctx.cfg?.chat?.recall !== false) {
|
|
270
|
+
try {
|
|
271
|
+
const { recalledLayer } = await import('../mas/prompt_stack.mjs');
|
|
272
|
+
sendMessages = _injectRecall(messages, ctx.cfgDir, recalledLayer);
|
|
273
|
+
} catch { /* recall is best-effort — never block a turn */ }
|
|
274
|
+
}
|
|
82
275
|
// C8 — prompt-cache the static system prefix. The Anthropic
|
|
83
276
|
// provider prefers `systemStatic` when present; non-Anthropic
|
|
84
277
|
// providers ignore the field and fall back to the legacy
|
|
85
278
|
// single-block path with `cache:true`.
|
|
86
|
-
|
|
279
|
+
let truncated = false;
|
|
280
|
+
for await (const chunk of prov.sendMessage(sendMessages, {
|
|
87
281
|
apiKey: ctx.resolveAuthKey(activeProvName),
|
|
88
282
|
model: activeModel,
|
|
89
283
|
sandbox: ctx.sandboxSpec,
|
|
90
284
|
signal,
|
|
91
285
|
onUsage: ctx.accumulateUsage,
|
|
286
|
+
// Streaming providers fire this when a turn hits the model's output-token
|
|
287
|
+
// limit (finish_reason 'length' / MAX_TOKENS / stop_reason 'max_tokens' /
|
|
288
|
+
// done_reason 'length'). Warn the user instead of presenting a truncated
|
|
289
|
+
// answer as complete, mirroring the agentic path (mas/agent_turn.mjs).
|
|
290
|
+
onTruncated: () => { truncated = true; },
|
|
92
291
|
cache: true,
|
|
292
|
+
// Default-ON (set cfg.chat.persistentSession=false to disable): reuse one
|
|
293
|
+
// warm `claude` per conversation so the harness boots once, not every
|
|
294
|
+
// turn (measured ~1.2s/turn / ~42% faster after the cold first turn).
|
|
295
|
+
// claude-cli-only (other providers ignore it); the provider falls back to
|
|
296
|
+
// a one-shot spawn on any session failure, and the session respawns if
|
|
297
|
+
// the system prompt changes (plan-mode toggle) so it can't go stale.
|
|
298
|
+
persistent: ctx.cfg?.chat?.persistentSession !== false,
|
|
299
|
+
sessionKey: (ctx.getSessionId && ctx.getSessionId()) || ctx.syntheticChatSessionId,
|
|
300
|
+
...(sysMsg ? { sessionSystem: sysMsg.content } : {}),
|
|
301
|
+
...(maxTokens !== undefined ? { maxTokens } : {}),
|
|
93
302
|
...(sysMsg ? { systemStatic: sysMsg.content } : {}),
|
|
94
303
|
})) {
|
|
95
304
|
_writeChunk(chunk);
|
|
@@ -99,39 +308,32 @@ export function makeRunTurn({ ctx, writeFn }) {
|
|
|
99
308
|
_flush();
|
|
100
309
|
try { writeFn('\n'); }
|
|
101
310
|
catch { /* sink failure must not kill the turn */ }
|
|
311
|
+
if (truncated) {
|
|
312
|
+
try { writeFn('[truncated — the model hit its output-token limit; raise maxTokens]\n'); }
|
|
313
|
+
catch { /* sink failure must not kill the turn */ }
|
|
314
|
+
}
|
|
102
315
|
messages.push({ role: 'assistant', content: acc });
|
|
103
316
|
ctx.persistTurn('assistant', acc);
|
|
104
|
-
|
|
105
|
-
// successful chat turn. Fire-and-forget via queueMicrotask so the
|
|
106
|
-
// next prompt is never blocked on trajectory write / synth.
|
|
107
|
-
try {
|
|
108
|
-
queueMicrotask(() => {
|
|
109
|
-
import('../mas/learning.mjs').then((mod) => mod.runLearning('post-task', {
|
|
110
|
-
agent: { name: 'chat', provider: activeProvName, model: activeModel, role: '' },
|
|
111
|
-
task: {
|
|
112
|
-
id: ctx.getSessionId() || ctx.syntheticChatSessionId,
|
|
113
|
-
title: '(chat turn)',
|
|
114
|
-
turns: messages.slice(-2).map((m) => ({
|
|
115
|
-
agent: m.role === 'user' ? 'user' : 'chat',
|
|
116
|
-
text: m.content,
|
|
117
|
-
ts: new Date().toISOString(),
|
|
118
|
-
})),
|
|
119
|
-
},
|
|
120
|
-
configDir: ctx.cfgDir,
|
|
121
|
-
cfg: ctx.cfg,
|
|
122
|
-
transcript: acc.slice(0, 8000),
|
|
123
|
-
})).catch(() => { /* learning loop is best-effort */ });
|
|
124
|
-
});
|
|
125
|
-
} catch { /* never let learning hook break the chat */ }
|
|
317
|
+
_fireLearningHook(ctx, messages, activeProvName, activeModel, acc);
|
|
126
318
|
} catch (err) {
|
|
127
319
|
if (_writeTimer) clearTimeout(_writeTimer);
|
|
128
320
|
_flush();
|
|
129
321
|
// ABORT errors are user-initiated; drop the partial reply (don't
|
|
130
322
|
// push an incomplete assistant message — next turn would treat
|
|
131
|
-
// it as a complete reply and confuse the model).
|
|
132
|
-
|
|
133
|
-
|
|
323
|
+
// it as a complete reply and confuse the model). Emit a visible dim
|
|
324
|
+
// [aborted] marker so the output doesn't just silently stop (the
|
|
325
|
+
// host onTurnComplete sees reason:'done' because we swallow ABORT).
|
|
326
|
+
if (err?.code === 'ABORT' || signal?.aborted) {
|
|
327
|
+
try { writeFn(`${_statusChalk.dim('[aborted]')}\n`); }
|
|
328
|
+
catch { /* sink failure must not kill the turn */ }
|
|
329
|
+
} else {
|
|
330
|
+
// Provider errors render in the red error style (not normal amber
|
|
331
|
+
// assistant text — the audit gap). The red SGR is embedded so the
|
|
332
|
+
// Ink scrollback <Text> preserves it and the legacy stdout path shows it.
|
|
333
|
+
try { writeFn(`${_statusChalk.red(`error: ${err?.message || String(err)}`)}\n`); }
|
|
134
334
|
catch { /* sink failure must not mask err */ }
|
|
335
|
+
const _hint = _errorHint(err);
|
|
336
|
+
if (_hint) { try { writeFn(`${_statusChalk.dim(_hint)}\n`); } catch { /* sink */ } }
|
|
135
337
|
}
|
|
136
338
|
}
|
|
137
339
|
};
|