@nanhara/hara 0.122.7 → 0.123.1
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 +40 -0
- package/README.md +22 -6
- package/dist/agent/loop.js +3 -2
- package/dist/config.js +70 -11
- package/dist/context/workspace-scope.js +4 -4
- package/dist/index.js +206 -44
- package/dist/org-fleet/enroll.js +30 -4
- package/dist/security/guardian.js +13 -5
- package/dist/security/private-state.js +3 -1
- package/dist/serve/protocol.js +2 -1
- package/dist/serve/server.js +125 -50
- package/dist/serve/sessions.js +12 -7
- package/dist/session/store.js +32 -3
- package/dist/session/task.js +161 -0
- package/dist/tui/App.js +21 -9
- package/dist/tui/InputBox.js +61 -18
- package/dist/tui/bracketed-paste.js +273 -0
- package/dist/tui/run.js +8 -2
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -9,7 +9,7 @@ import { setTheme } from "./tui/theme.js";
|
|
|
9
9
|
import { memoryDigest, memoryDir, readRecentLogs, scaffoldMemory } from "./memory/store.js";
|
|
10
10
|
import { nextMode as cycleMode } from "./tui/InputBox.js";
|
|
11
11
|
import { stdin, stdout } from "node:process";
|
|
12
|
-
import { readFileSync, existsSync, writeFileSync, rmSync } from "node:fs";
|
|
12
|
+
import { readFileSync, existsSync, realpathSync, statSync, writeFileSync, rmSync } from "node:fs";
|
|
13
13
|
import { homedir, tmpdir } from "node:os";
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
15
15
|
import { randomUUID } from "node:crypto";
|
|
@@ -75,6 +75,7 @@ import { collectRepoChunksAsync, collectDirChunksAsync, buildIndex, indexPath, i
|
|
|
75
75
|
import { searchHybrid } from "./search/hybrid.js";
|
|
76
76
|
import { expandMentionsAsync, fileCandidates, isSlashCommand, inlineLeadingPath } from "./context/mentions.js";
|
|
77
77
|
import { newSessionId, shortId, resolveSessionId, validSessionId, sessionFileExists, saveSession, loadSession, acquireSessionLock, releaseSessionLock, listSessions, latestForCwd, titleFrom, sessionSourceFromEnv, automatedTitle, slugify, } from "./session/store.js";
|
|
78
|
+
import { continueTaskExecution, createTaskExecution, finishTaskExecution, formatTaskExecution, newSteerInteraction, newTurnInteraction, recordTaskSteering, recoverTaskExecution, taskExecutionContext, } from "./session/task.js";
|
|
78
79
|
import { setSessionForceModel, isSessionForceModel, effectiveRoleModel } from "./session/session-model.js";
|
|
79
80
|
import { loadGlobalRoles, loadRoles, roleToolFilter, scaffoldRoles, subagentToolFilter } from "./org/roles.js";
|
|
80
81
|
import { buildAgentsIndex, canonicalProjectPath, resolveAgent, loadProjects, addProject, removeProject } from "./org/projects.js";
|
|
@@ -870,7 +871,7 @@ const MEMORY_DISTILL_SYSTEM = "You consolidate an agent's short-term daily memor
|
|
|
870
871
|
// pin their structure and serve's session.compact shares the exact same distillation.
|
|
871
872
|
/** Summarize the conversation and replace history with the summary (keeping working-memory notes). Shared by
|
|
872
873
|
* /compact (manual) and auto-compaction. Returns the summary, or null on failure / nothing to do. */
|
|
873
|
-
async function compactConversation(provider, history, meta, stats, signal) {
|
|
874
|
+
async function compactConversation(provider, history, meta, stats, signal, task) {
|
|
874
875
|
if (history.length < 2 || signal?.aborted)
|
|
875
876
|
return null;
|
|
876
877
|
const r = await boundedProviderTurn(provider, {
|
|
@@ -908,13 +909,13 @@ async function compactConversation(provider, history, meta, stats, signal) {
|
|
|
908
909
|
stats.input += r.usage?.input ?? 0;
|
|
909
910
|
stats.output += r.usage?.output ?? 0;
|
|
910
911
|
stats.lastInput = r.usage?.input ?? 0; // ctx% now reflects the (small) summary, not the old full turn
|
|
911
|
-
saveSession(meta, history);
|
|
912
|
+
saveSession(meta, history, task);
|
|
912
913
|
return summary;
|
|
913
914
|
}
|
|
914
915
|
/** Auto-compact (à la Claude Code) when the last turn filled the context past the threshold, so the NEXT turn
|
|
915
916
|
* doesn't overflow. Opt-out via `autoCompact: false` / `HARA_AUTO_COMPACT=0`. Best-effort; `notify` surfaces
|
|
916
917
|
* a one-line status. Returns true if it compacted. */
|
|
917
|
-
async function maybeAutoCompact(provider, history, meta, stats, cfg, notify, signal) {
|
|
918
|
+
async function maybeAutoCompact(provider, history, meta, stats, cfg, notify, signal, task) {
|
|
918
919
|
if (signal?.aborted)
|
|
919
920
|
return false;
|
|
920
921
|
const lastInput = stats.lastInput ?? 0;
|
|
@@ -929,7 +930,7 @@ async function maybeAutoCompact(provider, history, meta, stats, cfg, notify, sig
|
|
|
929
930
|
if (signal?.aborted)
|
|
930
931
|
return false;
|
|
931
932
|
notify(`✻ Auto-compacting conversation (context ${pct}% full, ~${Math.round(lastInput / 1000)}k tok)…`);
|
|
932
|
-
const summary = await compactConversation(provider, history, meta, stats, signal);
|
|
933
|
+
const summary = await compactConversation(provider, history, meta, stats, signal, task);
|
|
933
934
|
if (signal?.aborted)
|
|
934
935
|
return false;
|
|
935
936
|
notify(summary ? `(auto-compacted — context replaced with a summary; ${meta.workingSet?.length ?? 0} notes kept)` : "(auto-compact failed — use /compact or /clear)");
|
|
@@ -1080,6 +1081,7 @@ program
|
|
|
1080
1081
|
.option("--approval <mode>", "approval mode: suggest | auto-edit | full-auto")
|
|
1081
1082
|
.option("--profile <id>", "use this identity profile for this run (personal / org id) — see `hara profile list`")
|
|
1082
1083
|
.option("--overlay <name>", "apply a named config overlay from ~/.hara/config.json (legacy: --profile)")
|
|
1084
|
+
.option("--cwd <dir>", "run from this explicit project directory (alternative to cd)")
|
|
1083
1085
|
.option("-c, --continue", "resume the most recent session in this directory")
|
|
1084
1086
|
.option("--resume <id>", "resume a specific session by id")
|
|
1085
1087
|
.option("--sandbox <mode>", "sandbox the shell: off | workspace-write | read-only");
|
|
@@ -1090,6 +1092,19 @@ program
|
|
|
1090
1092
|
// Validation: unknown id is a hard fail (don't silently fall through to default; the user
|
|
1091
1093
|
// asked for a specific identity, surface the mistake).
|
|
1092
1094
|
program.hook("preAction", (thisCmd) => {
|
|
1095
|
+
const cwdFlag = thisCmd.opts().cwd;
|
|
1096
|
+
if (cwdFlag) {
|
|
1097
|
+
try {
|
|
1098
|
+
const target = realpathSync.native(resolve(cwdFlag));
|
|
1099
|
+
if (!statSync(target).isDirectory())
|
|
1100
|
+
throw new Error("not a directory");
|
|
1101
|
+
process.chdir(target);
|
|
1102
|
+
}
|
|
1103
|
+
catch (error) {
|
|
1104
|
+
out(c.red(`Cannot use --cwd '${cwdFlag}': ${error instanceof Error ? error.message : String(error)}.\n`));
|
|
1105
|
+
process.exit(2);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1093
1108
|
const flag = thisCmd.opts().profile;
|
|
1094
1109
|
if (!flag)
|
|
1095
1110
|
return;
|
|
@@ -2917,6 +2932,7 @@ program.action(async (opts) => {
|
|
|
2917
2932
|
// Plain `hara -p` stays stateless. A --resume id with no match is created WITH that id (stable per caller).
|
|
2918
2933
|
let meta = null;
|
|
2919
2934
|
let continuationSession = false;
|
|
2935
|
+
let task;
|
|
2920
2936
|
const history = [];
|
|
2921
2937
|
if (opts.resume || opts.continue) {
|
|
2922
2938
|
const resumeArg = opts.resume ? String(opts.resume) : undefined;
|
|
@@ -2947,6 +2963,7 @@ program.action(async (opts) => {
|
|
|
2947
2963
|
// another gateway/cron process appends history that this stale snapshot later overwrites.
|
|
2948
2964
|
const prior = loadSession(rid);
|
|
2949
2965
|
continuationSession = Boolean(prior?.history.length);
|
|
2966
|
+
task = recoverTaskExecution(prior?.task);
|
|
2950
2967
|
if (sessionFileExists(rid) && !prior) {
|
|
2951
2968
|
process.stderr.write(`hara: session ${shortId(rid)} exists but is unreadable or corrupt; refusing to overwrite it. Inspect ~/.hara/sessions/${rid}.json.\n`);
|
|
2952
2969
|
process.exitCode = 2;
|
|
@@ -2980,8 +2997,8 @@ program.action(async (opts) => {
|
|
|
2980
2997
|
updatedAt: "",
|
|
2981
2998
|
...(src.source !== "interactive" ? { source: src.source, sourceName: src.sourceName } : { source: "interactive" }),
|
|
2982
2999
|
};
|
|
2983
|
-
//
|
|
2984
|
-
//
|
|
3000
|
+
// Checklist continuity remains in meta; execution identity is restored independently in top-level
|
|
3001
|
+
// SessionData.task so transcript/interaction changes cannot silently replace the active objective.
|
|
2985
3002
|
restoreTodos(meta.todos);
|
|
2986
3003
|
onTodosChange((list) => {
|
|
2987
3004
|
if (meta)
|
|
@@ -3075,6 +3092,21 @@ program.action(async (opts) => {
|
|
|
3075
3092
|
else {
|
|
3076
3093
|
history.push({ role: "user", content: userText });
|
|
3077
3094
|
}
|
|
3095
|
+
// A resumed unfinished task keeps its objective; this prompt is a new turn that steers it. A completed
|
|
3096
|
+
// task (or a stateless -p run) starts a fresh execution identity. Persist before provider side effects so
|
|
3097
|
+
// a crash is recoverable as paused rather than looking like a completed turn.
|
|
3098
|
+
const headlessInteraction = task && task.status !== "completed"
|
|
3099
|
+
? newSteerInteraction(task.turnId)
|
|
3100
|
+
: newTurnInteraction();
|
|
3101
|
+
if (headlessInteraction.kind === "steer") {
|
|
3102
|
+
const continued = continueTaskExecution(task, headlessInteraction);
|
|
3103
|
+
task = continued.ok ? continued.task : createTaskExecution(String(opts.print), headlessInteraction.turnId);
|
|
3104
|
+
}
|
|
3105
|
+
else {
|
|
3106
|
+
task = createTaskExecution(String(opts.print), headlessInteraction.turnId);
|
|
3107
|
+
}
|
|
3108
|
+
if (meta)
|
|
3109
|
+
saveSession(meta, history, task);
|
|
3078
3110
|
// --role: run this headless turn AS an org role/agent persona (the gateway's /agent switch lands here).
|
|
3079
3111
|
// Local roles resolve at cwd; qualified project agents were resolved before config/provider startup and
|
|
3080
3112
|
// cwd is already their registered home. Explicit global roles remain portable in the current project.
|
|
@@ -3109,6 +3141,7 @@ program.action(async (opts) => {
|
|
|
3109
3141
|
projectContext,
|
|
3110
3142
|
memory: memoryDigest(cwd),
|
|
3111
3143
|
continuationSession,
|
|
3144
|
+
executionContext: taskExecutionContext(task, headlessInteraction),
|
|
3112
3145
|
...(roleOverride ? { systemOverride: roleOverride } : {}),
|
|
3113
3146
|
...(headlessToolFilter ? { toolFilter: headlessToolFilter } : {}),
|
|
3114
3147
|
hooks: headlessHooks,
|
|
@@ -3153,12 +3186,13 @@ program.action(async (opts) => {
|
|
|
3153
3186
|
}
|
|
3154
3187
|
}
|
|
3155
3188
|
if (meta) {
|
|
3189
|
+
task = finishTaskExecution(task, runOutcome, meta.todos ?? [], false);
|
|
3156
3190
|
// Long-session safety: auto-compact before saving so a long chat/cron thread never overflows context.
|
|
3157
3191
|
// Silent (no-op notify) in headless mode so nothing leaks into a captured -p reply. Opt-out via config.
|
|
3158
3192
|
if (runOutcome.status === "completed") {
|
|
3159
|
-
await maybeAutoCompact(headlessProvider, history, meta, stats, cfg, () => { });
|
|
3193
|
+
await maybeAutoCompact(headlessProvider, history, meta, stats, cfg, () => { }, undefined, task);
|
|
3160
3194
|
}
|
|
3161
|
-
saveSession(meta, history); // persist when resuming/continuing; plain -p stays stateless
|
|
3195
|
+
saveSession(meta, history, task); // persist when resuming/continuing; plain -p stays stateless
|
|
3162
3196
|
}
|
|
3163
3197
|
if (!schemaObj && runOutcome.status === "completed" && (stats.input || stats.output))
|
|
3164
3198
|
out(statusLine(headlessProvider.model, stats.input, stats.output) + "\n");
|
|
@@ -3186,7 +3220,7 @@ program.action(async (opts) => {
|
|
|
3186
3220
|
const useTui = stdin.isTTY && stdout.isTTY && process.env.HARA_TUI !== "0";
|
|
3187
3221
|
out(c.bold(`hara ${pkg.version}`) + c.dim(` · ${cfg.provider}:${cfg.model} · ${approval}${sandbox !== "off" ? ` · sandbox:${sandbox}` : ""} · ${cwd}\n`));
|
|
3188
3222
|
if (homeWorkspace) {
|
|
3189
|
-
out(c.yellow("⚠ Home directory is not treated as a project workspace. Run `cd /path/to/project`; recursive project scans are disabled here.\n"));
|
|
3223
|
+
out(c.yellow("⚠ Home directory is not treated as a project workspace. Run `cd /path/to/project` or `hara --cwd /path/to/project`; recursive project scans are disabled here.\n"));
|
|
3190
3224
|
}
|
|
3191
3225
|
// Startup update notice — cache-driven (a previous session's background probe), so it costs zero
|
|
3192
3226
|
// latency; today's probe (if due) fires in the background for the NEXT launch. TTY sessions only.
|
|
@@ -3312,6 +3346,10 @@ program.action(async (opts) => {
|
|
|
3312
3346
|
updatedAt: "",
|
|
3313
3347
|
source: "interactive",
|
|
3314
3348
|
};
|
|
3349
|
+
// Conversation transcript and task execution are restored independently. A process that disappeared
|
|
3350
|
+
// mid-run leaves `running`; recovery turns it into an explicit paused/interrupted task.
|
|
3351
|
+
let task = recoverTaskExecution(resumed?.task);
|
|
3352
|
+
let resumeTaskPending = Boolean(resumeId && task && task.status !== "completed");
|
|
3315
3353
|
// Task-state continuity (interactive twin of the -p path): restore the checklist, mirror changes onto meta.
|
|
3316
3354
|
restoreTodos(meta.todos);
|
|
3317
3355
|
onTodosChange((list) => {
|
|
@@ -3350,6 +3388,10 @@ program.action(async (opts) => {
|
|
|
3350
3388
|
}
|
|
3351
3389
|
}
|
|
3352
3390
|
const history = resumed?.history ? [...resumed.history] : [];
|
|
3391
|
+
const persistSession = () => saveSession(meta, history, task);
|
|
3392
|
+
const keepUnfinishedTaskActive = () => {
|
|
3393
|
+
resumeTaskPending = Boolean(task && task.status !== "completed");
|
|
3394
|
+
};
|
|
3353
3395
|
let continuationSession = Boolean(resumed?.history.length);
|
|
3354
3396
|
const memorySnap = memoryDigest(cwd); // durable memory, read once (frozen snapshot)
|
|
3355
3397
|
const buildMemory = () => (meta.workingSet?.length ? `## Working memory (this task)\n${meta.workingSet.map((w) => `- ${w}`).join("\n")}\n\n` : "") + memorySnap;
|
|
@@ -3454,7 +3496,7 @@ program.action(async (opts) => {
|
|
|
3454
3496
|
provider = p;
|
|
3455
3497
|
if (bar.isActive())
|
|
3456
3498
|
bar.update({ model: id });
|
|
3457
|
-
|
|
3499
|
+
persistSession(); // persist the session-pinned model so resume restores it
|
|
3458
3500
|
out(c.dim(`(model → ${cfg.provider}:${id}${force ? " · forced (all roles)" : ""})\n`));
|
|
3459
3501
|
}
|
|
3460
3502
|
else
|
|
@@ -3577,6 +3619,19 @@ program.action(async (opts) => {
|
|
|
3577
3619
|
desc: "show what's filling the context window (token breakdown by category)",
|
|
3578
3620
|
run: () => void out(formatContextReport(history, cfg.model) + "\n"),
|
|
3579
3621
|
},
|
|
3622
|
+
{
|
|
3623
|
+
name: "task",
|
|
3624
|
+
desc: "show separated task/run state: /task · /task clear",
|
|
3625
|
+
run: (a) => {
|
|
3626
|
+
if ((a ?? "").trim() === "clear") {
|
|
3627
|
+
task = undefined;
|
|
3628
|
+
resumeTaskPending = false;
|
|
3629
|
+
persistSession();
|
|
3630
|
+
return void out(c.dim("(task state cleared; conversation kept)\n"));
|
|
3631
|
+
}
|
|
3632
|
+
out(formatTaskExecution(task) + "\n");
|
|
3633
|
+
},
|
|
3634
|
+
},
|
|
3580
3635
|
{
|
|
3581
3636
|
name: "rewind",
|
|
3582
3637
|
desc: "fork the conversation back to an earlier turn: /rewind (list) · /rewind <n> (files unchanged)",
|
|
@@ -3591,7 +3646,9 @@ program.action(async (opts) => {
|
|
|
3591
3646
|
return void out(c.dim(`(no such turn: ${arg})\n`));
|
|
3592
3647
|
history.length = 0;
|
|
3593
3648
|
history.push(...nh);
|
|
3594
|
-
|
|
3649
|
+
task = undefined; // the dropped transcript may have owned the current task; do not retain stale identity
|
|
3650
|
+
resumeTaskPending = false;
|
|
3651
|
+
persistSession();
|
|
3595
3652
|
out(c.green(`(rewound — dropped the last ${arg} turn(s); ${history.length} messages kept. Files are unchanged. Type your next message.)\n`));
|
|
3596
3653
|
},
|
|
3597
3654
|
},
|
|
@@ -3620,7 +3677,7 @@ program.action(async (opts) => {
|
|
|
3620
3677
|
currentTurn = compactTurn;
|
|
3621
3678
|
let summary = null;
|
|
3622
3679
|
try {
|
|
3623
|
-
summary = await compactConversation(provider, history, meta, stats, compactTurn.signal);
|
|
3680
|
+
summary = await compactConversation(provider, history, meta, stats, compactTurn.signal, task);
|
|
3624
3681
|
}
|
|
3625
3682
|
finally {
|
|
3626
3683
|
if (currentTurn === compactTurn)
|
|
@@ -3652,7 +3709,7 @@ program.action(async (opts) => {
|
|
|
3652
3709
|
meta.title = a.slice(0, 32);
|
|
3653
3710
|
if (bar.isActive())
|
|
3654
3711
|
bar.update({ sessionName: meta.title });
|
|
3655
|
-
|
|
3712
|
+
persistSession();
|
|
3656
3713
|
out(c.green(`(renamed → ${meta.title})\n`));
|
|
3657
3714
|
},
|
|
3658
3715
|
},
|
|
@@ -3662,13 +3719,15 @@ program.action(async (opts) => {
|
|
|
3662
3719
|
desc: "clear conversation context",
|
|
3663
3720
|
run: () => {
|
|
3664
3721
|
history.length = 0;
|
|
3722
|
+
task = undefined;
|
|
3723
|
+
resumeTaskPending = false;
|
|
3665
3724
|
recalledContext = "";
|
|
3666
3725
|
clearTodos();
|
|
3667
3726
|
meta.todos = [];
|
|
3668
3727
|
resetReachability();
|
|
3669
3728
|
resetRepeatGuard();
|
|
3670
3729
|
clearTouched();
|
|
3671
|
-
|
|
3730
|
+
persistSession();
|
|
3672
3731
|
out(c.dim("(context cleared)\n"));
|
|
3673
3732
|
},
|
|
3674
3733
|
},
|
|
@@ -3829,14 +3888,14 @@ program.action(async (opts) => {
|
|
|
3829
3888
|
// never saw update notices and versions silently went stale (field report: stuck on 0.112.5)
|
|
3830
3889
|
updateNotice: cfg.updateCheck ? (checkForUpdate(pkg.version) ?? undefined) : undefined,
|
|
3831
3890
|
workspaceNotice: homeWorkspace
|
|
3832
|
-
? "Home is not a project workspace · cd /path/to/project
|
|
3891
|
+
? "Home is not a project workspace · cd there or use hara --cwd /path/to/project"
|
|
3833
3892
|
: undefined,
|
|
3834
3893
|
},
|
|
3835
3894
|
visionNotice: __visionNotice,
|
|
3836
3895
|
cycleApproval: (m) => cycleMode(m),
|
|
3837
3896
|
onClipboardImage: readClipboardImage,
|
|
3838
3897
|
vim: cfg.vimMode,
|
|
3839
|
-
onSubmit: async (line, h, images) => {
|
|
3898
|
+
onSubmit: async (line, h, images, interaction) => {
|
|
3840
3899
|
// A dropped/pasted file path (`/Users/…/doc.md`, maybe trailing text/images) starts with '/' but
|
|
3841
3900
|
// is NOT a command — treat it as a file to read, not "Unknown command" (see isSlashCommand).
|
|
3842
3901
|
if (isSlashCommand(line)) {
|
|
@@ -3858,7 +3917,7 @@ program.action(async (opts) => {
|
|
|
3858
3917
|
signal: h.signal,
|
|
3859
3918
|
...agentRunLimits(cfg),
|
|
3860
3919
|
});
|
|
3861
|
-
|
|
3920
|
+
persistSession();
|
|
3862
3921
|
}
|
|
3863
3922
|
catch {
|
|
3864
3923
|
/* exit anyway */
|
|
@@ -3872,6 +3931,8 @@ program.action(async (opts) => {
|
|
|
3872
3931
|
return void h.sink.notice(getTools().map((t) => `${t.name}${t.kind !== "read" ? " *" : ""} — ${t.description}`).join("\n"));
|
|
3873
3932
|
if (nm === "reset" || nm === "clear") {
|
|
3874
3933
|
history.length = 0;
|
|
3934
|
+
task = undefined;
|
|
3935
|
+
resumeTaskPending = false;
|
|
3875
3936
|
continuationSession = false;
|
|
3876
3937
|
recalledContext = "";
|
|
3877
3938
|
resetReachability(); // fresh start — drop any "host unreachable" marks (network may be fixed)
|
|
@@ -3879,7 +3940,7 @@ program.action(async (opts) => {
|
|
|
3879
3940
|
clearTouched();
|
|
3880
3941
|
clearTodos();
|
|
3881
3942
|
meta.todos = [];
|
|
3882
|
-
|
|
3943
|
+
persistSession();
|
|
3883
3944
|
return void h.sink.notice("(context cleared)");
|
|
3884
3945
|
}
|
|
3885
3946
|
if (nm === "undo") {
|
|
@@ -3911,7 +3972,7 @@ program.action(async (opts) => {
|
|
|
3911
3972
|
if (!p2)
|
|
3912
3973
|
return void h.sink.notice("(could not rebuild provider)");
|
|
3913
3974
|
provider = p2;
|
|
3914
|
-
|
|
3975
|
+
persistSession();
|
|
3915
3976
|
return void h.sink.notice(`(model → ${cfg.provider}:${cfg.model} · thinking ${chosen.effort ?? "default"})`);
|
|
3916
3977
|
}
|
|
3917
3978
|
cfg.model = id;
|
|
@@ -3922,7 +3983,7 @@ program.action(async (opts) => {
|
|
|
3922
3983
|
const p = await buildProvider(cfg);
|
|
3923
3984
|
if (p) {
|
|
3924
3985
|
provider = p;
|
|
3925
|
-
|
|
3986
|
+
persistSession(); // persist the session-pinned model so resume restores it
|
|
3926
3987
|
return void h.sink.notice(`(model → ${cfg.provider}:${id}${force ? " · forced (all roles)" : ""})`);
|
|
3927
3988
|
}
|
|
3928
3989
|
return void h.sink.notice("(could not rebuild provider)");
|
|
@@ -3941,11 +4002,20 @@ program.action(async (opts) => {
|
|
|
3941
4002
|
return void h.sink.notice(`session: ${meta.title || "(untitled)"} · ${meta.id}`);
|
|
3942
4003
|
meta.title = arg.slice(0, 32);
|
|
3943
4004
|
h.sink.session(meta.title);
|
|
3944
|
-
|
|
4005
|
+
persistSession();
|
|
3945
4006
|
return void h.sink.notice(`(renamed → ${meta.title})`);
|
|
3946
4007
|
}
|
|
3947
4008
|
if (nm === "context")
|
|
3948
4009
|
return void h.sink.notice(formatContextReport(history, cfg.model));
|
|
4010
|
+
if (nm === "task") {
|
|
4011
|
+
if (arg === "clear") {
|
|
4012
|
+
task = undefined;
|
|
4013
|
+
resumeTaskPending = false;
|
|
4014
|
+
persistSession();
|
|
4015
|
+
return void h.sink.notice("(task state cleared; conversation kept)");
|
|
4016
|
+
}
|
|
4017
|
+
return void h.sink.notice(formatTaskExecution(task));
|
|
4018
|
+
}
|
|
3949
4019
|
if (nm === "rewind") {
|
|
3950
4020
|
if (!arg) {
|
|
3951
4021
|
const turns = userTurnPreviews(history);
|
|
@@ -3956,7 +4026,9 @@ program.action(async (opts) => {
|
|
|
3956
4026
|
return void h.sink.notice(`(no such turn: ${arg})`);
|
|
3957
4027
|
history.length = 0;
|
|
3958
4028
|
history.push(...nh);
|
|
3959
|
-
|
|
4029
|
+
task = undefined;
|
|
4030
|
+
resumeTaskPending = false;
|
|
4031
|
+
persistSession();
|
|
3960
4032
|
return void h.sink.notice(`(rewound — kept ${history.length} messages; files unchanged. Type your next message.)`);
|
|
3961
4033
|
}
|
|
3962
4034
|
if (nm === "checkpoint") {
|
|
@@ -3999,7 +4071,7 @@ program.action(async (opts) => {
|
|
|
3999
4071
|
if (h.signal.aborted || (distillOutcome && distillOutcome.status !== "completed")) {
|
|
4000
4072
|
return void h.sink.notice("(compact cancelled — memory distillation was interrupted)");
|
|
4001
4073
|
}
|
|
4002
|
-
const summary = await compactConversation(provider, history, meta, stats, h.signal);
|
|
4074
|
+
const summary = await compactConversation(provider, history, meta, stats, h.signal, task);
|
|
4003
4075
|
return void h.sink.notice(summary ? `(compacted — kept ${meta.workingSet?.length ?? 0} working-memory notes)` : "(nothing to compact / compact failed)");
|
|
4004
4076
|
}
|
|
4005
4077
|
if (nm === "sessions") {
|
|
@@ -4098,10 +4170,21 @@ program.action(async (opts) => {
|
|
|
4098
4170
|
const sk = loadSkillIndex(cwd).find((s) => s.id === nm && s.userInvocable);
|
|
4099
4171
|
if (sk) {
|
|
4100
4172
|
h.sink.notice(`↗ entering ${sk.id}…`);
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
|
|
4173
|
+
resumeTaskPending = false; // an explicit skill entry starts its own task
|
|
4174
|
+
const skillContent = `Skill \`${sk.id}\`:\n${loadSkillBody(sk)}\n\n---\nEntering ${sk.id} mode${arg ? ` — request: ${arg}` : ""}. Follow this skill now. If it has a workspace or live preview, OPEN it FIRST so any existing progress is visible, then proceed — offer to continue existing work or start fresh.`;
|
|
4175
|
+
const skillInteraction = interaction ?? newTurnInteraction();
|
|
4176
|
+
if (skillInteraction.kind === "steer") {
|
|
4177
|
+
const continued = continueTaskExecution(task, skillInteraction);
|
|
4178
|
+
if (!continued.ok)
|
|
4179
|
+
return void h.sink.notice(`(steer rejected: ${continued.reason})`);
|
|
4180
|
+
task = continued.task;
|
|
4181
|
+
}
|
|
4182
|
+
else {
|
|
4183
|
+
task = createTaskExecution(arg || `enter ${sk.id}`, skillInteraction.turnId);
|
|
4184
|
+
}
|
|
4185
|
+
const skillExecutionContext = taskExecutionContext(task, skillInteraction);
|
|
4186
|
+
history.push({ role: "user", content: skillContent });
|
|
4187
|
+
persistSession();
|
|
4105
4188
|
const skin = stats.input;
|
|
4106
4189
|
const skout = stats.output;
|
|
4107
4190
|
// `h.approval` is the TUI-level union (includes "plan"); runAgent wants the config-level
|
|
@@ -4110,7 +4193,7 @@ program.action(async (opts) => {
|
|
|
4110
4193
|
const __skApproval = h.approval === "plan" ? "suggest" : h.approval;
|
|
4111
4194
|
let skillOutcome;
|
|
4112
4195
|
try {
|
|
4113
|
-
skillOutcome = await runAgent(history, { provider, ctx: { cwd, sandbox, spawn, ui: { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice }, ask: h.ask, describeImage: describeScreenshot, locate: locateScreenshot }, approval: __skApproval, confirm: h.confirm, autoApprove, projectContext, memory: buildMemory(), continuationSession, stats, signal: h.signal, fallback: fbOpt, guardian: guardianOpt, ...agentRunLimits(cfg) });
|
|
4196
|
+
skillOutcome = await runAgent(history, { provider, ctx: { cwd, sandbox, spawn, ui: { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice }, ask: h.ask, describeImage: describeScreenshot, locate: locateScreenshot }, approval: __skApproval, confirm: h.confirm, autoApprove, projectContext, memory: buildMemory(), continuationSession, executionContext: skillExecutionContext, stats, signal: h.signal, fallback: fbOpt, guardian: guardianOpt, ...agentRunLimits(cfg) });
|
|
4114
4197
|
}
|
|
4115
4198
|
catch (e) {
|
|
4116
4199
|
h.sink.notice(`[error] ${e?.message ?? e}`);
|
|
@@ -4120,7 +4203,9 @@ program.action(async (opts) => {
|
|
|
4120
4203
|
h.sink.session(meta.title);
|
|
4121
4204
|
}
|
|
4122
4205
|
h.sink.usage(stats.input - skin, stats.output - skout);
|
|
4123
|
-
|
|
4206
|
+
task = finishTaskExecution(task, skillOutcome, meta.todos ?? [], h.signal.aborted);
|
|
4207
|
+
keepUnfinishedTaskActive();
|
|
4208
|
+
persistSession();
|
|
4124
4209
|
return;
|
|
4125
4210
|
}
|
|
4126
4211
|
}
|
|
@@ -4132,6 +4217,25 @@ program.action(async (opts) => {
|
|
|
4132
4217
|
line = inlineLeadingPath(line, existsSync);
|
|
4133
4218
|
const ui = { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice };
|
|
4134
4219
|
const appr = h.approval;
|
|
4220
|
+
let submittedInteraction = interaction ?? newTurnInteraction();
|
|
4221
|
+
if (resumeTaskPending && task && submittedInteraction.kind === "turn") {
|
|
4222
|
+
submittedInteraction = { kind: "steer", expectedTurnId: task.turnId, turnId: submittedInteraction.turnId };
|
|
4223
|
+
}
|
|
4224
|
+
const beginExecution = (objective) => {
|
|
4225
|
+
if (submittedInteraction.kind === "steer") {
|
|
4226
|
+
const continued = continueTaskExecution(task, submittedInteraction);
|
|
4227
|
+
if (!continued.ok) {
|
|
4228
|
+
h.sink.notice(`(steer rejected: ${continued.reason})`);
|
|
4229
|
+
return null;
|
|
4230
|
+
}
|
|
4231
|
+
task = continued.task;
|
|
4232
|
+
}
|
|
4233
|
+
else {
|
|
4234
|
+
task = createTaskExecution(objective, submittedInteraction.turnId);
|
|
4235
|
+
}
|
|
4236
|
+
resumeTaskPending = false;
|
|
4237
|
+
return taskExecutionContext(task, submittedInteraction);
|
|
4238
|
+
};
|
|
4135
4239
|
// Type-ahead steering: fold messages typed mid-turn into the next model call (codex-style) so a
|
|
4136
4240
|
// clarification/addition course-corrects the live task, rather than waiting for a fresh turn.
|
|
4137
4241
|
// Shared by every turn below (plan investigate, plan execute, and the regular turn).
|
|
@@ -4143,8 +4247,18 @@ program.action(async (opts) => {
|
|
|
4143
4247
|
const attach = !r2.skip && r2.attach?.length ? r2.attach : undefined;
|
|
4144
4248
|
if (!body.trim() && !attach)
|
|
4145
4249
|
continue; // image-only message whose image was skipped → nothing to add
|
|
4250
|
+
const recorded = recordTaskSteering(task, it.expectedTurnId, body || "(image-only steering)");
|
|
4251
|
+
if (!recorded.ok) {
|
|
4252
|
+
h.sink.notice(`(steer rejected: ${recorded.reason})`);
|
|
4253
|
+
continue;
|
|
4254
|
+
}
|
|
4255
|
+
task = recorded.task;
|
|
4146
4256
|
out.push({ role: "user", content: `${INTERJECT_PREFIX}\n\n${body}`, ...(attach ? { images: attach } : {}) });
|
|
4147
4257
|
}
|
|
4258
|
+
// runAgent appends these immediately after return. Persist an equivalent snapshot first so an
|
|
4259
|
+
// abrupt process exit cannot lose accepted steering while retaining a running task identity.
|
|
4260
|
+
if (out.length)
|
|
4261
|
+
saveSession(meta, [...history, ...out], task);
|
|
4148
4262
|
return out;
|
|
4149
4263
|
};
|
|
4150
4264
|
const turnStart = Date.now(); // for the task-done notification (gated on elapsed)
|
|
@@ -4155,7 +4269,12 @@ program.action(async (opts) => {
|
|
|
4155
4269
|
const planImg = await resolveImages(images, h);
|
|
4156
4270
|
if (planImg.skip)
|
|
4157
4271
|
return;
|
|
4158
|
-
|
|
4272
|
+
const planContent = (recalledContext ? `${recalledContext}\n\n---\n\n` : "") + await expandMentionsAsync(line, cwd, { signal: h.signal }) + (planImg.extraText ?? "");
|
|
4273
|
+
const executionContext = beginExecution(line);
|
|
4274
|
+
if (!executionContext)
|
|
4275
|
+
return;
|
|
4276
|
+
history.push({ role: "user", content: planContent, ...(planImg.attach?.length ? { images: planImg.attach } : {}) });
|
|
4277
|
+
persistSession();
|
|
4159
4278
|
recalledContext = "";
|
|
4160
4279
|
const pin = stats.input;
|
|
4161
4280
|
const pout = stats.output;
|
|
@@ -4187,6 +4306,7 @@ program.action(async (opts) => {
|
|
|
4187
4306
|
memory: buildMemory(),
|
|
4188
4307
|
projectContext,
|
|
4189
4308
|
continuationSession,
|
|
4309
|
+
executionContext,
|
|
4190
4310
|
stats,
|
|
4191
4311
|
signal: h.signal,
|
|
4192
4312
|
pendingInput,
|
|
@@ -4197,8 +4317,11 @@ program.action(async (opts) => {
|
|
|
4197
4317
|
h.sink.session(meta.title);
|
|
4198
4318
|
}
|
|
4199
4319
|
h.sink.usage(stats.input - pin, stats.output - pout);
|
|
4200
|
-
|
|
4320
|
+
persistSession();
|
|
4201
4321
|
if (planOutcome.status !== "completed" || h.signal.aborted) {
|
|
4322
|
+
task = finishTaskExecution(task, planOutcome, meta.todos ?? [], h.signal.aborted);
|
|
4323
|
+
keepUnfinishedTaskActive();
|
|
4324
|
+
persistSession();
|
|
4202
4325
|
notifyDone(cfg.notify, {
|
|
4203
4326
|
message: planOutcome.error ?? "plan turn did not complete",
|
|
4204
4327
|
elapsedMs: Date.now() - turnStart,
|
|
@@ -4208,6 +4331,9 @@ program.action(async (opts) => {
|
|
|
4208
4331
|
}
|
|
4209
4332
|
if (!proposedPlan) {
|
|
4210
4333
|
// No exit_plan this turn — the model was investigating or answering. Stay in plan mode quietly.
|
|
4334
|
+
task = finishTaskExecution(task, planOutcome, meta.todos ?? [], h.signal.aborted);
|
|
4335
|
+
keepUnfinishedTaskActive();
|
|
4336
|
+
persistSession();
|
|
4211
4337
|
notifyDone(cfg.notify, {
|
|
4212
4338
|
message: meta.title || "plan turn complete",
|
|
4213
4339
|
elapsedMs: Date.now() - turnStart,
|
|
@@ -4233,6 +4359,7 @@ program.action(async (opts) => {
|
|
|
4233
4359
|
autoApprove,
|
|
4234
4360
|
projectContext,
|
|
4235
4361
|
continuationSession,
|
|
4362
|
+
executionContext,
|
|
4236
4363
|
stats,
|
|
4237
4364
|
signal: h.signal,
|
|
4238
4365
|
pendingInput,
|
|
@@ -4240,8 +4367,12 @@ program.action(async (opts) => {
|
|
|
4240
4367
|
...agentRunLimits(cfg),
|
|
4241
4368
|
});
|
|
4242
4369
|
h.sink.usage(stats.input - xin, stats.output - xout);
|
|
4243
|
-
saveSession(meta, history);
|
|
4244
4370
|
}
|
|
4371
|
+
task = choice === "no"
|
|
4372
|
+
? finishTaskExecution(task, planOutcome, [{ text: "execute approved plan", status: "pending" }], false)
|
|
4373
|
+
: finishTaskExecution(task, planOutcome, meta.todos ?? [], h.signal.aborted);
|
|
4374
|
+
keepUnfinishedTaskActive();
|
|
4375
|
+
persistSession();
|
|
4245
4376
|
notifyDone(cfg.notify, {
|
|
4246
4377
|
message: planOutcome.status === "halted" ? (planOutcome.error ?? "agent run halted") : (meta.title || "plan turn complete"),
|
|
4247
4378
|
elapsedMs: Date.now() - turnStart,
|
|
@@ -4254,7 +4385,11 @@ program.action(async (opts) => {
|
|
|
4254
4385
|
return;
|
|
4255
4386
|
const userContent = (recalledContext ? `${recalledContext}\n\n---\n\n` : "") + await expandMentionsAsync(line, cwd, { signal: h.signal }) + (ri.extraText ?? "");
|
|
4256
4387
|
recalledContext = "";
|
|
4388
|
+
const executionContext = beginExecution(line);
|
|
4389
|
+
if (!executionContext)
|
|
4390
|
+
return;
|
|
4257
4391
|
history.push({ role: "user", content: userContent, ...(ri.attach?.length ? { images: ri.attach } : {}) });
|
|
4392
|
+
persistSession();
|
|
4258
4393
|
if (cfg.fileCheckpoints)
|
|
4259
4394
|
checkpoint(cwd, line.slice(0, 80)); // shadow-git snapshot before the turn mutates
|
|
4260
4395
|
const beforeIn = stats.input;
|
|
@@ -4268,6 +4403,7 @@ program.action(async (opts) => {
|
|
|
4268
4403
|
autoApprove,
|
|
4269
4404
|
projectContext,
|
|
4270
4405
|
continuationSession,
|
|
4406
|
+
executionContext,
|
|
4271
4407
|
stats,
|
|
4272
4408
|
signal: h.signal,
|
|
4273
4409
|
pendingInput,
|
|
@@ -4285,9 +4421,11 @@ program.action(async (opts) => {
|
|
|
4285
4421
|
elapsedMs: Date.now() - turnStart,
|
|
4286
4422
|
minMs: turnOutcome.status === "halted" ? 0 : undefined,
|
|
4287
4423
|
});
|
|
4288
|
-
|
|
4424
|
+
task = finishTaskExecution(task, turnOutcome, meta.todos ?? [], h.signal.aborted);
|
|
4425
|
+
keepUnfinishedTaskActive();
|
|
4426
|
+
persistSession();
|
|
4289
4427
|
if (turnOutcome.status === "completed" && !h.signal.aborted) {
|
|
4290
|
-
await maybeAutoCompact(provider, history, meta, stats, cfg, (m) => h.sink.notice(m), h.signal);
|
|
4428
|
+
await maybeAutoCompact(provider, history, meta, stats, cfg, (m) => h.sink.notice(m), h.signal, task);
|
|
4291
4429
|
}
|
|
4292
4430
|
},
|
|
4293
4431
|
});
|
|
@@ -4331,15 +4469,18 @@ program.action(async (opts) => {
|
|
|
4331
4469
|
// ENTER the mode: load the skill + run a kickoff turn now (mirrors the TUI path) so e.g. /design
|
|
4332
4470
|
// opens its workspace + surfaces prior progress immediately, instead of just staging context.
|
|
4333
4471
|
out(c.dim(`↗ entering ${sk.id}…\n`));
|
|
4334
|
-
|
|
4335
|
-
|
|
4336
|
-
|
|
4337
|
-
});
|
|
4472
|
+
const skillContent = `Skill \`${sk.id}\`:\n${loadSkillBody(sk)}\n\n---\nEntering ${sk.id} mode${rest.length ? ` — request: ${rest.join(" ")}` : ""}. Follow this skill now. If it has a workspace or live preview, OPEN it FIRST so any existing progress is visible, then proceed — offer to continue existing work or start fresh.`;
|
|
4473
|
+
const skillInteraction = newTurnInteraction();
|
|
4474
|
+
resumeTaskPending = false;
|
|
4475
|
+
task = createTaskExecution(rest.join(" ") || `enter ${sk.id}`, skillInteraction.turnId);
|
|
4476
|
+
const skillExecutionContext = taskExecutionContext(task, skillInteraction);
|
|
4477
|
+
history.push({ role: "user", content: skillContent });
|
|
4478
|
+
persistSession();
|
|
4338
4479
|
const skillTurn = new AbortController();
|
|
4339
4480
|
currentTurn = skillTurn;
|
|
4340
4481
|
let skillOutcome;
|
|
4341
4482
|
try {
|
|
4342
|
-
skillOutcome = await runAgent(history, { provider, ctx: { cwd, sandbox, spawn, ask: askUser }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), continuationSession, stats, signal: skillTurn.signal, fallback: fbOpt, guardian: guardianOpt, ...agentRunLimits(cfg) });
|
|
4483
|
+
skillOutcome = await runAgent(history, { provider, ctx: { cwd, sandbox, spawn, ask: askUser }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), continuationSession, executionContext: skillExecutionContext, stats, signal: skillTurn.signal, fallback: fbOpt, guardian: guardianOpt, ...agentRunLimits(cfg) });
|
|
4343
4484
|
}
|
|
4344
4485
|
catch (e) {
|
|
4345
4486
|
out(c.red(`\n[error] ${e.message}\n`));
|
|
@@ -4348,7 +4489,9 @@ program.action(async (opts) => {
|
|
|
4348
4489
|
if (!meta.title && skillOutcome?.status === "completed" && !skillTurn.signal.aborted) {
|
|
4349
4490
|
meta.title = await nameSession(provider, history, skillTurn.signal);
|
|
4350
4491
|
}
|
|
4351
|
-
|
|
4492
|
+
task = finishTaskExecution(task, skillOutcome, meta.todos ?? [], skillTurn.signal.aborted);
|
|
4493
|
+
keepUnfinishedTaskActive();
|
|
4494
|
+
persistSession();
|
|
4352
4495
|
}
|
|
4353
4496
|
finally {
|
|
4354
4497
|
if (currentTurn === skillTurn)
|
|
@@ -4369,7 +4512,24 @@ program.action(async (opts) => {
|
|
|
4369
4512
|
line = inlineLeadingPath(line, existsSync); // leading dropped file path → @-mention so it's read in
|
|
4370
4513
|
const userContent = (recalledContext ? `${recalledContext}\n\n---\n\n` : "") + await expandMentionsAsync(line, cwd);
|
|
4371
4514
|
recalledContext = "";
|
|
4515
|
+
let interaction = newTurnInteraction();
|
|
4516
|
+
if (resumeTaskPending && task)
|
|
4517
|
+
interaction = newSteerInteraction(task.turnId);
|
|
4518
|
+
if (interaction.kind === "steer") {
|
|
4519
|
+
const continued = continueTaskExecution(task, interaction);
|
|
4520
|
+
if (!continued.ok) {
|
|
4521
|
+
out(c.red(`(steer rejected: ${continued.reason})\n`));
|
|
4522
|
+
continue;
|
|
4523
|
+
}
|
|
4524
|
+
task = continued.task;
|
|
4525
|
+
}
|
|
4526
|
+
else {
|
|
4527
|
+
task = createTaskExecution(line, interaction.turnId);
|
|
4528
|
+
}
|
|
4529
|
+
resumeTaskPending = false;
|
|
4530
|
+
const executionContext = taskExecutionContext(task, interaction);
|
|
4372
4531
|
history.push({ role: "user", content: userContent });
|
|
4532
|
+
persistSession();
|
|
4373
4533
|
if (cfg.fileCheckpoints)
|
|
4374
4534
|
checkpoint(cwd, userContent.slice(0, 80)); // shadow-git snapshot before the turn mutates
|
|
4375
4535
|
const turnController = new AbortController();
|
|
@@ -4377,7 +4537,7 @@ program.action(async (opts) => {
|
|
|
4377
4537
|
const t0 = Date.now();
|
|
4378
4538
|
let turnOutcome;
|
|
4379
4539
|
try {
|
|
4380
|
-
turnOutcome = await runAgent(history, { provider, ctx: { cwd, sandbox, spawn, ask: askUser }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), continuationSession, stats, signal: turnController.signal, fallback: fbOpt, guardian: guardianOpt, ...agentRunLimits(cfg) });
|
|
4540
|
+
turnOutcome = await runAgent(history, { provider, ctx: { cwd, sandbox, spawn, ask: askUser }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), continuationSession, executionContext, stats, signal: turnController.signal, fallback: fbOpt, guardian: guardianOpt, ...agentRunLimits(cfg) });
|
|
4381
4541
|
}
|
|
4382
4542
|
catch (e) {
|
|
4383
4543
|
out(c.red(`\n[error] ${e.message}\n`));
|
|
@@ -4402,9 +4562,11 @@ program.action(async (opts) => {
|
|
|
4402
4562
|
else {
|
|
4403
4563
|
out(statusLine(cfg.model, stats.input, stats.output) + "\n\n");
|
|
4404
4564
|
}
|
|
4405
|
-
|
|
4565
|
+
task = finishTaskExecution(task, turnOutcome, meta.todos ?? [], turnController.signal.aborted);
|
|
4566
|
+
keepUnfinishedTaskActive();
|
|
4567
|
+
persistSession();
|
|
4406
4568
|
const compacted = turnOutcome?.status === "completed" && !turnController.signal.aborted
|
|
4407
|
-
? await maybeAutoCompact(provider, history, meta, stats, cfg, (m) => out(c.dim(`${m}\n`)), turnController.signal)
|
|
4569
|
+
? await maybeAutoCompact(provider, history, meta, stats, cfg, (m) => out(c.dim(`${m}\n`)), turnController.signal, task)
|
|
4408
4570
|
: false;
|
|
4409
4571
|
if (!compacted) {
|
|
4410
4572
|
const ctxPct = bar.ctxPctFor(cfg.model, stats.lastInput ?? 0);
|