@nanhara/hara 0.124.1 → 0.124.3

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
@@ -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.1",
3
+ "version": "0.124.3",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "runtime-bootstrap.cjs"