@nanhara/hara 0.124.2 → 0.124.4

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 CHANGED
@@ -5,6 +5,37 @@ All notable changes to `@nanhara/hara`.
5
5
  > Versioning (pre-1.0, SemVer-style): the **minor** (middle) number bumps for a **new feature**; the
6
6
  > **patch** (last) number bumps for **optimizations/fixes of existing features**.
7
7
 
8
+ ## 0.124.4 — 2026-07-18 — deadline-aware task checkpoints
9
+
10
+ - **The 80% run-budget warning now reaches the Agent, not only the user.** Before the next model turn,
11
+ Hara injects a hidden checkpoint instruction to finish the current atomic step, persist artifacts,
12
+ update the checklist, and defer a new multi-minute stage to `/continue`. A video/image batch, full
13
+ validation, preview, render, install, or deployment should no longer begin with too little turn budget
14
+ and then appear to stop mysteriously at the 30-minute safety pause.
15
+
16
+ ## 0.124.3 — 2026-07-17 — project-aware resume and runtime hardening
17
+
18
+ - **Saved sessions now resume in the project they belong to.** `hara resume <id>` validates the persisted
19
+ project root and relaunches there even when invoked from another directory; session lists show each project
20
+ path. Inside Hara, `/resume <id>` switches saved sessions while `/continue` exclusively resumes the active
21
+ task. Missing project roots, corrupt records, concurrent writers, and raw/headless cross-project resumes
22
+ remain fail-closed.
23
+ - **A total run deadline is now a resumable safety pause.** The 80% warning tells the agent to finish or
24
+ checkpoint its current step, the hard-stop notice explains that the task/checklist remains resumable with
25
+ `/continue`, and deadline outcomes persist as `paused` instead of looking irrecoverably blocked. Loop and
26
+ repeated-failure breakers remain blocked until the approach changes.
27
+ - **Home protection now covers recursive ancestor scopes.** Launching from `/`, `/Users`, a symlink alias, or
28
+ any directory that contains Home cannot implicitly enumerate or mutate private user state. Explicit child
29
+ projects remain usable, and interactive `/cd <project>` safely relaunches Hara in the selected project while
30
+ preserving explicit profile/model/approval/sandbox choices.
31
+ - **Windows portable Home is consistent for direct module consumers.** Explicit Git Bash/MSYS `HOME` wins over
32
+ a different `USERPROFILE` even when callers bypass the normal CLI bootstrap, keeping config, sessions,
33
+ indexes, permissions, cron state, and project boundaries on one private root.
34
+ - **Session input is bounded before parsing and recursive processing.** Resume/list reject oversized,
35
+ excessively deep, structure-heavy, symlinked, or hard-linked session files through a verified no-follow
36
+ snapshot; saves fail atomically with compact/new-session guidance before unsafe allocations.
37
+ - Upgrade with `npm i -g @nanhara/hara@0.124.3`.
38
+
8
39
  ## 0.124.2 — 2026-07-17 — reliable WeCom gateway transport
9
40
 
10
41
  - **The WeCom gateway is ready only after WeCom accepts its credentials.** Hara now waits for the
@@ -149,6 +149,13 @@ function composeSystem(cwd, projectContext, override, memory, continuationSessio
149
149
  }
150
150
  const RUN_STOPPED = Symbol("agent-run-stopped");
151
151
  const REPEATED_FAILURE_LIMIT = 3;
152
+ export function deadlineCheckpointReminder(timeoutMs) {
153
+ return (`Turn budget checkpoint: about 20% remains before the ${formatAgentDuration(timeoutMs)} safety pause. ` +
154
+ "Stop expanding scope. Finish only the current atomic step, persist any usable artifact, update todo_write, " +
155
+ "and reply with the completed checkpoint plus the next exact step. Do not start another generation batch, " +
156
+ "install, full validation suite, preview, render, deployment, or other multi-minute stage in this turn. " +
157
+ "The user can run /continue to start that next stage with a fresh bounded budget.");
158
+ }
152
159
  function showRunNotice(opts, message, critical = false) {
153
160
  if (opts.quiet)
154
161
  return;
@@ -164,11 +171,20 @@ function showRunNotice(opts, message, critical = false) {
164
171
  }
165
172
  }
166
173
  }
174
+ function requestRunCheckpoint(opts, life) {
175
+ if (life.disposed || life.checkpointDue || life.signal.aborted)
176
+ return;
177
+ life.checkpointDue = true;
178
+ const remainingMs = Math.max(0, life.timeoutMs - (Date.now() - life.startedAt));
179
+ showRunNotice(opts, `⚠ agent turn nearing its safety pause: ${formatAgentDuration(remainingMs)} remains. The agent will be told to finish the current atomic step and checkpoint; use \`/continue\` for the next expensive stage.`);
180
+ }
167
181
  function warnRun(opts, life) {
168
182
  if (life.disposed || life.warned || life.signal.aborted)
169
183
  return;
170
184
  life.warned = true;
171
- showRunNotice(opts, `⚠ agent still running: ${formatAgentDuration(Date.now() - life.startedAt)} elapsed, round ${life.rounds}/${life.maxRounds}; hard stop at ${formatAgentDuration(life.timeoutMs)}.`);
185
+ const elapsedMs = Date.now() - life.startedAt;
186
+ const remainingMs = Math.max(0, life.timeoutMs - elapsedMs);
187
+ showRunNotice(opts, `⚠ agent still running: ${formatAgentDuration(elapsedMs)} elapsed, round ${life.rounds}/${life.maxRounds}; ${formatAgentDuration(remainingMs)} remains before this turn pauses. Finish the current step or leave a checklist checkpoint; unfinished session work can resume with \`/continue\`.`);
172
188
  }
173
189
  function createRunLifecycle(opts) {
174
190
  const timeoutMs = agentRunTimeoutMs(opts.timeoutMs);
@@ -189,6 +205,9 @@ function createRunLifecycle(opts) {
189
205
  const warningDelay = Math.min(5 * 60_000, Math.max(250, Math.floor(timeoutMs * 0.8)));
190
206
  const warningTimer = setTimeout(() => warnRun(opts, life), warningDelay);
191
207
  warningTimer.unref?.();
208
+ const checkpointDelay = Math.max(250, Math.floor(timeoutMs * 0.8));
209
+ const checkpointTimer = setTimeout(() => requestRunCheckpoint(opts, life), checkpointDelay);
210
+ checkpointTimer.unref?.();
192
211
  let removeStopListener = () => { };
193
212
  const stopPromise = new Promise((resolveStopped) => {
194
213
  const stopped = () => resolveStopped(RUN_STOPPED);
@@ -203,6 +222,7 @@ function createRunLifecycle(opts) {
203
222
  timeoutController,
204
223
  timeoutTimer,
205
224
  warningTimer,
225
+ checkpointTimer,
206
226
  stopPromise,
207
227
  removeStopListener,
208
228
  startedAt,
@@ -211,6 +231,8 @@ function createRunLifecycle(opts) {
211
231
  rounds: 0,
212
232
  timedOut: false,
213
233
  warned: false,
234
+ checkpointDue: false,
235
+ checkpointInjected: false,
214
236
  limitAnnounced: false,
215
237
  disposed: false,
216
238
  failedCalls: new Map(),
@@ -221,12 +243,13 @@ function disposeRunLifecycle(life) {
221
243
  life.disposed = true;
222
244
  clearTimeout(life.timeoutTimer);
223
245
  clearTimeout(life.warningTimer);
246
+ clearTimeout(life.checkpointTimer);
224
247
  life.removeStopListener();
225
248
  }
226
249
  function hardStop(opts, life, kind, detail) {
227
250
  const elapsedMs = Date.now() - life.startedAt;
228
251
  const message = kind === "deadline"
229
- ? `⛔ agent run stopped: total deadline ${formatAgentDuration(life.timeoutMs)} reached after ${life.rounds} round(s). No further model or tool calls will start. Increase it with \`hara config set runTimeoutMs 45m\` (maximum 2h) only for intentional long work.`
252
+ ? `⏸ agent run paused: total deadline ${formatAgentDuration(life.timeoutMs)} reached after ${life.rounds} round(s). No further model or tool calls will start in this turn. Session-backed work keeps its task and checklist checkpoint; type \`/continue\` to resume in a fresh bounded turn. Only for intentionally long single turns, use \`hara config set runTimeoutMs 45m\` (maximum 2h).`
230
253
  : kind === "max_rounds"
231
254
  ? `⛔ agent run stopped: ${life.maxRounds}-round safety limit reached after ${formatAgentDuration(elapsedMs)}. This usually means the model is looping. Increase it with \`hara config set maxAgentRounds <n>\` (maximum 256) only if the extra rounds are intentional.`
232
255
  : `⛔ agent run stopped: the same failing ${detail?.tool ?? "tool"} call repeated ${detail?.count ?? REPEATED_FAILURE_LIMIT} times. Change the approach or fix the reported cause before retrying.`;
@@ -323,6 +346,15 @@ async function runAgentInner(history, opts, life) {
323
346
  life.rounds += 1;
324
347
  if (life.rounds >= Math.ceil(life.maxRounds * 0.75))
325
348
  warnRun(opts, life);
349
+ if (Date.now() - life.startedAt >= Math.floor(life.timeoutMs * 0.8))
350
+ requestRunCheckpoint(opts, life);
351
+ if (!opts.quiet && life.checkpointDue && !life.checkpointInjected) {
352
+ life.checkpointInjected = true;
353
+ history.push({
354
+ role: "user",
355
+ content: wrapReminders([deadlineCheckpointReminder(life.timeoutMs)]),
356
+ });
357
+ }
326
358
  // Type-ahead steering: fold in anything the user submitted while the previous step ran, so it
327
359
  // reaches the model on this next call (drained after the last tool round; empty on the 1st pass).
328
360
  if (opts.pendingInput && !runSignal.aborted) {
@@ -10,7 +10,7 @@ import { dirname, join } from "node:path";
10
10
  import { homedir } from "node:os";
11
11
  import { createHash } from "node:crypto";
12
12
  import { findProjectRoot } from "./context/agents-md.js";
13
- import { isHomeWorkspace } from "./context/workspace-scope.js";
13
+ import { isUnsafeProjectWorkspace } from "./context/workspace-scope.js";
14
14
  import { toolSubprocessEnv } from "./security/subprocess-env.js";
15
15
  import { sensitiveFileReason } from "./security/sensitive-files.js";
16
16
  import { redactSensitiveText } from "./security/secrets.js";
@@ -107,7 +107,7 @@ function dropSensitiveIndexEntries(root, gitDir) {
107
107
  export function checkpoint(cwd, label) {
108
108
  // A shadow `git add -A` at ~/ would traverse the user's entire personal/control-data scope. Home is
109
109
  // deliberately not an implicit project, including when reached through a symlink alias.
110
- if (isHomeWorkspace(cwd))
110
+ if (isUnsafeProjectWorkspace(cwd))
111
111
  return null;
112
112
  const root = findProjectRoot(cwd);
113
113
  const gitDir = shadowGitDir(root);
@@ -138,7 +138,7 @@ export function checkpoint(cwd, label) {
138
138
  }
139
139
  /** Recent checkpoints, newest first. */
140
140
  export function listCheckpoints(cwd, n = 15) {
141
- if (isHomeWorkspace(cwd))
141
+ if (isUnsafeProjectWorkspace(cwd))
142
142
  return [];
143
143
  const root = findProjectRoot(cwd);
144
144
  const gitDir = shadowGitDir(root);
@@ -163,7 +163,7 @@ export function listCheckpoints(cwd, n = 15) {
163
163
  export function restoreCheckpoint(cwd, ref) {
164
164
  if (!/^[0-9a-f]{4,64}$/i.test(ref))
165
165
  return null; // checkpoint refs are hashes; reject option/ref injection
166
- if (isHomeWorkspace(cwd))
166
+ if (isUnsafeProjectWorkspace(cwd))
167
167
  return null;
168
168
  const root = findProjectRoot(cwd);
169
169
  const gitDir = shadowGitDir(root);
@@ -1,6 +1,6 @@
1
- import { realpathSync } from "node:fs";
2
- import { homedir } from "node:os";
1
+ import { realpathSync, statSync } from "node:fs";
3
2
  import { isAbsolute, relative, resolve, sep } from "node:path";
3
+ import { effectiveHomeDir } from "../runtime.js";
4
4
  /** Resolve existing paths through symlinks before comparing security/workspace scopes. Falling back to
5
5
  * resolve() keeps messages deterministic for a path that disappears during startup; normal cwd/home paths
6
6
  * exist and therefore take the realpath branch. */
@@ -14,21 +14,25 @@ export function canonicalWorkspacePath(path) {
14
14
  }
15
15
  }
16
16
  /** The user's home directory is a control/personal-data scope, not an implicit project workspace. */
17
- export function isHomeWorkspace(cwd, home = homedir()) {
17
+ export function isHomeWorkspace(cwd, home = effectiveHomeDir()) {
18
18
  return canonicalWorkspacePath(cwd) === canonicalWorkspacePath(home);
19
19
  }
20
20
  /** A recursive root is unsafe when it is Home itself OR an ancestor that would descend into Home. This
21
21
  * closes `path: ".."`, filesystem-root, and symlink-alias bypasses while keeping an explicitly selected
22
22
  * project child under Home usable. */
23
- export function recursiveRootContainsHome(root, home = homedir()) {
23
+ export function recursiveRootContainsHome(root, home = effectiveHomeDir()) {
24
24
  const canonicalRoot = canonicalWorkspacePath(root);
25
25
  const canonicalHome = canonicalWorkspacePath(home);
26
26
  const rel = relative(canonicalRoot, canonicalHome);
27
27
  return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
28
28
  }
29
+ /** Project-scoped execution from Home OR one of its ancestors can implicitly reach private user state. */
30
+ export function isUnsafeProjectWorkspace(cwd, home = effectiveHomeDir()) {
31
+ return recursiveRootContainsHome(cwd, home);
32
+ }
29
33
  export function homeWorkspaceActionError(action) {
30
- return (`Refusing to ${action} from the home directory: it is not an implicit project workspace. ` +
31
- "Run `cd /path/to/project` first, or launch with `hara --cwd /path/to/project`.");
34
+ return (`Refusing to ${action} from a workspace that is the home directory or contains it: it is not an implicit project workspace. ` +
35
+ "Use `/cd /path/to/project`, run `cd /path/to/project` first, or launch with `hara --cwd /path/to/project`.");
32
36
  }
33
37
  export function recursiveHomeSearchError(tool) {
34
38
  return (`Error: ${tool} will not recursively scan the home directory. ` +
@@ -43,11 +47,44 @@ export function homeWorkspaceDirectoryScanError(tool) {
43
47
  /** Injected into model context when Hara was intentionally launched at ~/. Runtime checks enforce the same
44
48
  * policy, but guidance avoids wasting turns on calls that are guaranteed to be rejected. */
45
49
  export function homeWorkspaceGuidance(cwd) {
46
- if (!isHomeWorkspace(cwd))
50
+ if (!isUnsafeProjectWorkspace(cwd))
47
51
  return "";
48
52
  return ("# Home-directory workspace boundary\n" +
49
- "The working directory resolves to the user's home directory, which is not an implicit project. " +
53
+ "The working directory resolves to the user's home directory or an ancestor that contains it, which is not an implicit project. " +
50
54
  "Do not initialize a project, create or modify files, build a repository index, run shell/external " +
51
55
  "executable tools, enumerate directories, or grep/glob/search Home or one of its child directories. " +
52
- "Ask the user to run `cd /path/to/project` or `hara --cwd /path/to/project` for project work. Only explicitly named single-file reads remain available.");
56
+ "Ask the user to switch with `/cd /path/to/project`, run `cd /path/to/project`, or launch with " +
57
+ "`hara --cwd /path/to/project` for project work. Only explicitly named single-file reads remain available.");
58
+ }
59
+ /** Resolve an explicit interactive workspace handoff without accepting Home/ancestor scopes. */
60
+ export function resolveWorkspaceSwitch(input, currentCwd, home = effectiveHomeDir()) {
61
+ let requested = input.trim();
62
+ if (!requested)
63
+ return { ok: false, error: "usage: /cd <project-directory>" };
64
+ if (requested.length >= 2
65
+ && ((requested.startsWith('"') && requested.endsWith('"')) || (requested.startsWith("'") && requested.endsWith("'"))))
66
+ requested = requested.slice(1, -1).trim();
67
+ if (!requested)
68
+ return { ok: false, error: "usage: /cd <project-directory>" };
69
+ if (requested === "~")
70
+ requested = home;
71
+ else if (/^~[\\/]/u.test(requested)) {
72
+ const parts = requested.slice(2).split(/[\\/]+/u).filter(Boolean);
73
+ requested = resolve(home, ...parts);
74
+ }
75
+ try {
76
+ const target = realpathSync.native(resolve(currentCwd, requested));
77
+ if (!statSync(target).isDirectory())
78
+ return { ok: false, error: `not a directory: ${requested}` };
79
+ if (isUnsafeProjectWorkspace(target, home)) {
80
+ return { ok: false, error: homeWorkspaceActionError("switch project scope") };
81
+ }
82
+ return { ok: true, cwd: target };
83
+ }
84
+ catch (error) {
85
+ return {
86
+ ok: false,
87
+ error: `cannot switch to '${requested}': ${error instanceof Error ? error.message : String(error)}`,
88
+ };
89
+ }
53
90
  }
package/dist/index.js CHANGED
@@ -70,13 +70,14 @@ function renderBgJobs() {
70
70
  }
71
71
  import { qwenDeviceLogin, getValidQwenAuth, loadQwenToken } from "./providers/qwen-oauth.js";
72
72
  import { loadAgentContext, hasAgentsMd, INIT_PROMPT, findProjectRoot } from "./context/agents-md.js";
73
- import { homeWorkspaceActionError, isHomeWorkspace } from "./context/workspace-scope.js";
73
+ import { homeWorkspaceActionError, isUnsafeProjectWorkspace, resolveWorkspaceSwitch, } from "./context/workspace-scope.js";
74
74
  import { getEmbedder } from "./search/embed.js";
75
75
  import { collectRepoChunksAsync, collectDirChunksAsync, buildIndex, indexPath, indexExists } from "./search/semindex.js";
76
76
  import { searchHybrid } from "./search/hybrid.js";
77
77
  import { expandMentionsAsync, fileCandidates, isSlashCommand, inlineLeadingPath } from "./context/mentions.js";
78
78
  import { newSessionId, shortId, resolveSessionId, validSessionId, sessionFileExists, saveSession, loadSession, acquireSessionLock, releaseSessionLock, listSessions, latestForCwd, titleFrom, sessionSourceFromEnv, automatedTitle, slugify, } from "./session/store.js";
79
79
  import { consumePendingTaskSteering, continueTaskExecution, createTaskExecution, finishTaskExecution, formatTaskExecution, hasPendingTaskSteering, newSteerInteraction, newTurnInteraction, recordTaskSteering, recoverTaskExecution, requestsTaskContinuation, taskExecutionContext, } from "./session/task.js";
80
+ import { displaySessionCwd, resolveSessionResumeTarget } from "./session/resume.js";
80
81
  import { setSessionForceModel, isSessionForceModel, effectiveRoleModel } from "./session/session-model.js";
81
82
  import { loadGlobalRoles, loadRoles, roleToolFilter, scaffoldRoles, subagentToolFilter } from "./org/roles.js";
82
83
  import { buildAgentsIndex, canonicalProjectPath, resolveAgent, loadProjects, addProject, removeProject } from "./org/projects.js";
@@ -392,7 +393,7 @@ function agentRunLimits(cfg) {
392
393
  return { timeoutMs: cfg.runTimeoutMs, maxRounds: cfg.maxAgentRounds };
393
394
  }
394
395
  async function runInit(provider, cwd, sandbox = "off", cfg) {
395
- if (isHomeWorkspace(cwd))
396
+ if (isUnsafeProjectWorkspace(cwd))
396
397
  throw new Error(homeWorkspaceActionError("initialize AGENTS.md"));
397
398
  const history = [{ role: "user", content: INIT_PROMPT }];
398
399
  await runAgent(history, { provider, ctx: { cwd, sandbox }, approval: "full-auto", confirm: async () => true, ...(cfg ? agentRunLimits(cfg) : {}) });
@@ -1139,7 +1140,7 @@ program
1139
1140
  .description("analyze the project and (re)generate AGENTS.md")
1140
1141
  .action(async () => {
1141
1142
  const cfg = loadConfig();
1142
- if (isHomeWorkspace(cfg.cwd)) {
1143
+ if (isUnsafeProjectWorkspace(cfg.cwd)) {
1143
1144
  out(c.red(homeWorkspaceActionError("initialize AGENTS.md")) + "\n");
1144
1145
  process.exitCode = 2;
1145
1146
  return;
@@ -1163,34 +1164,40 @@ program
1163
1164
  }
1164
1165
  for (const m of metas) {
1165
1166
  out(`${c.bold(shortId(m.id))} ${c.dim(m.updatedAt.slice(0, 16).replace("T", " "))} ${c.dim(m.provider + ":" + m.model)} ${m.title || c.dim("(untitled)")}\n`);
1167
+ out(` ${c.dim(displaySessionCwd(m.cwd))}\n`);
1166
1168
  }
1167
1169
  out(c.dim("\nResume: hara resume <id>\n"));
1168
1170
  });
1169
1171
  program
1170
1172
  .command("resume [id]")
1171
- .description("resume a session — no id resumes the most recent here (list ids with `hara sessions`)")
1173
+ .description("resume a session in its saved project — no id resumes the most recent here")
1172
1174
  .action(async (id) => {
1173
- let full;
1174
- if (id) {
1175
- full = resolveSessionId(id) ?? undefined;
1176
- if (!full) {
1177
- out(c.red(`No session matching '${id}'.`) + c.dim(" Run `hara sessions` to list.\n"));
1178
- process.exit(1);
1175
+ const target = resolveSessionResumeTarget(id, process.cwd());
1176
+ if (!target.ok) {
1177
+ if (target.reason === "not-found") {
1178
+ out(c.red(`No session matching '${id ?? ""}'.`) + c.dim(" Run `hara sessions` to list.\n"));
1179
+ process.exitCode = 1;
1179
1180
  }
1180
- }
1181
- else {
1182
- const latest = latestForCwd(process.cwd());
1183
- if (!latest) {
1184
- out(c.dim("No sessions for this directory yet `hara sessions` lists all.\n"));
1185
- process.exit(0);
1181
+ else if (target.reason === "no-current") {
1182
+ out(c.dim("No sessions for this directory yet — `hara sessions` lists all projects.\n"));
1183
+ }
1184
+ else if (target.reason === "unreadable") {
1185
+ out(c.red(`Session ${shortId(target.id ?? id ?? "")} exists but is unreadable or corrupt; refusing to resume it.\n`));
1186
+ process.exitCode = 2;
1186
1187
  }
1187
- full = latest.meta.id;
1188
+ else {
1189
+ out(c.red(`Session ${shortId(target.id ?? id ?? "")} belongs to ${target.cwd}, but that project directory is unavailable.\n`));
1190
+ process.exitCode = 2;
1191
+ }
1192
+ return;
1188
1193
  }
1189
- out(c.dim(`↩ resuming ${shortId(full)}…\n`));
1194
+ out(c.dim(`↩ resuming ${shortId(target.id)} in ${displaySessionCwd(target.cwd)}…\n`));
1190
1195
  // Reuse the existing --resume path exactly (one engine), inheriting this terminal. selfArgv() inside
1191
1196
  // runSelfAttached distinguishes node+entry from a Bun-compiled binary (whose argv[1] is a user arg).
1197
+ // Launch from the persisted project root: the low-level --resume engine intentionally refuses a
1198
+ // foreign cwd so a transcript can never be reinterpreted against the wrong repository.
1192
1199
  try {
1193
- const result = await runSelfAttached(["--resume", full]);
1200
+ const result = await runSelfAttached(["--resume", target.id], target.cwd);
1194
1201
  if (result.signal) {
1195
1202
  out(c.yellow(`Resumed Hara process stopped by ${result.signal}.\n`));
1196
1203
  process.exitCode = 1;
@@ -1374,7 +1381,7 @@ program
1374
1381
  const cwd = process.cwd();
1375
1382
  let doRepo = !!(opts.all || opts.repo || (!opts.assets && !opts.all));
1376
1383
  const doAssets = !!(opts.all || opts.assets);
1377
- if (doRepo && isHomeWorkspace(cwd)) {
1384
+ if (doRepo && isUnsafeProjectWorkspace(cwd)) {
1378
1385
  if (!doAssets) {
1379
1386
  out(c.red(homeWorkspaceActionError("build a repository index")) + "\n");
1380
1387
  process.exitCode = 2;
@@ -2782,7 +2789,7 @@ program.action(async (opts) => {
2782
2789
  }
2783
2790
  const cfg = loadConfig({ overlay: opts.overlay, ...(requestedHeadlessAgent?.home ? { cwd: requestedHeadlessAgent.home } : {}) });
2784
2791
  const cwd = cfg.cwd;
2785
- const homeWorkspace = isHomeWorkspace(cwd);
2792
+ const homeWorkspace = isUnsafeProjectWorkspace(cwd);
2786
2793
  // Resolve the concrete role before constructing any user/plugin MCP transport. MCP servers are arbitrary
2787
2794
  // stdio subprocesses, so a read-only persona must not start them merely by launching a turn. Reusing this
2788
2795
  // object later also closes the resolve→connect→re-resolve race where a role could disappear or change policy.
@@ -3248,7 +3255,7 @@ program.action(async (opts) => {
3248
3255
  const useTui = stdin.isTTY && stdout.isTTY && process.env.HARA_TUI !== "0";
3249
3256
  out(c.bold(`hara ${pkg.version}`) + c.dim(` · ${cfg.provider}:${cfg.model} · ${approval}${sandbox !== "off" ? ` · sandbox:${sandbox}` : ""} · ${cwd}\n`));
3250
3257
  if (homeWorkspace) {
3251
- 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"));
3258
+ out(c.yellow("⚠ Home or a directory containing it is not treated as a project workspace. Switch with `/cd /path/to/project`, or launch with `hara --cwd /path/to/project`; project-scoped execution is disabled here.\n"));
3252
3259
  }
3253
3260
  // Startup update notice — cache-driven (a previous session's background probe), so it costs zero
3254
3261
  // latency; today's probe (if due) fires in the background for the NEXT launch. TTY sessions only.
@@ -3417,6 +3424,51 @@ program.action(async (opts) => {
3417
3424
  }
3418
3425
  const history = resumed?.history ? [...resumed.history] : [];
3419
3426
  const persistSession = () => saveSession(meta, history, task);
3427
+ let requestedWorkspaceSwitch = null;
3428
+ let requestedSessionSwitch = null;
3429
+ const relaunchRequestedTarget = async () => {
3430
+ if (!requestedWorkspaceSwitch && !requestedSessionSwitch)
3431
+ return;
3432
+ const sessionSwitch = requestedSessionSwitch;
3433
+ const target = sessionSwitch?.cwd ?? requestedWorkspaceSwitch;
3434
+ requestedWorkspaceSwitch = null;
3435
+ requestedSessionSwitch = null;
3436
+ // The foreground child starts a new session. Do not keep the old session artificially locked for the
3437
+ // entire lifetime of the new workspace merely because this small parent process is waiting on it.
3438
+ releaseSessionLock(sessionId);
3439
+ const args = [];
3440
+ if (opts.profile)
3441
+ args.push("--profile", String(opts.profile));
3442
+ if (opts.overlay)
3443
+ args.push("--overlay", String(opts.overlay));
3444
+ if (opts.model)
3445
+ args.push("--model", String(opts.model));
3446
+ if (opts.yes)
3447
+ args.push("--yes");
3448
+ else if (opts.approval)
3449
+ args.push("--approval", String(opts.approval));
3450
+ if (opts.sandbox)
3451
+ args.push("--sandbox", String(opts.sandbox));
3452
+ if (sessionSwitch)
3453
+ args.push("--resume", sessionSwitch.id);
3454
+ out(c.dim(sessionSwitch
3455
+ ? `Resuming session ${shortId(sessionSwitch.id)} → ${displaySessionCwd(target)}\n`
3456
+ : `Switching workspace → ${target}\n`));
3457
+ try {
3458
+ const result = await runSelfAttached(args, target);
3459
+ if (result.signal) {
3460
+ out(c.yellow(`Hara in ${target} stopped by ${result.signal}.\n`));
3461
+ process.exitCode = 1;
3462
+ }
3463
+ else if (result.code) {
3464
+ process.exitCode = result.code;
3465
+ }
3466
+ }
3467
+ catch (error) {
3468
+ out(c.red(`Could not start Hara in ${target}: ${error instanceof Error ? error.message : String(error)}\n`));
3469
+ process.exitCode = 1;
3470
+ }
3471
+ };
3420
3472
  const keepUnfinishedTaskActive = () => {
3421
3473
  resumeTaskPending = Boolean(task && task.status !== "completed");
3422
3474
  };
@@ -3460,14 +3512,51 @@ program.action(async (opts) => {
3460
3512
  };
3461
3513
  const commands = [
3462
3514
  { name: "help", desc: "show this help", run: () => void out(helpText(commands)) },
3515
+ {
3516
+ name: "cd",
3517
+ desc: "switch to a project workspace: /cd <directory>",
3518
+ run: (a) => {
3519
+ const result = resolveWorkspaceSwitch(a, cwd);
3520
+ if (!result.ok)
3521
+ return void out(c.red(`(${result.error})\n`));
3522
+ if (result.cwd === realpathSync.native(cwd))
3523
+ return void out(c.dim(`(already in ${result.cwd})\n`));
3524
+ requestedWorkspaceSwitch = result.cwd;
3525
+ return "exit";
3526
+ },
3527
+ },
3463
3528
  {
3464
3529
  name: "continue",
3465
- aliases: ["resume"],
3466
3530
  desc: "resume the unfinished task: /continue [instruction]",
3467
3531
  // Interactive loops translate this command into an ordinary model turn before slash dispatch. This
3468
3532
  // fallback only protects future callers that invoke the command table directly.
3469
3533
  run: () => void out(c.dim("(type /continue [instruction] in an interactive session)\n")),
3470
3534
  },
3535
+ {
3536
+ name: "resume",
3537
+ desc: "switch to a saved session: /resume <id>",
3538
+ run: (a) => {
3539
+ if (!a.trim()) {
3540
+ const ms = listSessions().slice(0, 12);
3541
+ if (!ms.length)
3542
+ return void out(c.dim("No sessions yet.\n"));
3543
+ for (const m of ms) {
3544
+ out(` ${shortId(m.id)} ${c.dim(displaySessionCwd(m.cwd))} ${m.title || "(untitled)"}\n`);
3545
+ }
3546
+ return void out(c.dim("Use /resume <id> to switch sessions.\n"));
3547
+ }
3548
+ const target = resolveSessionResumeTarget(a.trim(), cwd);
3549
+ if (!target.ok) {
3550
+ if (target.reason === "cwd-unavailable")
3551
+ return void out(c.red(`(saved project unavailable: ${target.cwd})\n`));
3552
+ return void out(c.red(`(cannot resume '${a.trim()}': ${target.reason})\n`));
3553
+ }
3554
+ if (target.id === sessionId)
3555
+ return void out(c.dim("(this session is already open)\n"));
3556
+ requestedSessionSwitch = { id: target.id, cwd: target.cwd };
3557
+ return "exit";
3558
+ },
3559
+ },
3471
3560
  {
3472
3561
  name: "init",
3473
3562
  desc: "analyze project & regenerate AGENTS.md",
@@ -3637,7 +3726,7 @@ program.action(async (opts) => {
3637
3726
  if (!ms.length)
3638
3727
  return void out(c.dim("No sessions yet.\n"));
3639
3728
  for (const m of ms)
3640
- out(` ${shortId(m.id)} ${c.dim(m.updatedAt.slice(0, 16).replace("T", " "))} ${m.title || "(untitled)"}\n`);
3729
+ out(` ${shortId(m.id)} ${c.dim(m.updatedAt.slice(0, 16).replace("T", " "))} ${c.dim(displaySessionCwd(m.cwd))} ${m.title || "(untitled)"}\n`);
3641
3730
  },
3642
3731
  },
3643
3732
  {
@@ -3994,7 +4083,7 @@ program.action(async (opts) => {
3994
4083
  // never saw update notices and versions silently went stale (field report: stuck on 0.112.5)
3995
4084
  updateNotice: cfg.updateCheck ? (checkForUpdate(pkg.version) ?? undefined) : undefined,
3996
4085
  workspaceNotice: homeWorkspace
3997
- ? "Home is not a project workspace · cd there or use hara --cwd /path/to/project"
4086
+ ? "Home is not a project workspace · use /cd /path/to/project to switch"
3998
4087
  : undefined,
3999
4088
  },
4000
4089
  visionNotice: __visionNotice,
@@ -4004,7 +4093,7 @@ program.action(async (opts) => {
4004
4093
  onSubmit: async (line, h, images, interaction) => {
4005
4094
  // `/continue` is a task interaction, not a control-only command: turn it into a model message before
4006
4095
  // 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());
4096
+ const continueCommand = /^\/continue(?:\s+([\s\S]+))?$/i.exec(line.trim());
4008
4097
  if (continueCommand) {
4009
4098
  if (!task || task.status === "completed")
4010
4099
  return void h.sink.notice("(there is no unfinished task to continue)");
@@ -4020,6 +4109,35 @@ program.action(async (opts) => {
4020
4109
  if (isSlashCommand(line)) {
4021
4110
  const [nm, ...rest] = line.slice(1).split(/\s+/);
4022
4111
  const arg = rest.join(" ").trim();
4112
+ if (nm === "cd") {
4113
+ const result = resolveWorkspaceSwitch(arg, cwd);
4114
+ if (!result.ok)
4115
+ return void h.sink.notice(`(${result.error})`);
4116
+ if (result.cwd === realpathSync.native(cwd))
4117
+ return void h.sink.notice(`(already in ${result.cwd})`);
4118
+ requestedWorkspaceSwitch = result.cwd;
4119
+ h.sink.notice(`(switching workspace → ${result.cwd})`);
4120
+ return void h.exit();
4121
+ }
4122
+ if (nm === "resume") {
4123
+ if (!arg) {
4124
+ const ms = listSessions().slice(0, 12);
4125
+ return void h.sink.notice(ms.length
4126
+ ? "Saved sessions — /resume <id>:\n" + ms.map((m) => ` ${shortId(m.id)} ${displaySessionCwd(m.cwd)} ${m.title || "(untitled)"}`).join("\n")
4127
+ : "No sessions yet.");
4128
+ }
4129
+ const target = resolveSessionResumeTarget(arg, cwd);
4130
+ if (!target.ok) {
4131
+ if (target.reason === "cwd-unavailable")
4132
+ return void h.sink.notice(`(saved project unavailable: ${target.cwd})`);
4133
+ return void h.sink.notice(`(cannot resume '${arg}': ${target.reason})`);
4134
+ }
4135
+ if (target.id === sessionId)
4136
+ return void h.sink.notice("(this session is already open)");
4137
+ requestedSessionSwitch = { id: target.id, cwd: target.cwd };
4138
+ h.sink.notice(`(resuming ${shortId(target.id)} → ${displaySessionCwd(target.cwd)})`);
4139
+ return void h.exit();
4140
+ }
4023
4141
  if (nm === "exit" || nm === "quit") {
4024
4142
  if (shouldAutoEvolve(cfg.evolve, history.length)) {
4025
4143
  h.sink.notice("✻ distilling session learnings…");
@@ -4222,7 +4340,7 @@ program.action(async (opts) => {
4222
4340
  }
4223
4341
  if (nm === "sessions") {
4224
4342
  const ms = listSessions();
4225
- return void h.sink.notice(ms.length ? ms.slice(0, 12).map((m) => ` ${shortId(m.id)} ${m.updatedAt.slice(0, 16).replace("T", " ")} ${m.title || "(untitled)"}`).join("\n") : "No sessions yet.");
4343
+ return void h.sink.notice(ms.length ? ms.slice(0, 12).map((m) => ` ${shortId(m.id)} ${m.updatedAt.slice(0, 16).replace("T", " ")} ${displaySessionCwd(m.cwd)} ${m.title || "(untitled)"}`).join("\n") : "No sessions yet.");
4226
4344
  }
4227
4345
  if (nm === "usage")
4228
4346
  return void h.sink.notice(`tokens — ↑${stats.input} ↓${stats.output}`);
@@ -4598,7 +4716,8 @@ program.action(async (opts) => {
4598
4716
  if (loadSession(meta.id))
4599
4717
  out("\n" + c.dim("Session ") + c.bold(shortId(meta.id)) + c.dim(" saved · resume: ") + c.cyan(`hara resume ${shortId(meta.id)}`) + "\n");
4600
4718
  await closeMcp();
4601
- process.exit(0); // TUI done — exit cleanly (ink can leave stdin referenced)
4719
+ await relaunchRequestedTarget();
4720
+ process.exit(process.exitCode ?? 0); // TUI done — exit cleanly (ink can leave stdin referenced)
4602
4721
  }
4603
4722
  out(c.dim(`Type a task. /help · @path attaches a file · shift+tab cycles mode · Esc interrupts · /exit to quit.${hasAgentsMd(cwd) ? " (AGENTS.md loaded)" : ""}\n\n`));
4604
4723
  bar.install({ sessionName: meta.title || shortId(meta.id), model: cfg.model, approval, input: stats.input, output: stats.output, profileId: __activeP.id, profileKind: __activeP.kind });
@@ -4623,7 +4742,7 @@ program.action(async (opts) => {
4623
4742
  if (!line)
4624
4743
  continue;
4625
4744
  let forcedContinuation;
4626
- const continueCommand = /^\/(?:continue|resume)(?:\s+([\s\S]+))?$/i.exec(line);
4745
+ const continueCommand = /^\/continue(?:\s+([\s\S]+))?$/i.exec(line);
4627
4746
  if (continueCommand) {
4628
4747
  if (!task || task.status === "completed") {
4629
4748
  out(c.dim("(there is no unfinished task to continue)\n"));
@@ -4766,9 +4885,11 @@ program.action(async (opts) => {
4766
4885
  }
4767
4886
  }
4768
4887
  bar.uninstall();
4769
- out("\n" + c.dim("Session ") + c.bold(shortId(meta.id)) + c.dim(" saved · resume: ") + c.cyan(`hara resume ${shortId(meta.id)}`) + "\n");
4888
+ if (loadSession(meta.id))
4889
+ out("\n" + c.dim("Session ") + c.bold(shortId(meta.id)) + c.dim(" saved · resume: ") + c.cyan(`hara resume ${shortId(meta.id)}`) + "\n");
4770
4890
  rl.close();
4771
4891
  await closeMcp();
4892
+ await relaunchRequestedTarget();
4772
4893
  });
4773
4894
  program.parseAsync().catch((e) => {
4774
4895
  try {
package/dist/runtime.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { homedir } from "node:os";
1
2
  export const MIN_NODE_MAJOR = 22;
2
3
  // Commander 15 (a direct runtime dependency) requires this exact floor. Keep package engines, startup,
3
4
  // doctor, and docs aligned so an early Node 22 release gets an upgrade hint before dependencies load.
@@ -26,6 +27,18 @@ export function normalizePortableWindowsHome(value) {
26
27
  return home[0].toUpperCase() + home.slice(1).replace(/\//g, "\\");
27
28
  return home;
28
29
  }
30
+ /**
31
+ * Resolve the same user home even when Hara modules are embedded directly instead of entered through
32
+ * `cli.ts`/`runtime-bootstrap.cjs`. Native Windows Node ignores HOME in `os.homedir()`, while Git Bash and
33
+ * portable launchers intentionally use it. Keeping this resolver side-effect free lets security/workspace
34
+ * checks honor that explicit boundary without requiring callers to mutate their environment first.
35
+ */
36
+ export function effectiveHomeDir(env = process.env, runtimePlatform = process.platform, systemHome = homedir()) {
37
+ const explicit = String(env.HOME ?? "").trim();
38
+ if (!explicit)
39
+ return systemHome;
40
+ return runtimePlatform === "win32" ? normalizePortableWindowsHome(explicit) : explicit;
41
+ }
29
42
  function supportedNodeVersion(version) {
30
43
  const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
31
44
  if (!match)
package/dist/sandbox.js CHANGED
@@ -13,7 +13,7 @@ import { platform } from "node:os";
13
13
  import { win32 as winPath } from "node:path";
14
14
  import { existingSensitiveSeatbeltMasks, sensitiveFilesAllowed, sensitiveShellCommandReason, } from "./security/sensitive-files.js";
15
15
  import { terminateSubprocessTree, toolSubprocessEnv } from "./security/subprocess-env.js";
16
- import { homeWorkspaceActionError, isHomeWorkspace } from "./context/workspace-scope.js";
16
+ import { homeWorkspaceActionError, isUnsafeProjectWorkspace } from "./context/workspace-scope.js";
17
17
  // Windows shell resolution. hara (and the model) speak POSIX shell — the agent writes `ls`, `grep`,
18
18
  // `cat`, pipes, `&&`. So on Windows we PREFER a real bash (Git Bash or WSL, which most Windows devs
19
19
  // have) and only fall back to cmd.exe when none is found. Memoized: the PATH probe runs at most once.
@@ -126,7 +126,7 @@ let warnedUnsandboxed = false; // emit the "macOS-only" notice at most once per
126
126
  export function shellCommand(command, cwd, mode) {
127
127
  // Shell syntax can recursively traverse arbitrary paths, so unlike explicit read/write tools it is not
128
128
  // safe at the Home root. Check before protected-file mask discovery, which itself would have to walk ~/.
129
- if (isHomeWorkspace(cwd))
129
+ if (isUnsafeProjectWorkspace(cwd))
130
130
  throw new Error(homeWorkspaceActionError("run shell commands"));
131
131
  const protectedReason = sensitiveShellCommandReason(command, cwd);
132
132
  if (protectedReason) {
@@ -11,7 +11,7 @@ import { listProjectFiles, listProjectFilesAsync, walkFiles, walkFilesAsync, } f
11
11
  import { zvecBuild, zvecQueryIds, zvecRemove } from "./zvec-store.js";
12
12
  import { isSensitiveFilePath } from "../security/sensitive-files.js";
13
13
  import { readModelContextFileSync } from "../fs-read.js";
14
- import { homeWorkspaceActionError, isHomeWorkspace } from "../context/workspace-scope.js";
14
+ import { homeWorkspaceActionError, isUnsafeProjectWorkspace } from "../context/workspace-scope.js";
15
15
  import { ensurePrivateStateSubdirectory, readPrivateStateFileSnapshot, removePrivateStateFile, } from "../security/private-state.js";
16
16
  import { atomicWriteText, bindHaraPrivateStateWritePath, } from "../fs-write.js";
17
17
  // Same code/text extensions codebase_search ranks lexically — keep the two walks in sync.
@@ -52,7 +52,7 @@ async function ensurePrivateIndexState(name, cwd) {
52
52
  }
53
53
  function existingPrivateIndexPath(name, cwd) {
54
54
  try {
55
- if (name === "repo" && isHomeWorkspace(cwd))
55
+ if (name === "repo" && isUnsafeProjectWorkspace(cwd))
56
56
  return null;
57
57
  const requested = indexPath(name, cwd);
58
58
  const base = realpathSync.native(indexBase(name, cwd));
@@ -132,7 +132,7 @@ async function rotateLegacyIndex(name, cwd, state) {
132
132
  export async function buildIndex(name, chunks, embed, cwd, model = "embed", signal) {
133
133
  if (signal?.aborted)
134
134
  throw new Error("semantic index build interrupted");
135
- if (name === "repo" && isHomeWorkspace(cwd))
135
+ if (name === "repo" && isUnsafeProjectWorkspace(cwd))
136
136
  throw new Error(homeWorkspaceActionError("build a repository index"));
137
137
  const state = await ensurePrivateIndexState(name, cwd);
138
138
  const p = state.path;
@@ -254,7 +254,7 @@ export function collectDirChunks(dir, source) {
254
254
  }
255
255
  /** Walk the repo (respecting .gitignore) and chunk every code/text file — the corpus `hara index` embeds. */
256
256
  export function collectRepoChunks(root) {
257
- if (isHomeWorkspace(root))
257
+ if (isUnsafeProjectWorkspace(root))
258
258
  throw new Error(homeWorkspaceActionError("scan the home directory for a repository index"));
259
259
  const chunks = [];
260
260
  for (const rel of listProjectFiles(root)) {
@@ -315,7 +315,7 @@ export async function collectDirChunksAsync(dir, source, options = {}) {
315
315
  }
316
316
  /** Interruptible repository collection. Git discovery and file reads share one total wall budget. */
317
317
  export async function collectRepoChunksAsync(root, options = {}) {
318
- if (isHomeWorkspace(root))
318
+ if (isUnsafeProjectWorkspace(root))
319
319
  throw new Error(homeWorkspaceActionError("scan the home directory for a repository index"));
320
320
  const timeoutMs = Number.isFinite(options.timeoutMs) ? Math.max(0, Math.floor(options.timeoutMs)) : 30_000;
321
321
  const startedAt = Date.now();
@@ -15,7 +15,7 @@ import { closeSync, existsSync, fstatSync, fsyncSync, linkSync, lstatSync, mkdir
15
15
  import { psArgumentsExposeEnvironment } from "./sensitive-files.js";
16
16
  import { projectRepositoryTrustedAtStartup } from "./project-trust.js";
17
17
  import { readVerifiedRegularFileSnapshotSync } from "../fs-read.js";
18
- import { homeWorkspaceActionError, isHomeWorkspace } from "../context/workspace-scope.js";
18
+ import { homeWorkspaceActionError, isHomeWorkspace, isUnsafeProjectWorkspace } from "../context/workspace-scope.js";
19
19
  import { sameOpenedFileIdentity } from "../fs-identity.js";
20
20
  const PROJECT_ROOT_MARKERS = [".git", "package.json", "Cargo.toml", "go.mod", "pyproject.toml", ".hg"];
21
21
  const MAX_PROJECT_PERMISSIONS_BYTES = 64 * 1024;
@@ -436,7 +436,7 @@ function scaffoldProjectPermissions(cwd) {
436
436
  const project = realpathSync.native(resolve(cwd));
437
437
  // Project scaffolding at ~/ would target the same ~/.hara/permissions.json used by global policy and
438
438
  // silently promote repository starter rules to every Hara session. Reject before creating `.hara`.
439
- if (isHomeWorkspace(project))
439
+ if (isUnsafeProjectWorkspace(project))
440
440
  throw new Error(homeWorkspaceActionError("create project permissions"));
441
441
  const projectIdentity = verifiedDirectory(project);
442
442
  const parent = join(project, ".hara");
@@ -0,0 +1,52 @@
1
+ import { homedir } from "node:os";
2
+ import { isAbsolute, relative, sep } from "node:path";
3
+ import { canonicalProjectPath } from "../org/projects.js";
4
+ import { latestForCwd, loadSession, resolveSessionId, sessionFileExists, } from "./store.js";
5
+ /**
6
+ * Resolve the execution root before relaunching an interactive session.
7
+ *
8
+ * The low-level `--resume` path still rejects a foreign cwd. This resolver is for the explicit,
9
+ * user-facing `hara resume <id>` transition: it binds the attached child to the session's persisted
10
+ * project root so invoking the command from another directory cannot reinterpret the transcript there.
11
+ */
12
+ export function resolveSessionResumeTarget(idOrPrefix, currentCwd) {
13
+ let id;
14
+ if (idOrPrefix) {
15
+ id = resolveSessionId(idOrPrefix) ?? undefined;
16
+ if (!id)
17
+ return { ok: false, reason: "not-found" };
18
+ }
19
+ else {
20
+ const latest = latestForCwd(currentCwd);
21
+ if (!latest)
22
+ return { ok: false, reason: "no-current" };
23
+ id = latest.meta.id;
24
+ }
25
+ const session = loadSession(id);
26
+ if (!session) {
27
+ return {
28
+ ok: false,
29
+ reason: sessionFileExists(id) ? "unreadable" : "not-found",
30
+ id,
31
+ };
32
+ }
33
+ const cwd = canonicalProjectPath(session.meta.cwd, true);
34
+ if (!cwd) {
35
+ return {
36
+ ok: false,
37
+ reason: "cwd-unavailable",
38
+ id,
39
+ cwd: session.meta.cwd,
40
+ };
41
+ }
42
+ return { ok: true, id, cwd, meta: session.meta };
43
+ }
44
+ /** Compact a persisted absolute project path for session lists without losing its identity. */
45
+ export function displaySessionCwd(cwd, home = homedir()) {
46
+ const rel = relative(home, cwd);
47
+ if (rel === "")
48
+ return "~";
49
+ if (rel && !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel))
50
+ return `~${sep}${rel}`;
51
+ return cwd;
52
+ }
@@ -4,7 +4,15 @@ import { join } from "node:path";
4
4
  import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, } from "node:fs";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import { redactSensitiveValue } from "../security/secrets.js";
7
+ import { readVerifiedRegularFileSnapshotSync } from "../fs-read.js";
7
8
  import { isTaskExecution } from "./task.js";
9
+ /** Durable transcripts are local input on resume. Bound both allocation and post-parse traversal so a
10
+ * corrupt/hostile session cannot turn startup or `hara sessions` into an unbounded resource operation. */
11
+ export const MAX_SESSION_FILE_BYTES = 64 * 1024 * 1024;
12
+ export const MAX_SESSION_JSON_DEPTH = 64;
13
+ export const MAX_SESSION_JSON_NODES = 250_000;
14
+ export const MAX_SESSION_ARRAY_ITEMS = 50_000;
15
+ export const MAX_SESSION_STRING_CHARS = 8 * 1024 * 1024;
8
16
  /** Derive the session source from the spawn environment — the gateway subprocess runs with
9
17
  * HARA_GATEWAY=<platform>, the cron runner with HARA_CRON=1 (+ HARA_CRON_NAME=<job name>). */
10
18
  export function sessionSourceFromEnv() {
@@ -382,13 +390,21 @@ function redactedSessionCopy(data) {
382
390
  export function saveSession(meta, history, task) {
383
391
  checkedSessionId(meta.id);
384
392
  meta.updatedAt = new Date().toISOString();
385
- const safe = redactedSessionCopy({ meta, history, ...(task ? { task } : {}) });
393
+ const data = { meta, history, ...(task ? { task } : {}) };
394
+ if (!sessionValueWithinLimits(data)) {
395
+ throw new Error("session exceeds Hara's safe persistence complexity limit; compact or start a new session");
396
+ }
397
+ const safe = redactedSessionCopy(data);
398
+ const encoded = JSON.stringify(safe, null, 2);
399
+ if (Buffer.byteLength(encoded, "utf8") > MAX_SESSION_FILE_BYTES) {
400
+ throw new Error("session exceeds Hara's 64 MiB persistence limit; compact or start a new session");
401
+ }
386
402
  const target = sessionFile(meta.id);
387
403
  const tmp = `${target}.${process.pid}.${randomUUID()}.tmp`;
388
404
  let fd;
389
405
  try {
390
406
  fd = openSync(tmp, "wx", 0o600);
391
- writeFileSync(fd, JSON.stringify(safe, null, 2), "utf8");
407
+ writeFileSync(fd, encoded, "utf8");
392
408
  fsyncSync(fd);
393
409
  closeSync(fd);
394
410
  fd = undefined;
@@ -480,6 +496,32 @@ function isSessionMeta(value) {
480
496
  (meta.archived === undefined || typeof meta.archived === "boolean") &&
481
497
  (meta.gatewayOwner === undefined || typeof meta.gatewayOwner === "string"));
482
498
  }
499
+ /** Iterative on purpose: reject excessive nesting before recursive redaction/validation can exhaust stack. */
500
+ function sessionValueWithinLimits(value) {
501
+ const pending = [{ value, depth: 0 }];
502
+ let nodes = 0;
503
+ while (pending.length) {
504
+ const current = pending.pop();
505
+ nodes += 1;
506
+ if (nodes > MAX_SESSION_JSON_NODES || current.depth > MAX_SESSION_JSON_DEPTH)
507
+ return false;
508
+ if (typeof current.value === "string") {
509
+ if (current.value.length > MAX_SESSION_STRING_CHARS)
510
+ return false;
511
+ continue;
512
+ }
513
+ if (!current.value || typeof current.value !== "object")
514
+ continue;
515
+ const values = Array.isArray(current.value)
516
+ ? current.value
517
+ : Object.values(current.value);
518
+ if (values.length > MAX_SESSION_ARRAY_ITEMS)
519
+ return false;
520
+ for (const child of values)
521
+ pending.push({ value: child, depth: current.depth + 1 });
522
+ }
523
+ return true;
524
+ }
483
525
  /** True if a parsed object has the SessionData shape we can safely use. */
484
526
  function isSessionData(d) {
485
527
  const o = d;
@@ -491,8 +533,13 @@ function isSessionData(d) {
491
533
  * here: listing/resuming must not perform an unlocked write. The next explicit save atomically migrates it. */
492
534
  function readSessionFile(p) {
493
535
  try {
494
- const d = JSON.parse(readFileSync(p, "utf8"));
495
- return isSessionData(d) ? redactedSessionCopy(d) : null;
536
+ const raw = readVerifiedRegularFileSnapshotSync(p, MAX_SESSION_FILE_BYTES, {
537
+ action: "read Hara session",
538
+ protectSensitive: false,
539
+ rejectHardLinks: true,
540
+ }).text;
541
+ const d = JSON.parse(raw);
542
+ return sessionValueWithinLimits(d) && isSessionData(d) ? redactedSessionCopy(d) : null;
496
543
  }
497
544
  catch {
498
545
  return null;
@@ -111,7 +111,7 @@ export function requestsTaskContinuation(text) {
111
111
  const value = text.trim().toLocaleLowerCase();
112
112
  if (!value)
113
113
  return false;
114
- return /^(?:\/(?:continue|resume)(?:\s|$)|(?:continue|resume|go\s+on)(?:[\s,.:;!?,。:;!?]|$)|(?:继续|接着|接着做|继续处理)(?:[\s,.:;!?,。:;!?]|$))/.test(value);
114
+ return /^(?:\/continue(?:\s|$)|(?:continue|resume|go\s+on)(?:[\s,.:;!?,。:;!?]|$)|(?:继续|接着|接着做|继续处理)(?:[\s,.:;!?,。:;!?]|$))/.test(value);
115
115
  }
116
116
  export function finishTaskExecution(task, outcome, todos = [], interrupted = false, at = new Date()) {
117
117
  if (!task)
@@ -123,9 +123,11 @@ export function finishTaskExecution(task, outcome, todos = [], interrupted = fal
123
123
  ? "paused"
124
124
  : outcome?.status === "completed"
125
125
  ? (incomplete ? "paused" : "completed")
126
- : outcome?.status === "error" || outcome?.status === "empty" || outcome?.status === "halted"
127
- ? "blocked"
128
- : "paused";
126
+ : outcome?.status === "halted" && outcome.stopReason === "deadline"
127
+ ? "paused"
128
+ : outcome?.status === "error" || outcome?.status === "empty" || outcome?.status === "halted"
129
+ ? "blocked"
130
+ : "paused";
129
131
  return { ...task, status, lastOutcome, updatedAt: now, endedAt: now };
130
132
  }
131
133
  /** A process died while this task was running. Recovery is explicit and never claims success. */
@@ -11,7 +11,7 @@ import { runJobTracked } from "../cron/runner.js";
11
11
  import { parseDeliver } from "../cron/deliver.js";
12
12
  import { isInstalled } from "../cron/install.js";
13
13
  import { sensitiveShellCommandReason } from "../security/sensitive-files.js";
14
- import { homeWorkspaceActionError, isHomeWorkspace } from "../context/workspace-scope.js";
14
+ import { homeWorkspaceActionError, isUnsafeProjectWorkspace } from "../context/workspace-scope.js";
15
15
  const fmt = (ms) => (ms ? new Date(ms).toLocaleString() : "—");
16
16
  const fmtNext = (ms, now) => (ms !== null && ms <= now ? "due now" : fmt(ms));
17
17
  const duration = (ms) => {
@@ -87,7 +87,7 @@ registerTool({
87
87
  if (action === "add") {
88
88
  // Every new job persists ctx.cwd and later treats it as its implicit agent/shell workspace. Management
89
89
  // of existing jobs remains available at Home, but creating a Home-root job would bypass project scope.
90
- if (isHomeWorkspace(ctx.cwd))
90
+ if (isUnsafeProjectWorkspace(ctx.cwd))
91
91
  return `Error: ${homeWorkspaceActionError("schedule a job")}`;
92
92
  const scheduleStr = String(input.schedule ?? "").trim();
93
93
  const task = String(input.task ?? "").trim();
@@ -1,5 +1,5 @@
1
1
  import { limitToolResult } from "./result-limit.js";
2
- import { homeWorkspaceActionError, isHomeWorkspace } from "../context/workspace-scope.js";
2
+ import { homeWorkspaceActionError, isUnsafeProjectWorkspace } from "../context/workspace-scope.js";
3
3
  /** Names of required parameters that are ABSENT (undefined/null) in a tool call's input. Defends
4
4
  * against models that drop parameters outright (observed: qwen3.7-plus losing write_file's
5
5
  * path/content mid-stream) — the loop rejects the call with a precise error instead of executing
@@ -28,7 +28,7 @@ export function registerTool(t) {
28
28
  // Only explicitly project-scoped operations are blocked. Tool kinds are also used for safe
29
29
  // management/delivery side effects, so a kind must not itself imply a project workspace requirement.
30
30
  // Canonical comparison closes Home symlink aliases.
31
- if (t.requiresProjectWorkspace && isHomeWorkspace(ctx.cwd)) {
31
+ if (t.requiresProjectWorkspace && isUnsafeProjectWorkspace(ctx.cwd)) {
32
32
  return `Error: ${homeWorkspaceActionError(`run ${t.name}`)}`;
33
33
  }
34
34
  return limitToolResult(await run(input, ctx));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.124.2",
3
+ "version": "0.124.4",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "runtime-bootstrap.cjs"