@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/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
- // Task-state continuity: restore the persisted checklist, then mirror every change back onto meta
2984
- // live all existing saveSession() sites persist it for free (no per-site threading).
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
- saveSession(meta, history); // persist the session-pinned model so resume restores it
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
- saveSession(meta, history);
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
- saveSession(meta, history);
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
- saveSession(meta, history);
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 (recursive scans disabled here)"
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
- saveSession(meta, history);
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
- saveSession(meta, history);
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
- saveSession(meta, history);
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
- saveSession(meta, history); // persist the session-pinned model so resume restores it
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
- saveSession(meta, history);
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
- saveSession(meta, history);
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
- history.push({
4102
- role: "user",
4103
- content: `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.`,
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
- saveSession(meta, history);
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
- history.push({ role: "user", content: (recalledContext ? `${recalledContext}\n\n---\n\n` : "") + await expandMentionsAsync(line, cwd, { signal: h.signal }) + (planImg.extraText ?? ""), ...(planImg.attach?.length ? { images: planImg.attach } : {}) });
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
- saveSession(meta, history);
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
- saveSession(meta, history);
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
- history.push({
4335
- role: "user",
4336
- content: `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.`,
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
- saveSession(meta, history);
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
- saveSession(meta, history);
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);