@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/CHANGELOG.md +44 -0
- package/dist/agent/loop.js +4 -2
- package/dist/checkpoints.js +4 -4
- package/dist/context/workspace-scope.js +46 -9
- package/dist/gateway/wecom.js +512 -223
- package/dist/index.js +151 -30
- package/dist/runtime.js +13 -0
- package/dist/sandbox.js +2 -2
- package/dist/search/semindex.js +5 -5
- package/dist/security/permissions.js +2 -2
- package/dist/session/resume.js +52 -0
- package/dist/session/store.js +51 -4
- package/dist/session/task.js +6 -4
- package/dist/tools/cron.js +2 -2
- package/dist/tools/registry.js +2 -2
- package/package.json +1 -1
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,
|
|
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 (
|
|
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 (
|
|
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
|
|
1173
|
+
.description("resume a session in its saved project — no id resumes the most recent here")
|
|
1172
1174
|
.action(async (id) => {
|
|
1173
|
-
|
|
1174
|
-
if (
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
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
|
-
|
|
1182
|
-
|
|
1183
|
-
if (
|
|
1184
|
-
out(c.
|
|
1185
|
-
process.
|
|
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
|
-
|
|
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(
|
|
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",
|
|
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 &&
|
|
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 =
|
|
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.
|
|
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 ·
|
|
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 = /^\/
|
|
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
|
-
|
|
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 = /^\/
|
|
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
|
-
|
|
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,
|
|
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 (
|
|
129
|
+
if (isUnsafeProjectWorkspace(cwd))
|
|
130
130
|
throw new Error(homeWorkspaceActionError("run shell commands"));
|
|
131
131
|
const protectedReason = sensitiveShellCommandReason(command, cwd);
|
|
132
132
|
if (protectedReason) {
|
package/dist/search/semindex.js
CHANGED
|
@@ -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,
|
|
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" &&
|
|
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" &&
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
+
}
|
package/dist/session/store.js
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
|
495
|
-
|
|
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;
|
package/dist/session/task.js
CHANGED
|
@@ -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 /^(?:\/
|
|
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 === "
|
|
127
|
-
? "
|
|
128
|
-
: "
|
|
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. */
|
package/dist/tools/cron.js
CHANGED
|
@@ -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,
|
|
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 (
|
|
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();
|
package/dist/tools/registry.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { limitToolResult } from "./result-limit.js";
|
|
2
|
-
import { homeWorkspaceActionError,
|
|
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 &&
|
|
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));
|