@nanhara/hara 0.123.0 → 0.124.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/dist/index.js CHANGED
@@ -26,7 +26,7 @@ import { clearEnrollment, enrollDevice, heartbeat, syncOrgRoles } from "./org-fl
26
26
  import { loadActiveProfile, listProfiles, useProfile, addProfile, upsertProfile, removeProfile, setModel as setProfileModel, resetModel as resetProfileModel, getProfile, effectiveModel, routingLabel, routeHost, activeId, resolveActive, setFlagOverride, writePin, removePin, pinFilePath, DEFAULT_ORG_ID, PERSONAL_ID, } from "./profile/profile.js";
27
27
  import { loadPermissionRules, scaffoldPermissions, globalPermissionsPath, projectPermissionsPath } from "./security/permissions.js";
28
28
  import { routingProvider } from "./agent/route.js";
29
- import { shouldAutoCompact, shouldAutoCompactTokens, AUTO_COMPACT_TOKEN_CAP, COMPACT_SYSTEM, buildFileRestore, workingSetFromSummary } from "./agent/compact.js";
29
+ import { shouldAutoCompact, shouldAutoCompactTokens, AUTO_COMPACT_TOKEN_CAP, COMPACT_SYSTEM, buildFileRestore, compactedConversationHistory, compactedHistoryTokenEstimate, compactionSourceHistory, normalizeCompactionSummary, recentHistoryForCompaction, workingSetFromSummary, } from "./agent/compact.js";
30
30
  import { recentTouched, clearTouched } from "./agent/touched.js";
31
31
  import { INTERJECT_PREFIX, disposeReminderScope } from "./agent/reminders.js";
32
32
  import { checkForUpdate } from "./update-check.js";
@@ -43,6 +43,7 @@ import { installScheduler, uninstallScheduler, isInstalled } from "./cron/instal
43
43
  import { getTools } from "./tools/registry.js";
44
44
  import { resetReachability } from "./tools/net-reachability.js";
45
45
  import { resetRepeatGuard } from "./agent/repeat-guard.js";
46
+ import { allowsEvolutionTool, EVOLUTION_SYSTEM, evolutionStatus, shouldAutoEvolve } from "./agent/evolution.js";
46
47
  import { EXPLORE_SYSTEM } from "./tools/agent.js";
47
48
  import { createAnthropicProvider } from "./providers/anthropic.js";
48
49
  import { createOpenAIProvider } from "./providers/openai.js";
@@ -67,7 +68,7 @@ function renderBgJobs() {
67
68
  });
68
69
  return `Background jobs — /jobs tail <id> · /jobs kill <id>:\n${rows.join("\n")}`;
69
70
  }
70
- import { qwenDeviceLogin, getValidQwenAuth } from "./providers/qwen-oauth.js";
71
+ import { qwenDeviceLogin, getValidQwenAuth, loadQwenToken } from "./providers/qwen-oauth.js";
71
72
  import { loadAgentContext, hasAgentsMd, INIT_PROMPT, findProjectRoot } from "./context/agents-md.js";
72
73
  import { homeWorkspaceActionError, isHomeWorkspace } from "./context/workspace-scope.js";
73
74
  import { getEmbedder } from "./search/embed.js";
@@ -75,7 +76,7 @@ import { collectRepoChunksAsync, collectDirChunksAsync, buildIndex, indexPath, i
75
76
  import { searchHybrid } from "./search/hybrid.js";
76
77
  import { expandMentionsAsync, fileCandidates, isSlashCommand, inlineLeadingPath } from "./context/mentions.js";
77
78
  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";
79
+ import { consumePendingTaskSteering, continueTaskExecution, createTaskExecution, finishTaskExecution, formatTaskExecution, hasPendingTaskSteering, newSteerInteraction, newTurnInteraction, recordTaskSteering, recoverTaskExecution, requestsTaskContinuation, taskExecutionContext, } from "./session/task.js";
79
80
  import { setSessionForceModel, isSessionForceModel, effectiveRoleModel } from "./session/session-model.js";
80
81
  import { loadGlobalRoles, loadRoles, roleToolFilter, scaffoldRoles, subagentToolFilter } from "./org/roles.js";
81
82
  import { buildAgentsIndex, canonicalProjectPath, resolveAgent, loadProjects, addProject, removeProject } from "./org/projects.js";
@@ -860,31 +861,51 @@ const PLAN_SYSTEM = "You are in PLAN MODE — a read-only investigation phase. E
860
861
  "When (and only when) you have a complete, actionable plan, call the `exit_plan` tool with the full plan " +
861
862
  "(concise markdown, short numbered steps), then stop and wait for the user's decision. " +
862
863
  "If the user is asking a question, or the plan isn't ready yet, just answer normally WITHOUT calling exit_plan.";
863
- const DISTILL_SYSTEM = "The session is ending. Reflect and persist only durable, reusable learnings: memory_write for facts / " +
864
- "conventions / the user's preferences, skill_create for reusable how-tos. Be selective — skip the trivial. Then reply DONE.";
865
864
  const MEMORY_DISTILL_SYSTEM = "You consolidate an agent's short-term daily memory logs into its durable long-term memory. You're given " +
866
865
  "the current durable memory and recent daily logs. Extract ONLY durable, reusable facts / decisions / " +
867
866
  "conventions / user preferences from the logs that are NOT already captured, and persist each with " +
868
867
  "memory_write (target=memory, or target=user for preferences; pick the right scope=project|global). " +
869
868
  "Skip the ephemeral, the one-off, and anything already known. Be terse and de-duplicated. Then reply DONE.";
870
- // COMPACT_SYSTEM (the 8-section brief) + workingSetFromSummary live in agent/compact.ts so tests can
871
- // pin their structure and serve's session.compact shares the exact same distillation.
869
+ async function curateSessionLearning(provider, history, cfg, options) {
870
+ // Curation gets a private transcript branch: its DONE/tool chatter must not become task conversation or
871
+ // create assistant→assistant role adjacency. Memory/skill writes remain the only durable side effects.
872
+ const curationHistory = [
873
+ ...history,
874
+ { role: "user", content: "Run the evidence-gated self-evolution curation now. If nothing qualifies, write nothing." },
875
+ ];
876
+ return runAgent(curationHistory, {
877
+ provider,
878
+ ctx: { cwd: options.cwd, sandbox: options.sandbox, spawn: options.spawn, ui: options.ui },
879
+ approval: cfg.assetCapture === "auto" ? "full-auto" : "suggest",
880
+ confirm: options.confirm,
881
+ toolFilter: (name) => allowsEvolutionTool(name, cfg.assetCapture),
882
+ systemOverride: EVOLUTION_SYSTEM,
883
+ memory: options.memory,
884
+ stats: options.stats,
885
+ signal: options.signal,
886
+ ...agentRunLimits(cfg),
887
+ });
888
+ }
889
+ // The bounded checkpoint contract + recent-turn preservation live in agent/compact.ts so CLI and serve
890
+ // cannot drift into different context semantics.
872
891
  /** Summarize the conversation and replace history with the summary (keeping working-memory notes). Shared by
873
892
  * /compact (manual) and auto-compaction. Returns the summary, or null on failure / nothing to do. */
874
893
  async function compactConversation(provider, history, meta, stats, signal, task) {
875
894
  if (history.length < 2 || signal?.aborted)
876
895
  return null;
896
+ const recent = recentHistoryForCompaction(history);
877
897
  const r = await boundedProviderTurn(provider, {
878
898
  system: COMPACT_SYSTEM,
879
- history: [...history, { role: "user", content: "Summarize our conversation so far per the instructions." }],
899
+ history: [...compactionSourceHistory(history), { role: "user", content: "Create the bounded execution checkpoint now." }],
880
900
  tools: [],
881
901
  onText: () => { },
882
902
  }, { timeoutMs: 60_000, label: "conversation compaction", signal });
883
903
  if (signal?.aborted || r.stop === "error")
884
904
  return null;
885
- const summary = r.text.trim();
886
- if (!summary)
905
+ const rawSummary = r.text.trim();
906
+ if (!rawSummary)
887
907
  return null;
908
+ const summary = normalizeCompactionSummary(rawSummary);
888
909
  const workingSet = workingSetFromSummary(summary);
889
910
  // TW5-style file restore: the summary alone loses the working set's ACTUAL content — re-attach the
890
911
  // most recently touched files (current on-disk state, byte-capped) so work continues without re-reads.
@@ -902,13 +923,12 @@ async function compactConversation(provider, history, meta, stats, signal, task)
902
923
  if (signal?.aborted)
903
924
  return null;
904
925
  meta.workingSet = workingSet; // survives the history wipe + injects into the next turns
926
+ const compacted = compactedConversationHistory(summary, recent, restore);
905
927
  history.length = 0;
906
- history.push({ role: "user", content: `Summary of our conversation so far (continue from here):\n\n${summary}` });
907
- if (restore)
908
- history.push({ role: "user", content: restore });
928
+ history.push(...compacted);
909
929
  stats.input += r.usage?.input ?? 0;
910
930
  stats.output += r.usage?.output ?? 0;
911
- stats.lastInput = r.usage?.input ?? 0; // ctx% now reflects the (small) summary, not the old full turn
931
+ stats.lastInput = compactedHistoryTokenEstimate(compacted); // reflect replacement, not the large summarizer request
912
932
  saveSession(meta, history, task);
913
933
  return summary;
914
934
  }
@@ -1023,7 +1043,7 @@ function runDoctor(cfg) {
1023
1043
  const dot = c.dim("·");
1024
1044
  const nodeSupported = unsupportedNodeMessage() === null;
1025
1045
  const hasKey = !!(cfg.apiKey || process.env[providerEnvKey(cfg.provider)] || process.env.HARA_API_KEY);
1026
- const oauthOk = cfg.provider === "qwen-oauth" && existsSync(join(homedir(), ".hara", "qwen-oauth.json"));
1046
+ const oauthOk = cfg.provider === "qwen-oauth" && loadQwenToken() !== null;
1027
1047
  const authed = hasKey || oauthOk;
1028
1048
  const ad = assetsDir();
1029
1049
  const roles = loadRoles(cfg.cwd);
@@ -2006,14 +2026,8 @@ program
2006
2026
  return void out(c.yellow(`↩ ${pane} registered, but no WeChat login (run \`hara gateway --platform weixin --login\`). Your next reply to the bot still injects here.\n`));
2007
2027
  let peer = process.env.HARA_WX_PEER;
2008
2028
  if (!peer) {
2009
- try {
2010
- const f = join(homedir(), ".hara", "weixin", `${creds.account_id}.context-tokens.json`);
2011
- const keys = Object.keys(JSON.parse(readFileSync(f, "utf8")));
2012
- peer = keys.find((k) => k.endsWith("@im.wechat")) || keys[0];
2013
- }
2014
- catch {
2015
- /* no peer file */
2016
- }
2029
+ const peers = wx.weixinKnownPeers(creds.account_id);
2030
+ peer = peers.find((candidate) => candidate.endsWith("@im.wechat")) || peers[0];
2017
2031
  }
2018
2032
  if (peer)
2019
2033
  await wx.weixinAdapter(creds).send(peer, text);
@@ -2416,7 +2430,7 @@ memoryCmd
2416
2430
  ctx: { cwd: cfg.cwd, sandbox: cfg.sandbox },
2417
2431
  approval: "full-auto",
2418
2432
  confirm: async () => true,
2419
- toolFilter: (n) => n === "memory_write" || READONLY_TOOLS.has(n),
2433
+ toolFilter: (n) => allowsEvolutionTool(n, "off"),
2420
2434
  systemOverride: MEMORY_DISTILL_SYSTEM,
2421
2435
  stats,
2422
2436
  ...agentRunLimits(cfg),
@@ -3061,6 +3075,16 @@ program.action(async (opts) => {
3061
3075
  }
3062
3076
  schemaObj = parsed;
3063
3077
  }
3078
+ // Recover any steering that was acknowledged by serve before a crash but had not yet reached a model
3079
+ // round. The next session write commits its transcript copy and consumed marker atomically.
3080
+ const recoveredHeadlessSteering = consumePendingTaskSteering(task);
3081
+ if (recoveredHeadlessSteering) {
3082
+ task = recoveredHeadlessSteering.task;
3083
+ history.push(...recoveredHeadlessSteering.entries.map((entry) => ({
3084
+ role: "user",
3085
+ content: `${INTERJECT_PREFIX}\n\n${entry.content}`,
3086
+ })));
3087
+ }
3064
3088
  // Inbound images (gateway): the platform downloaded the user's photo(s) and passed their paths via env.
3065
3089
  // Let the agent actually SEE them — attached inline for a vision-capable main model, else described via the
3066
3090
  // visionModel sidecar and folded into the message (text-only models can't take image blocks).
@@ -3095,7 +3119,8 @@ program.action(async (opts) => {
3095
3119
  // A resumed unfinished task keeps its objective; this prompt is a new turn that steers it. A completed
3096
3120
  // task (or a stateless -p run) starts a fresh execution identity. Persist before provider side effects so
3097
3121
  // a crash is recoverable as paused rather than looking like a completed turn.
3098
- const headlessInteraction = task && task.status !== "completed"
3122
+ const headlessInteraction = task && task.status !== "completed" &&
3123
+ (recoveredHeadlessSteering !== null || requestsTaskContinuation(String(opts.print)))
3099
3124
  ? newSteerInteraction(task.turnId)
3100
3125
  : newTurnInteraction();
3101
3126
  if (headlessInteraction.kind === "steer") {
@@ -3104,6 +3129,9 @@ program.action(async (opts) => {
3104
3129
  }
3105
3130
  else {
3106
3131
  task = createTaskExecution(String(opts.print), headlessInteraction.turnId);
3132
+ clearTodos();
3133
+ if (meta)
3134
+ meta.todos = [];
3107
3135
  }
3108
3136
  if (meta)
3109
3137
  saveSession(meta, history, task);
@@ -3141,7 +3169,7 @@ program.action(async (opts) => {
3141
3169
  projectContext,
3142
3170
  memory: memoryDigest(cwd),
3143
3171
  continuationSession,
3144
- executionContext: taskExecutionContext(task, headlessInteraction),
3172
+ executionContext: taskExecutionContext(task, headlessInteraction, meta?.todos ?? []),
3145
3173
  ...(roleOverride ? { systemOverride: roleOverride } : {}),
3146
3174
  ...(headlessToolFilter ? { toolFilter: headlessToolFilter } : {}),
3147
3175
  hooks: headlessHooks,
@@ -3432,6 +3460,14 @@ program.action(async (opts) => {
3432
3460
  };
3433
3461
  const commands = [
3434
3462
  { name: "help", desc: "show this help", run: () => void out(helpText(commands)) },
3463
+ {
3464
+ name: "continue",
3465
+ aliases: ["resume"],
3466
+ desc: "resume the unfinished task: /continue [instruction]",
3467
+ // Interactive loops translate this command into an ordinary model turn before slash dispatch. This
3468
+ // fallback only protects future callers that invoke the command table directly.
3469
+ run: () => void out(c.dim("(type /continue [instruction] in an interactive session)\n")),
3470
+ },
3435
3471
  {
3436
3472
  name: "init",
3437
3473
  desc: "analyze project & regenerate AGENTS.md",
@@ -3632,6 +3668,18 @@ program.action(async (opts) => {
3632
3668
  out(formatTaskExecution(task) + "\n");
3633
3669
  },
3634
3670
  },
3671
+ {
3672
+ name: "new",
3673
+ desc: "start a new task while keeping conversation context",
3674
+ run: () => {
3675
+ task = undefined;
3676
+ resumeTaskPending = false;
3677
+ clearTodos();
3678
+ meta.todos = [];
3679
+ persistSession();
3680
+ out(c.dim("(new task boundary; conversation kept — type the new objective)\n"));
3681
+ },
3682
+ },
3635
3683
  {
3636
3684
  name: "rewind",
3637
3685
  desc: "fork the conversation back to an earlier turn: /rewind (list) · /rewind <n> (files unchanged)",
@@ -3668,6 +3716,32 @@ program.action(async (opts) => {
3668
3716
  out(k == null ? c.red("(restore failed)\n") : c.green(`(restored ${k} file(s) to ${cp.sha} — '${cp.label}'; prior state snapshotted too)\n`));
3669
3717
  },
3670
3718
  },
3719
+ {
3720
+ name: "evolve",
3721
+ desc: "show or run auditable self-evolution: /evolve [status|now]",
3722
+ run: async (a) => {
3723
+ const action = (a ?? "").trim() || "status";
3724
+ if (action === "status")
3725
+ return void out(evolutionStatus(cfg) + "\n");
3726
+ if (action !== "now")
3727
+ return void out(c.dim("usage: /evolve [status|now]\n"));
3728
+ if (cfg.evolve === "off")
3729
+ return void out(c.dim("(self-evolution is off — set `hara config set evolve light|proactive` first)\n"));
3730
+ if (history.length < 2)
3731
+ return void out(c.dim("(not enough session evidence to curate)\n"));
3732
+ out(c.dim("Curating evidence-backed memories and reusable skills…\n"));
3733
+ const outcome = await curateSessionLearning(provider, history, cfg, {
3734
+ cwd,
3735
+ sandbox,
3736
+ spawn,
3737
+ confirm,
3738
+ memory: buildMemory(),
3739
+ stats,
3740
+ });
3741
+ persistSession();
3742
+ out(outcome.status === "completed" ? c.green("(self-evolution curation complete)\n") : c.yellow(`(self-evolution stopped: ${outcome.error ?? outcome.status})\n`));
3743
+ },
3744
+ },
3671
3745
  {
3672
3746
  name: "compact",
3673
3747
  desc: "summarize the conversation so far to free up context",
@@ -3677,6 +3751,21 @@ program.action(async (opts) => {
3677
3751
  currentTurn = compactTurn;
3678
3752
  let summary = null;
3679
3753
  try {
3754
+ if (cfg.evolve !== "off") {
3755
+ const distill = await curateSessionLearning(provider, history, cfg, {
3756
+ cwd,
3757
+ sandbox,
3758
+ spawn,
3759
+ confirm,
3760
+ memory: buildMemory(),
3761
+ stats,
3762
+ signal: compactTurn.signal,
3763
+ });
3764
+ if (distill.status !== "completed") {
3765
+ out(c.yellow(`(compact cancelled — self-evolution stopped: ${distill.error ?? distill.status})\n`));
3766
+ return;
3767
+ }
3768
+ }
3680
3769
  summary = await compactConversation(provider, history, meta, stats, compactTurn.signal, task);
3681
3770
  }
3682
3771
  finally {
@@ -3731,7 +3820,24 @@ program.action(async (opts) => {
3731
3820
  out(c.dim("(context cleared)\n"));
3732
3821
  },
3733
3822
  },
3734
- { name: "exit", aliases: ["quit"], desc: "leave", run: () => "exit" },
3823
+ {
3824
+ name: "exit",
3825
+ aliases: ["quit"],
3826
+ desc: "leave",
3827
+ run: async () => {
3828
+ if (shouldAutoEvolve(cfg.evolve, history.length)) {
3829
+ out(c.dim("Distilling session learnings…\n"));
3830
+ try {
3831
+ await curateSessionLearning(provider, history, cfg, { cwd, sandbox, spawn, confirm, memory: buildMemory(), stats });
3832
+ persistSession();
3833
+ }
3834
+ catch {
3835
+ /* exiting remains available if optional curation fails */
3836
+ }
3837
+ }
3838
+ return "exit";
3839
+ },
3840
+ },
3735
3841
  ];
3736
3842
  const byName = new Map();
3737
3843
  for (const cmd of commands) {
@@ -3896,26 +4002,37 @@ program.action(async (opts) => {
3896
4002
  onClipboardImage: readClipboardImage,
3897
4003
  vim: cfg.vimMode,
3898
4004
  onSubmit: async (line, h, images, interaction) => {
4005
+ // `/continue` is a task interaction, not a control-only command: turn it into a model message before
4006
+ // slash dispatch, preserving the submitted turn id while explicitly targeting the unfinished task.
4007
+ const continueCommand = /^\/(?:continue|resume)(?:\s+([\s\S]+))?$/i.exec(line.trim());
4008
+ if (continueCommand) {
4009
+ if (!task || task.status === "completed")
4010
+ return void h.sink.notice("(there is no unfinished task to continue)");
4011
+ line = continueCommand[1]?.trim() || "continue";
4012
+ interaction = {
4013
+ kind: "steer",
4014
+ expectedTurnId: task.turnId,
4015
+ turnId: interaction?.turnId ?? newTurnInteraction().turnId,
4016
+ };
4017
+ }
3899
4018
  // A dropped/pasted file path (`/Users/…/doc.md`, maybe trailing text/images) starts with '/' but
3900
4019
  // is NOT a command — treat it as a file to read, not "Unknown command" (see isSlashCommand).
3901
4020
  if (isSlashCommand(line)) {
3902
4021
  const [nm, ...rest] = line.slice(1).split(/\s+/);
3903
4022
  const arg = rest.join(" ").trim();
3904
4023
  if (nm === "exit" || nm === "quit") {
3905
- if (cfg.evolve === "proactive" && history.length >= 4) {
4024
+ if (shouldAutoEvolve(cfg.evolve, history.length)) {
3906
4025
  h.sink.notice("✻ distilling session learnings…");
3907
4026
  try {
3908
- await runAgent(history, {
3909
- provider,
3910
- 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 } },
3911
- approval: cfg.assetCapture === "auto" ? "full-auto" : "suggest", // ask → prompt before each save; auto → silent
4027
+ await curateSessionLearning(provider, history, cfg, {
4028
+ cwd,
4029
+ sandbox,
4030
+ spawn,
4031
+ ui: { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice },
3912
4032
  confirm: h.confirm,
3913
- toolFilter: (n) => n === "memory_write" || (cfg.assetCapture !== "off" && n === "skill_create") || READONLY_TOOLS.has(n),
3914
- systemOverride: DISTILL_SYSTEM,
3915
4033
  memory: buildMemory(),
3916
4034
  stats,
3917
4035
  signal: h.signal,
3918
- ...agentRunLimits(cfg),
3919
4036
  });
3920
4037
  persistSession();
3921
4038
  }
@@ -3929,6 +4046,29 @@ program.action(async (opts) => {
3929
4046
  return void h.sink.notice(commands.map((x) => `/${x.name} — ${x.desc}`).join("\n"));
3930
4047
  if (nm === "tools")
3931
4048
  return void h.sink.notice(getTools().map((t) => `${t.name}${t.kind !== "read" ? " *" : ""} — ${t.description}`).join("\n"));
4049
+ if (nm === "evolve") {
4050
+ if (!arg || arg === "status")
4051
+ return void h.sink.notice(evolutionStatus(cfg));
4052
+ if (arg !== "now")
4053
+ return void h.sink.notice("usage: /evolve [status|now]");
4054
+ if (cfg.evolve === "off")
4055
+ return void h.sink.notice("(self-evolution is off — set `hara config set evolve light|proactive` first)");
4056
+ if (history.length < 2)
4057
+ return void h.sink.notice("(not enough session evidence to curate)");
4058
+ h.sink.notice("✻ curating evidence-backed memories and reusable skills…");
4059
+ const outcome = await curateSessionLearning(provider, history, cfg, {
4060
+ cwd,
4061
+ sandbox,
4062
+ spawn,
4063
+ ui: { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice },
4064
+ confirm: h.confirm,
4065
+ memory: buildMemory(),
4066
+ stats,
4067
+ signal: h.signal,
4068
+ });
4069
+ persistSession();
4070
+ return void h.sink.notice(outcome.status === "completed" ? "(self-evolution curation complete)" : `(self-evolution stopped: ${outcome.error ?? outcome.status})`);
4071
+ }
3932
4072
  if (nm === "reset" || nm === "clear") {
3933
4073
  history.length = 0;
3934
4074
  task = undefined;
@@ -4016,6 +4156,14 @@ program.action(async (opts) => {
4016
4156
  }
4017
4157
  return void h.sink.notice(formatTaskExecution(task));
4018
4158
  }
4159
+ if (nm === "new") {
4160
+ task = undefined;
4161
+ resumeTaskPending = false;
4162
+ clearTodos();
4163
+ meta.todos = [];
4164
+ persistSession();
4165
+ return void h.sink.notice("(new task boundary; conversation kept — type the new objective)");
4166
+ }
4019
4167
  if (nm === "rewind") {
4020
4168
  if (!arg) {
4021
4169
  const turns = userTurnPreviews(history);
@@ -4051,17 +4199,15 @@ program.action(async (opts) => {
4051
4199
  let distillOutcome;
4052
4200
  if (cfg.evolve !== "off") {
4053
4201
  try {
4054
- distillOutcome = await runAgent(history, {
4055
- provider,
4056
- ctx: { cwd, sandbox, spawn, ui: cui },
4057
- approval: cfg.assetCapture === "auto" ? "full-auto" : "suggest", // ask → prompt before each save; auto → silent
4202
+ distillOutcome = await curateSessionLearning(provider, history, cfg, {
4203
+ cwd,
4204
+ sandbox,
4205
+ spawn,
4206
+ ui: cui,
4058
4207
  confirm: h.confirm,
4059
- toolFilter: (n) => n === "memory_write" || (cfg.assetCapture !== "off" && n === "skill_create") || READONLY_TOOLS.has(n),
4060
- systemOverride: DISTILL_SYSTEM,
4061
4208
  memory: buildMemory(),
4062
4209
  stats,
4063
4210
  signal: h.signal,
4064
- ...agentRunLimits(cfg),
4065
4211
  });
4066
4212
  }
4067
4213
  catch {
@@ -4171,6 +4317,8 @@ program.action(async (opts) => {
4171
4317
  if (sk) {
4172
4318
  h.sink.notice(`↗ entering ${sk.id}…`);
4173
4319
  resumeTaskPending = false; // an explicit skill entry starts its own task
4320
+ clearTodos();
4321
+ meta.todos = [];
4174
4322
  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
4323
  const skillInteraction = interaction ?? newTurnInteraction();
4176
4324
  if (skillInteraction.kind === "steer") {
@@ -4218,7 +4366,8 @@ program.action(async (opts) => {
4218
4366
  const ui = { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice };
4219
4367
  const appr = h.approval;
4220
4368
  let submittedInteraction = interaction ?? newTurnInteraction();
4221
- if (resumeTaskPending && task && submittedInteraction.kind === "turn") {
4369
+ if (resumeTaskPending && task && submittedInteraction.kind === "turn" &&
4370
+ (hasPendingTaskSteering(task) || requestsTaskContinuation(line))) {
4222
4371
  submittedInteraction = { kind: "steer", expectedTurnId: task.turnId, turnId: submittedInteraction.turnId };
4223
4372
  }
4224
4373
  const beginExecution = (objective) => {
@@ -4232,15 +4381,17 @@ program.action(async (opts) => {
4232
4381
  }
4233
4382
  else {
4234
4383
  task = createTaskExecution(objective, submittedInteraction.turnId);
4384
+ clearTodos();
4385
+ meta.todos = [];
4235
4386
  }
4236
4387
  resumeTaskPending = false;
4237
- return taskExecutionContext(task, submittedInteraction);
4388
+ return taskExecutionContext(task, submittedInteraction, meta.todos ?? []);
4238
4389
  };
4239
4390
  // Type-ahead steering: fold messages typed mid-turn into the next model call (codex-style) so a
4240
4391
  // clarification/addition course-corrects the live task, rather than waiting for a fresh turn.
4241
4392
  // Shared by every turn below (plan investigate, plan execute, and the regular turn).
4242
4393
  const pendingInput = async () => {
4243
- const out = [];
4394
+ const freshMessages = new Map();
4244
4395
  for (const it of h.drainQueue()) {
4245
4396
  const r2 = await resolveImages(it.images, h);
4246
4397
  const body = await expandMentionsAsync(it.line, cwd, { signal: h.signal }) + (r2.skip ? "" : (r2.extraText ?? ""));
@@ -4253,13 +4404,25 @@ program.action(async (opts) => {
4253
4404
  continue;
4254
4405
  }
4255
4406
  task = recorded.task;
4256
- out.push({ role: "user", content: `${INTERJECT_PREFIX}\n\n${body}`, ...(attach ? { images: attach } : {}) });
4407
+ const accepted = task.steering?.at(-1);
4408
+ if (accepted?.deliveryState === "pending") {
4409
+ freshMessages.set(accepted.id, { role: "user", content: `${INTERJECT_PREFIX}\n\n${body}`, ...(attach ? { images: attach } : {}) });
4410
+ }
4257
4411
  }
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);
4262
- return out;
4412
+ const consumed = consumePendingTaskSteering(task);
4413
+ if (!consumed)
4414
+ return [];
4415
+ const out = consumed.entries.map((entry) => freshMessages.get(entry.id) ?? ({
4416
+ role: "user",
4417
+ content: `${INTERJECT_PREFIX}\n\n${entry.content}`,
4418
+ }));
4419
+ // Persist the projected transcript and consumed inbox state first, then update the same live
4420
+ // history synchronously. Returning [] prevents runAgent from appending the messages twice;
4421
+ // loop from appending twice; cancellation/error saves can no longer overwrite the projected input.
4422
+ saveSession(meta, [...history, ...out], consumed.task);
4423
+ task = consumed.task;
4424
+ history.push(...out);
4425
+ return [];
4263
4426
  };
4264
4427
  const turnStart = Date.now(); // for the task-done notification (gated on elapsed)
4265
4428
  if (appr === "plan") {
@@ -4459,6 +4622,16 @@ program.action(async (opts) => {
4459
4622
  bar.renderBottom(); // bottom border + modes/usage
4460
4623
  if (!line)
4461
4624
  continue;
4625
+ let forcedContinuation;
4626
+ const continueCommand = /^\/(?:continue|resume)(?:\s+([\s\S]+))?$/i.exec(line);
4627
+ if (continueCommand) {
4628
+ if (!task || task.status === "completed") {
4629
+ out(c.dim("(there is no unfinished task to continue)\n"));
4630
+ continue;
4631
+ }
4632
+ line = continueCommand[1]?.trim() || "continue";
4633
+ forcedContinuation = newSteerInteraction(task.turnId);
4634
+ }
4462
4635
  // A dropped/pasted file path starts with '/' but isn't a command — read it, don't error (see TUI path).
4463
4636
  if (isSlashCommand(line)) {
4464
4637
  const [name, ...rest] = line.slice(1).split(/\s+/);
@@ -4472,6 +4645,8 @@ program.action(async (opts) => {
4472
4645
  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
4646
  const skillInteraction = newTurnInteraction();
4474
4647
  resumeTaskPending = false;
4648
+ clearTodos();
4649
+ meta.todos = [];
4475
4650
  task = createTaskExecution(rest.join(" ") || `enter ${sk.id}`, skillInteraction.turnId);
4476
4651
  const skillExecutionContext = taskExecutionContext(task, skillInteraction);
4477
4652
  history.push({ role: "user", content: skillContent });
@@ -4512,9 +4687,18 @@ program.action(async (opts) => {
4512
4687
  line = inlineLeadingPath(line, existsSync); // leading dropped file path → @-mention so it's read in
4513
4688
  const userContent = (recalledContext ? `${recalledContext}\n\n---\n\n` : "") + await expandMentionsAsync(line, cwd);
4514
4689
  recalledContext = "";
4515
- let interaction = newTurnInteraction();
4516
- if (resumeTaskPending && task)
4690
+ const recoveredClassicSteering = consumePendingTaskSteering(task);
4691
+ if (recoveredClassicSteering) {
4692
+ task = recoveredClassicSteering.task;
4693
+ history.push(...recoveredClassicSteering.entries.map((entry) => ({
4694
+ role: "user",
4695
+ content: `${INTERJECT_PREFIX}\n\n${entry.content}`,
4696
+ })));
4697
+ }
4698
+ let interaction = forcedContinuation ?? newTurnInteraction();
4699
+ if (resumeTaskPending && task && (recoveredClassicSteering !== null || requestsTaskContinuation(line))) {
4517
4700
  interaction = newSteerInteraction(task.turnId);
4701
+ }
4518
4702
  if (interaction.kind === "steer") {
4519
4703
  const continued = continueTaskExecution(task, interaction);
4520
4704
  if (!continued.ok) {
@@ -4525,9 +4709,11 @@ program.action(async (opts) => {
4525
4709
  }
4526
4710
  else {
4527
4711
  task = createTaskExecution(line, interaction.turnId);
4712
+ clearTodos();
4713
+ meta.todos = [];
4528
4714
  }
4529
4715
  resumeTaskPending = false;
4530
- const executionContext = taskExecutionContext(task, interaction);
4716
+ const executionContext = taskExecutionContext(task, interaction, meta.todos ?? []);
4531
4717
  history.push({ role: "user", content: userContent });
4532
4718
  persistSession();
4533
4719
  if (cfg.fileCheckpoints)
@@ -11,10 +11,10 @@
11
11
  // POST {gateway}/v1/chat/completions (OpenAI-compatible; the normal agent traffic, Bearer <device_token>)
12
12
  import { homedir, hostname, platform } from "node:os";
13
13
  import { dirname, join, resolve } from "node:path";
14
- import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, rmSync } from "node:fs";
14
+ import { writeFileSync, mkdirSync, rmSync } from "node:fs";
15
15
  import { orgRolesDir } from "../org/roles.js";
16
16
  import { loadActiveProfile } from "../profile/profile.js";
17
- const orgPath = () => join(homedir(), ".hara", "org.json");
17
+ import { bindPrivateHaraStateFile, readPrivateStateFileSnapshotSync, removePrivateStateFile, writePrivateStateFileSync, } from "../security/private-state.js";
18
18
  const deviceInfo = () => ({ name: hostname(), os: platform(), hara_version: process.env.HARA_BUILD_VERSION ?? "dev" });
19
19
  /** The effective OpenAI-compatible base URL for an enrollment (explicit, else <gatewayUrl>/v1). */
20
20
  export function gatewayBaseURL(e) {
@@ -24,16 +24,15 @@ export function loadEnrollment() {
24
24
  // 1) Legacy storage (~/.hara/org.json) for back-compat with pre-profile builds. After the
25
25
  // profile migration runs (lazily on any profile.ts read), org.json is renamed to .legacy
26
26
  // so this branch only fires for users who never touched the new profile layer yet.
27
- const p = orgPath();
28
- if (existsSync(p)) {
29
- try {
30
- const e = JSON.parse(readFileSync(p, "utf8"));
31
- if (e && typeof e === "object" && e.gatewayUrl && e.deviceToken)
32
- return e;
33
- }
34
- catch {
35
- /* fall through to profile-derived */
36
- }
27
+ try {
28
+ const binding = bindPrivateHaraStateFile(homedir(), [], "org.json");
29
+ const snapshot = readPrivateStateFileSnapshotSync(binding.path, 1024 * 1024);
30
+ const e = snapshot ? JSON.parse(snapshot.text) : null;
31
+ if (e && typeof e === "object" && e.gatewayUrl && e.deviceToken)
32
+ return e;
33
+ }
34
+ catch {
35
+ /* fall through to profile-derived */
37
36
  }
38
37
  // 2) Active-profile path. profile.ts doesn't import enroll.ts so this static import is safe.
39
38
  try {
@@ -55,19 +54,15 @@ export function loadEnrollment() {
55
54
  return null;
56
55
  }
57
56
  function saveEnrollment(e) {
58
- mkdirSync(join(homedir(), ".hara"), { recursive: true });
59
- writeFileSync(orgPath(), JSON.stringify(e, null, 2) + "\n", { encoding: "utf8", mode: 0o600 }); // holds a device token → 0600
60
- try {
61
- chmodSync(orgPath(), 0o600);
62
- }
63
- catch {
64
- /* best-effort */
65
- }
57
+ const binding = bindPrivateHaraStateFile(homedir(), [], "org.json");
58
+ writePrivateStateFileSync(binding, JSON.stringify(e, null, 2) + "\n");
66
59
  }
67
60
  export function clearEnrollment() {
68
- if (!existsSync(orgPath()))
61
+ const binding = bindPrivateHaraStateFile(homedir(), [], "org.json");
62
+ const snapshot = readPrivateStateFileSnapshotSync(binding.path, 1024 * 1024);
63
+ if (!snapshot)
69
64
  return false;
70
- rmSync(orgPath());
65
+ removePrivateStateFile(binding.path, snapshot, binding.directory);
71
66
  return true;
72
67
  }
73
68
  /** Parse a control-plane enroll response (tolerant of snake_case / camelCase) into an Enrollment. */
@@ -2,11 +2,11 @@
2
2
  // runtime. The existing loaders pick the contents up (skillsDirs/loadRoles append the resolvers below;
3
3
  // index.ts merges pluginMcpServers into the MCP set). Manifest is Claude-Code-compatible: we read
4
4
  // .claude-plugin/plugin.json, .hara-plugin/plugin.json, or a bare plugin.json at the plugin root.
5
- import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync, cpSync, symlinkSync, chmodSync } from "node:fs";
5
+ import { readFileSync, existsSync, mkdirSync, readdirSync, rmSync, cpSync, symlinkSync, chmodSync } from "node:fs";
6
6
  import { join, resolve, isAbsolute } from "node:path";
7
7
  import { homedir } from "node:os";
8
8
  import { execFileSync } from "node:child_process";
9
- import { readRawConfig } from "../config.js";
9
+ import { readRawConfig, updateRawConfig } from "../config.js";
10
10
  export function pluginsDir() {
11
11
  return join(homedir(), ".hara", "plugins");
12
12
  }
@@ -193,11 +193,9 @@ export function panelsForProject(cwd) {
193
193
  }
194
194
  /** Persist a plugin's enabled flag in ~/.hara/config.json (`plugins.enabled[name]`). */
195
195
  export function setPluginEnabled(name, on) {
196
- const p = join(homedir(), ".hara", "config.json");
197
- const cfg = readRawConfig();
198
- const plugins = (cfg.plugins && typeof cfg.plugins === "object" ? cfg.plugins : {});
199
- plugins.enabled = { ...(plugins.enabled ?? {}), [name]: on };
200
- cfg.plugins = plugins;
201
- mkdirSync(join(homedir(), ".hara"), { recursive: true });
202
- writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n", "utf8");
196
+ updateRawConfig((config) => {
197
+ const plugins = (config.plugins && typeof config.plugins === "object" ? config.plugins : {});
198
+ plugins.enabled = { ...(plugins.enabled ?? {}), [name]: on };
199
+ config.plugins = plugins;
200
+ });
203
201
  }