@nanhara/hara 0.122.7 → 0.123.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/README.md +22 -6
- package/dist/agent/loop.js +3 -2
- package/dist/config.js +70 -11
- package/dist/context/workspace-scope.js +4 -4
- package/dist/index.js +206 -44
- package/dist/org-fleet/enroll.js +30 -4
- package/dist/security/guardian.js +13 -5
- package/dist/security/private-state.js +3 -1
- package/dist/serve/protocol.js +2 -1
- package/dist/serve/server.js +125 -50
- package/dist/serve/sessions.js +12 -7
- package/dist/session/store.js +32 -3
- package/dist/session/task.js +161 -0
- package/dist/tui/App.js +21 -9
- package/dist/tui/InputBox.js +61 -18
- package/dist/tui/bracketed-paste.js +265 -0
- package/dist/tui/run.js +8 -2
- package/package.json +1 -1
package/dist/org-fleet/enroll.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// GET {gateway}/v1/roles Bearer <device_token> -> {version, org_policy, roles:[…]} (B3 digital-employee push-down)
|
|
11
11
|
// POST {gateway}/v1/chat/completions (OpenAI-compatible; the normal agent traffic, Bearer <device_token>)
|
|
12
12
|
import { homedir, hostname, platform } from "node:os";
|
|
13
|
-
import { join } from "node:path";
|
|
13
|
+
import { dirname, join, resolve } from "node:path";
|
|
14
14
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, rmSync } from "node:fs";
|
|
15
15
|
import { orgRolesDir } from "../org/roles.js";
|
|
16
16
|
import { loadActiveProfile } from "../profile/profile.js";
|
|
@@ -117,6 +117,25 @@ export async function heartbeat(signal) {
|
|
|
117
117
|
return false;
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
|
+
const SAFE_ORG_ROLE_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
121
|
+
const WINDOWS_RESERVED_NAME = /^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i;
|
|
122
|
+
function isSafeBundleRole(value) {
|
|
123
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
124
|
+
return false;
|
|
125
|
+
const role = value;
|
|
126
|
+
if (typeof role.name !== "string" || !SAFE_ORG_ROLE_NAME.test(role.name) || WINDOWS_RESERVED_NAME.test(role.name) ||
|
|
127
|
+
typeof role.system !== "string" || !role.system.trim())
|
|
128
|
+
return false;
|
|
129
|
+
for (const key of ["description", "model"]) {
|
|
130
|
+
if (role[key] !== undefined && typeof role[key] !== "string")
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
for (const key of ["owns", "rejects", "allow_tools", "deny_tools"]) {
|
|
134
|
+
if (role[key] !== undefined && (!Array.isArray(role[key]) || !role[key].every((item) => typeof item === "string")))
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
120
139
|
/** Render one bundle role into the markdown frontmatter the CLI role loader expects
|
|
121
140
|
* (src/org/roles.ts parseFrontmatter): name/description/owns/rejects/model/allowTools/denyTools, body=system. */
|
|
122
141
|
function renderRoleMd(r) {
|
|
@@ -150,12 +169,19 @@ export async function syncOrgRoles(signal) {
|
|
|
150
169
|
if (!res.ok)
|
|
151
170
|
return 0;
|
|
152
171
|
const bundle = (await res.json());
|
|
153
|
-
const roles = Array.isArray(bundle.roles)
|
|
172
|
+
const roles = Array.isArray(bundle.roles)
|
|
173
|
+
? [...new Map(bundle.roles.filter(isSafeBundleRole).map((role) => [role.name, role])).values()]
|
|
174
|
+
: [];
|
|
154
175
|
const dir = orgRolesDir();
|
|
155
176
|
rmSync(dir, { recursive: true, force: true }); // authoritative replace
|
|
156
177
|
mkdirSync(dir, { recursive: true });
|
|
157
|
-
|
|
158
|
-
|
|
178
|
+
const root = resolve(dir);
|
|
179
|
+
for (const r of roles) {
|
|
180
|
+
const target = resolve(root, `${r.name}.md`);
|
|
181
|
+
if (dirname(target) !== root)
|
|
182
|
+
continue;
|
|
183
|
+
writeFileSync(target, renderRoleMd(r), "utf8");
|
|
184
|
+
}
|
|
159
185
|
// org policy sidecar (model/tool/approval floors the CLI enforces; skipped by the .md-only role loader)
|
|
160
186
|
writeFileSync(join(dir, "_policy.json"), JSON.stringify({ version: bundle.version ?? 0, org_policy: bundle.org_policy ?? {} }, null, 2) + "\n", "utf8");
|
|
161
187
|
return roles.length;
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// Plus a deterministic circuit-breaker: N guardian BLOCKS in one run (or an escalating-destructive runaway)
|
|
17
17
|
// trips a hard stop that requires explicit user confirmation to continue — a harder stop than the soft
|
|
18
18
|
// stuck-guard nudge, and one that aborts safely (never hangs) when there's no interactive user.
|
|
19
|
-
import { resolve, isAbsolute } from "node:path";
|
|
19
|
+
import { resolve, isAbsolute, relative, sep, win32 } from "node:path";
|
|
20
20
|
import { boundedProviderTurn } from "../providers/bounded-turn.js";
|
|
21
21
|
import { canonicalize, splitCompound } from "./permissions.js";
|
|
22
22
|
// ── Deterministic risk classifier ────────────────────────────────────────────────────────────────────
|
|
@@ -93,10 +93,18 @@ function redirectionTargets(canonical) {
|
|
|
93
93
|
export function isOutsideRoot(p, cwd) {
|
|
94
94
|
if (!p || p === "/dev/null" || p === "-")
|
|
95
95
|
return false;
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
96
|
+
// Windows uses `\\` boundaries, so concatenating `root + "/"` classifies every ordinary child as
|
|
97
|
+
// outside. `relative` expresses containment portably. Detect an explicit Windows root as well so the
|
|
98
|
+
// classifier remains testable and safe when a control-plane caller runs on another host.
|
|
99
|
+
const windowsRoot = win32.isAbsolute(cwd);
|
|
100
|
+
const pathApi = windowsRoot
|
|
101
|
+
? { resolve: win32.resolve, isAbsolute: win32.isAbsolute, relative: win32.relative, sep: win32.sep }
|
|
102
|
+
: { resolve, isAbsolute, relative, sep };
|
|
103
|
+
const expanded = p.replace(/^~(?=[/\\]|$)/, process.env.HOME ?? process.env.USERPROFILE ?? "~");
|
|
104
|
+
const abs = pathApi.isAbsolute(expanded) ? pathApi.resolve(expanded) : pathApi.resolve(cwd, expanded);
|
|
105
|
+
const root = pathApi.resolve(cwd);
|
|
106
|
+
const rel = pathApi.relative(root, abs);
|
|
107
|
+
return rel !== "" && (rel === ".." || rel.startsWith(`..${pathApi.sep}`) || pathApi.isAbsolute(rel));
|
|
100
108
|
}
|
|
101
109
|
/** Collect the file paths an edit-kind tool would write/delete, from its tool input. */
|
|
102
110
|
export function editPaths(name, input) {
|
|
@@ -248,7 +248,9 @@ function tightenTree(root, budget) {
|
|
|
248
248
|
for (const entry of entries)
|
|
249
249
|
stack.push(join(path, entry));
|
|
250
250
|
}
|
|
251
|
-
else if (info.isFile()) {
|
|
251
|
+
else if (info.isFile() && info.nlink === 1) {
|
|
252
|
+
// chmod follows hard links at the inode level. An attacker-controlled alias must not let startup
|
|
253
|
+
// tighten (and thereby mutate) an unrelated file outside ~/.hara; dedicated readers reject it later.
|
|
252
254
|
chmodPrivate(path, 0o600);
|
|
253
255
|
}
|
|
254
256
|
}
|
package/dist/serve/protocol.js
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
// session.list {cwd?} → {sessions:[{id,title,cwd,model,updatedAt}]}
|
|
9
9
|
// session.create {cwd?,approval?} → {sessionId,model}
|
|
10
10
|
// session.resume {sessionId} → {sessionId,model,history:[{role,text}]}
|
|
11
|
-
// session.send {sessionId,text,images?}
|
|
11
|
+
// session.send {sessionId,text,images?,newTask?} → (streams events, then) {reply,usage,taskId,turnId}
|
|
12
|
+
// session.steer {sessionId,text,expectedTurnId} → {accepted,taskId,turnId}
|
|
12
13
|
// images: [{path,mediaType?}] — pasted screenshots etc., inlined for vision models
|
|
13
14
|
// session.interrupt {sessionId} → {}
|
|
14
15
|
// approval.reply {approvalId,allow,always?} → {}
|
package/dist/serve/server.js
CHANGED
|
@@ -28,10 +28,11 @@ import { parseSchedule, describeSchedule } from "../cron/schedule.js";
|
|
|
28
28
|
import { loadTasks } from "../tools/task.js";
|
|
29
29
|
import { listPending, resolvePending } from "../gateway/flows-pending.js";
|
|
30
30
|
import { disposeTodoScope, restoreTodos, serializeTodos } from "../tools/todo.js";
|
|
31
|
-
import { disposeReminderScope } from "../agent/reminders.js";
|
|
31
|
+
import { INTERJECT_PREFIX, disposeReminderScope } from "../agent/reminders.js";
|
|
32
32
|
import { SessionHub, realStore } from "./sessions.js";
|
|
33
33
|
import { parseFrame, rpcResult, rpcError, rpcNotify, ERR, PROTOCOL_VERSION } from "./protocol.js";
|
|
34
34
|
import { readModelContextFileSync } from "../fs-read.js";
|
|
35
|
+
import { createTaskExecution, continueTaskExecution, finishTaskExecution, newSteerInteraction, newTurnInteraction, recordTaskSteering, taskExecutionContext, } from "../session/task.js";
|
|
35
36
|
const APPROVAL_TIMEOUT_MS = 300_000; // an unanswered approval denies after 5 min (never hangs a turn)
|
|
36
37
|
const COMPACT_TIMEOUT_MS = 60_000;
|
|
37
38
|
const SHUTDOWN_GRACE_MS = 2_000;
|
|
@@ -301,11 +302,31 @@ export async function startServe(opts, deps) {
|
|
|
301
302
|
}
|
|
302
303
|
}
|
|
303
304
|
/** Run one turn on a session, streaming events to all authed clients. */
|
|
304
|
-
const runTurn = async (s, text, images) => {
|
|
305
|
+
const runTurn = async (s, text, images, forceNewTask = false) => {
|
|
305
306
|
const sessionId = s.meta.id;
|
|
306
307
|
s.busy = true;
|
|
307
308
|
const turnAbort = new AbortController();
|
|
308
309
|
s.abort = turnAbort;
|
|
310
|
+
s.pendingInput = [];
|
|
311
|
+
const interaction = !forceNewTask && s.resumeTaskPending && s.task
|
|
312
|
+
? newSteerInteraction(s.task.turnId)
|
|
313
|
+
: newTurnInteraction();
|
|
314
|
+
if (interaction.kind === "steer") {
|
|
315
|
+
const continued = continueTaskExecution(s.task, interaction);
|
|
316
|
+
if (!continued.ok) {
|
|
317
|
+
s.abort = null;
|
|
318
|
+
s.busy = false;
|
|
319
|
+
throw new Error(continued.reason);
|
|
320
|
+
}
|
|
321
|
+
s.task = continued.task;
|
|
322
|
+
}
|
|
323
|
+
else {
|
|
324
|
+
s.task = createTaskExecution(text, interaction.turnId);
|
|
325
|
+
}
|
|
326
|
+
s.resumeTaskPending = false;
|
|
327
|
+
const executionContext = taskExecutionContext(s.task, interaction);
|
|
328
|
+
hub.save(s); // crash-safe running identity before provider/tool side effects
|
|
329
|
+
broadcast("event.turn_start", { sessionId, taskId: s.task.id, turnId: s.task.turnId });
|
|
309
330
|
const historyStart = s.history.length;
|
|
310
331
|
const before = { input: s.stats.input, output: s.stats.output };
|
|
311
332
|
const sink = {
|
|
@@ -361,49 +382,63 @@ export async function startServe(opts, deps) {
|
|
|
361
382
|
// @file mentions expand to file contents, same as the CLI (`@src/foo.ts` in the composer works).
|
|
362
383
|
// Pasted images ride along as NeutralMsg.images — a vision-capable model sees them inline.
|
|
363
384
|
s.history.push({ role: "user", content: await expandMentionsAsync(content, s.meta.cwd, { signal: turnAbort.signal }), ...(images && images.length ? { images } : {}) });
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
s.
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
s.
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
385
|
+
let outcome;
|
|
386
|
+
do {
|
|
387
|
+
outcome = await runAgent(s.history, {
|
|
388
|
+
provider: s.provider,
|
|
389
|
+
ctx: {
|
|
390
|
+
cwd: s.meta.cwd,
|
|
391
|
+
sandbox: deps.sandbox,
|
|
392
|
+
todoScope: sessionId,
|
|
393
|
+
spawn: (t, role, signal) => deps.spawnSubagent(s.provider, s.meta.cwd, s.projectContext, s.stats, t, role, signal),
|
|
394
|
+
ui: sink,
|
|
395
|
+
},
|
|
396
|
+
approval: s.approval,
|
|
397
|
+
confirm,
|
|
398
|
+
autoApprove: s.autoApprove,
|
|
399
|
+
projectContext: s.projectContext,
|
|
400
|
+
memory: memoryDigest(s.meta.cwd),
|
|
401
|
+
continuationSession: s.continuationSession,
|
|
402
|
+
executionContext,
|
|
403
|
+
pendingInput: async () => s.pendingInput.splice(0),
|
|
404
|
+
stats: s.stats,
|
|
405
|
+
signal: turnAbort.signal,
|
|
406
|
+
onProviderTurn: (turn) => {
|
|
407
|
+
s.pendingProviderTurns += 1;
|
|
408
|
+
const settled = () => {
|
|
409
|
+
s.pendingProviderTurns = Math.max(0, s.pendingProviderTurns - 1);
|
|
410
|
+
if (closing)
|
|
411
|
+
hub.releaseIdle();
|
|
412
|
+
};
|
|
413
|
+
void turn.then(settled, settled);
|
|
414
|
+
},
|
|
415
|
+
onToolRun: (toolRun) => {
|
|
416
|
+
s.pendingToolRuns += 1;
|
|
417
|
+
const settled = () => {
|
|
418
|
+
s.pendingToolRuns = Math.max(0, s.pendingToolRuns - 1);
|
|
419
|
+
// `abort === null` means the logical turn already returned. Keep the session busy/locked until
|
|
420
|
+
// every late side-effect-capable Promise has physically stopped.
|
|
421
|
+
if (s.pendingToolRuns === 0 && s.abort === null)
|
|
422
|
+
s.busy = false;
|
|
423
|
+
if (closing)
|
|
424
|
+
hub.releaseIdle();
|
|
425
|
+
};
|
|
426
|
+
void toolRun.then(settled, settled);
|
|
427
|
+
},
|
|
428
|
+
guardian: turnGuardian,
|
|
429
|
+
...(deps.runLimits?.(s.meta.cwd) ?? {}),
|
|
430
|
+
});
|
|
431
|
+
// A steer may land after the agent's final in-loop drain but before the logical turn returns. Keep
|
|
432
|
+
// it in the same task/run instead of making the client retry it as an unrelated session.send.
|
|
433
|
+
const trailing = s.pendingInput.splice(0);
|
|
434
|
+
if (!trailing.length || turnAbort.signal.aborted || outcome.status !== "completed")
|
|
435
|
+
break;
|
|
436
|
+
s.history.push(...trailing);
|
|
437
|
+
hub.save(s);
|
|
438
|
+
} while (true);
|
|
406
439
|
s.meta.todos = serializeTodos(sessionId);
|
|
440
|
+
s.task = finishTaskExecution(s.task, outcome, s.meta.todos, turnAbort.signal.aborted);
|
|
441
|
+
s.resumeTaskPending = Boolean(s.task && s.task.status !== "completed");
|
|
407
442
|
hub.save(s);
|
|
408
443
|
const usage = { input: s.stats.input - before.input, output: s.stats.output - before.output };
|
|
409
444
|
// context watermark rides along with every turn end (codex thread/tokenUsage/updated pattern) —
|
|
@@ -415,14 +450,22 @@ export async function startServe(opts, deps) {
|
|
|
415
450
|
: outcome.status === "halted"
|
|
416
451
|
? "agent turn halted by a safety control"
|
|
417
452
|
: "agent turn failed");
|
|
418
|
-
broadcast("event.turn_end", { sessionId, reply: "", error: failure, status: outcome.status, usage, ctx });
|
|
453
|
+
broadcast("event.turn_end", { sessionId, taskId: s.task.id, turnId: s.task.turnId, reply: "", error: failure, status: outcome.status, usage, ctx });
|
|
419
454
|
throw new Error(failure);
|
|
420
455
|
}
|
|
421
456
|
// A persistent session may already contain many assistant messages. Only messages appended by THIS
|
|
422
457
|
// request are eligible for its reply; a failed/empty turn must never replay a previous success.
|
|
423
458
|
const reply = lastAssistantText(s.history.slice(historyStart));
|
|
424
|
-
broadcast("event.turn_end", { sessionId, reply, usage, ctx });
|
|
425
|
-
return { reply, usage, ctx };
|
|
459
|
+
broadcast("event.turn_end", { sessionId, taskId: s.task.id, turnId: s.task.turnId, reply, usage, ctx });
|
|
460
|
+
return { reply, usage, ctx, taskId: s.task.id, turnId: s.task.turnId };
|
|
461
|
+
}
|
|
462
|
+
catch (error) {
|
|
463
|
+
if (s.task?.status === "running") {
|
|
464
|
+
s.task = finishTaskExecution(s.task, { status: "error", error: error instanceof Error ? error.message : String(error) }, s.meta.todos ?? [], turnAbort.signal.aborted);
|
|
465
|
+
s.resumeTaskPending = Boolean(s.task && s.task.status !== "completed");
|
|
466
|
+
hub.save(s);
|
|
467
|
+
}
|
|
468
|
+
throw error;
|
|
426
469
|
}
|
|
427
470
|
finally {
|
|
428
471
|
s.abort = null;
|
|
@@ -536,7 +579,7 @@ export async function startServe(opts, deps) {
|
|
|
536
579
|
// clients feature-detect up front instead of probing for -32601 per call. `p.capabilities`
|
|
537
580
|
// (client-declared) is accepted and currently unused — reserved for opt-outs/experimental gating.
|
|
538
581
|
const methods = [
|
|
539
|
-
"session.list", "session.create", "session.resume", "session.send", "session.interrupt", "session.set-model",
|
|
582
|
+
"session.list", "session.create", "session.resume", "session.send", "session.steer", "session.interrupt", "session.set-model",
|
|
540
583
|
"session.rename", "session.archive", "session.compact", "session.rewind", "session.context", "session.delete", "session.fork",
|
|
541
584
|
"approval.reply", "plugins.list", "plugins.set", "skills.list", "models.list", "files.search", "project.panels",
|
|
542
585
|
"automation.list", "automation.add", "automation.toggle", "automation.delete",
|
|
@@ -595,7 +638,18 @@ export async function startServe(opts, deps) {
|
|
|
595
638
|
return reply(rpcError(id, ERR.INTERNAL, `provider not authenticated for pinned model '${r.session.meta.model}'`));
|
|
596
639
|
}
|
|
597
640
|
r.session.projectContext = loadAgentContext(r.session.meta.cwd) || undefined;
|
|
598
|
-
return reply(rpcResult(id, {
|
|
641
|
+
return reply(rpcResult(id, {
|
|
642
|
+
sessionId: r.session.meta.id,
|
|
643
|
+
model: r.session.meta.model,
|
|
644
|
+
history: historyForClient(r.session.history),
|
|
645
|
+
task: r.session.task ? {
|
|
646
|
+
id: r.session.task.id,
|
|
647
|
+
objective: r.session.task.objective,
|
|
648
|
+
status: r.session.task.status,
|
|
649
|
+
turnId: r.session.task.turnId,
|
|
650
|
+
updatedAt: r.session.task.updatedAt,
|
|
651
|
+
} : undefined,
|
|
652
|
+
}));
|
|
599
653
|
}
|
|
600
654
|
case "session.send": {
|
|
601
655
|
if (typeof p.sessionId !== "string" || typeof p.text !== "string" || !p.text)
|
|
@@ -608,9 +662,27 @@ export async function startServe(opts, deps) {
|
|
|
608
662
|
const images = Array.isArray(p.images)
|
|
609
663
|
? p.images.filter((im) => im && typeof im.path === "string").map((im) => ({ path: im.path, mediaType: typeof im.mediaType === "string" ? im.mediaType : "image/png" }))
|
|
610
664
|
: undefined;
|
|
611
|
-
const r = await runTurn(s, p.text, images);
|
|
665
|
+
const r = await runTurn(s, p.text, images, p.newTask === true);
|
|
612
666
|
return reply(rpcResult(id, r));
|
|
613
667
|
}
|
|
668
|
+
case "session.steer": {
|
|
669
|
+
if (typeof p.sessionId !== "string" || typeof p.text !== "string" || !p.text || typeof p.expectedTurnId !== "string") {
|
|
670
|
+
return reply(rpcError(id, ERR.PARAMS, "sessionId + text + expectedTurnId required"));
|
|
671
|
+
}
|
|
672
|
+
const s = hub.get(p.sessionId);
|
|
673
|
+
if (!s)
|
|
674
|
+
return reply(rpcError(id, ERR.NO_SESSION, "no such live session"));
|
|
675
|
+
if (!s.busy || !s.abort || s.configuring)
|
|
676
|
+
return reply(rpcError(id, ERR.BUSY, "there is no steerable running turn"));
|
|
677
|
+
const expanded = await expandMentionsAsync(p.text, s.meta.cwd, { signal: s.abort.signal });
|
|
678
|
+
const recorded = recordTaskSteering(s.task, p.expectedTurnId, expanded);
|
|
679
|
+
if (!recorded.ok)
|
|
680
|
+
return reply(rpcError(id, ERR.BUSY, recorded.reason));
|
|
681
|
+
s.task = recorded.task;
|
|
682
|
+
s.pendingInput.push({ role: "user", content: `${INTERJECT_PREFIX}\n\n${expanded}` });
|
|
683
|
+
hub.save(s); // task audit is durable even before the running loop reaches its next drain
|
|
684
|
+
return reply(rpcResult(id, { accepted: true, taskId: s.task.id, turnId: s.task.turnId }));
|
|
685
|
+
}
|
|
614
686
|
case "session.interrupt": {
|
|
615
687
|
const s = typeof p.sessionId === "string" ? hub.get(p.sessionId) : undefined;
|
|
616
688
|
if (!s)
|
|
@@ -896,6 +968,9 @@ export async function startServe(opts, deps) {
|
|
|
896
968
|
return reply(rpcError(id, ERR.PARAMS, `n out of range (1..${s.history.filter((m) => m.role === "user").length})`));
|
|
897
969
|
s.history.length = 0;
|
|
898
970
|
s.history.push(...next);
|
|
971
|
+
s.task = undefined;
|
|
972
|
+
s.resumeTaskPending = false;
|
|
973
|
+
s.pendingInput = [];
|
|
899
974
|
hub.save(s);
|
|
900
975
|
return reply(rpcResult(id, { sessionId: s.meta.id, history: historyForClient(s.history) }));
|
|
901
976
|
}
|
package/dist/serve/sessions.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { newSessionId, saveSession, loadSession, listSessions, acquireSessionLock, releaseSessionLock, deleteSession, deriveTitle, } from "../session/store.js";
|
|
2
|
+
import { forkTaskExecution, recoverTaskExecution } from "../session/task.js";
|
|
2
3
|
/** The real ~/.hara/sessions store (default). */
|
|
3
4
|
export const realStore = {
|
|
4
5
|
load: loadSession,
|
|
@@ -26,7 +27,7 @@ export class SessionHub {
|
|
|
26
27
|
if (!current)
|
|
27
28
|
return false;
|
|
28
29
|
mutate(current);
|
|
29
|
-
this.store.save(current.meta, current.history);
|
|
30
|
+
this.store.save(current.meta, current.history, current.task);
|
|
30
31
|
return true;
|
|
31
32
|
}
|
|
32
33
|
finally {
|
|
@@ -47,7 +48,7 @@ export class SessionHub {
|
|
|
47
48
|
const lock = this.store.acquire(meta.id); // fresh UUID, but filesystem errors must still fail closed
|
|
48
49
|
if (!lock.ok)
|
|
49
50
|
throw new Error(`could not acquire session lock for ${meta.id}${lock.pid ? ` (held by pid ${lock.pid})` : ""}`);
|
|
50
|
-
const s = { meta, history: [], provider: o.provider, approval: o.approval, autoApprove: new Set(), stats: { input: 0, output: 0 }, projectContext: o.projectContext, continuationSession: false, busy: false, configuring: false, pendingProviderTurns: 0, pendingToolRuns: 0, abort: null };
|
|
51
|
+
const s = { meta, history: [], resumeTaskPending: false, pendingInput: [], provider: o.provider, approval: o.approval, autoApprove: new Set(), stats: { input: 0, output: 0 }, projectContext: o.projectContext, continuationSession: false, busy: false, configuring: false, pendingProviderTurns: 0, pendingToolRuns: 0, abort: null };
|
|
51
52
|
try {
|
|
52
53
|
this.sessions.set(meta.id, s);
|
|
53
54
|
this.store.save(meta, []); // an empty newly-created thread must survive restart and appear in lists
|
|
@@ -76,7 +77,8 @@ export class SessionHub {
|
|
|
76
77
|
return { missing: true };
|
|
77
78
|
// Credential/provider routing is live, while the model remains the session's explicit pin.
|
|
78
79
|
prior.meta.provider = o.provider.id;
|
|
79
|
-
const
|
|
80
|
+
const task = recoverTaskExecution(prior.task);
|
|
81
|
+
const s = { meta: prior.meta, history: [...prior.history], task, resumeTaskPending: Boolean(task && task.status !== "completed"), pendingInput: [], provider: o.provider, approval: o.approval, autoApprove: new Set(), stats: { input: 0, output: 0 }, projectContext: o.projectContext, continuationSession: prior.history.length > 0, busy: false, configuring: false, pendingProviderTurns: 0, pendingToolRuns: 0, abort: null, effort: prior.meta.effort };
|
|
80
82
|
this.sessions.set(id, s);
|
|
81
83
|
keepLock = true; // live session owns it until delete/releaseAll
|
|
82
84
|
return { session: s };
|
|
@@ -114,7 +116,7 @@ export class SessionHub {
|
|
|
114
116
|
if (first && "content" in first && typeof first.content === "string")
|
|
115
117
|
s.meta.title = deriveTitle(first.content);
|
|
116
118
|
}
|
|
117
|
-
this.store.save(s.meta, s.history);
|
|
119
|
+
this.store.save(s.meta, s.history, s.task);
|
|
118
120
|
}
|
|
119
121
|
/** Rename a session (live or on-disk). Returns false when the id is unknown. */
|
|
120
122
|
rename(id, title) {
|
|
@@ -123,7 +125,7 @@ export class SessionHub {
|
|
|
123
125
|
if (live.busy || live.configuring)
|
|
124
126
|
return false;
|
|
125
127
|
live.meta.title = title;
|
|
126
|
-
this.store.save(live.meta, live.history);
|
|
128
|
+
this.store.save(live.meta, live.history, live.task);
|
|
127
129
|
return true;
|
|
128
130
|
}
|
|
129
131
|
return this.mutateStored(id, (current) => {
|
|
@@ -137,7 +139,7 @@ export class SessionHub {
|
|
|
137
139
|
if (live.busy || live.configuring)
|
|
138
140
|
return false;
|
|
139
141
|
live.meta.archived = on;
|
|
140
|
-
this.store.save(live.meta, live.history);
|
|
142
|
+
this.store.save(live.meta, live.history, live.task);
|
|
141
143
|
return true;
|
|
142
144
|
}
|
|
143
145
|
return this.mutateStored(id, (current) => {
|
|
@@ -172,6 +174,9 @@ export class SessionHub {
|
|
|
172
174
|
const s = {
|
|
173
175
|
meta,
|
|
174
176
|
history: [...src.history],
|
|
177
|
+
task: forkTaskExecution(src.task),
|
|
178
|
+
resumeTaskPending: Boolean(src.task),
|
|
179
|
+
pendingInput: [],
|
|
175
180
|
provider: o.provider,
|
|
176
181
|
approval: o.approval,
|
|
177
182
|
autoApprove: new Set(),
|
|
@@ -187,7 +192,7 @@ export class SessionHub {
|
|
|
187
192
|
};
|
|
188
193
|
try {
|
|
189
194
|
this.sessions.set(meta.id, s);
|
|
190
|
-
this.store.save(meta, s.history); // persist immediately — a fork should survive a crash unsent
|
|
195
|
+
this.store.save(meta, s.history, s.task); // persist immediately — a fork should survive a crash unsent
|
|
191
196
|
return { session: s };
|
|
192
197
|
}
|
|
193
198
|
catch (error) {
|
package/dist/session/store.js
CHANGED
|
@@ -4,6 +4,7 @@ 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 { isTaskExecution } from "./task.js";
|
|
7
8
|
/** Derive the session source from the spawn environment — the gateway subprocess runs with
|
|
8
9
|
* HARA_GATEWAY=<platform>, the cron runner with HARA_CRON=1 (+ HARA_CRON_NAME=<job name>). */
|
|
9
10
|
export function sessionSourceFromEnv() {
|
|
@@ -345,12 +346,39 @@ function redactedSessionCopy(data) {
|
|
|
345
346
|
safe.meta.archived = data.meta.archived;
|
|
346
347
|
if (data.meta.gatewayOwner !== undefined)
|
|
347
348
|
safe.meta.gatewayOwner = data.meta.gatewayOwner;
|
|
349
|
+
if (data.task && safe.task) {
|
|
350
|
+
// Task objective/steering are free-form and stay redacted. Execution identity and transition metadata
|
|
351
|
+
// are structural: preserve them exactly so resume/expectedTurnId validation cannot be corrupted by a
|
|
352
|
+
// credential-looking identifier.
|
|
353
|
+
safe.task.schemaVersion = data.task.schemaVersion;
|
|
354
|
+
safe.task.id = data.task.id;
|
|
355
|
+
safe.task.status = data.task.status;
|
|
356
|
+
safe.task.turnId = data.task.turnId;
|
|
357
|
+
safe.task.createdAt = data.task.createdAt;
|
|
358
|
+
safe.task.updatedAt = data.task.updatedAt;
|
|
359
|
+
safe.task.startedAt = data.task.startedAt;
|
|
360
|
+
if (data.task.endedAt !== undefined)
|
|
361
|
+
safe.task.endedAt = data.task.endedAt;
|
|
362
|
+
if (data.task.lastOutcome !== undefined)
|
|
363
|
+
safe.task.lastOutcome = data.task.lastOutcome;
|
|
364
|
+
if (data.task.steering && safe.task.steering) {
|
|
365
|
+
for (let index = 0; index < data.task.steering.length; index++) {
|
|
366
|
+
const source = data.task.steering[index];
|
|
367
|
+
const target = safe.task.steering[index];
|
|
368
|
+
if (!source || !target)
|
|
369
|
+
continue;
|
|
370
|
+
target.id = source.id;
|
|
371
|
+
target.turnId = source.turnId;
|
|
372
|
+
target.createdAt = source.createdAt;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
348
376
|
return safe;
|
|
349
377
|
}
|
|
350
|
-
export function saveSession(meta, history) {
|
|
378
|
+
export function saveSession(meta, history, task) {
|
|
351
379
|
checkedSessionId(meta.id);
|
|
352
380
|
meta.updatedAt = new Date().toISOString();
|
|
353
|
-
const safe = redactedSessionCopy({ meta, history });
|
|
381
|
+
const safe = redactedSessionCopy({ meta, history, ...(task ? { task } : {}) });
|
|
354
382
|
const target = sessionFile(meta.id);
|
|
355
383
|
const tmp = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
|
356
384
|
let fd;
|
|
@@ -452,7 +480,8 @@ function isSessionMeta(value) {
|
|
|
452
480
|
function isSessionData(d) {
|
|
453
481
|
const o = d;
|
|
454
482
|
return !!o && typeof o === "object" && !Array.isArray(o) && isSessionMeta(o.meta) &&
|
|
455
|
-
Array.isArray(o.history) && o.history.every(isNeutralMessage)
|
|
483
|
+
Array.isArray(o.history) && o.history.every(isNeutralMessage) &&
|
|
484
|
+
(o.task === undefined || isTaskExecution(o.task));
|
|
456
485
|
}
|
|
457
486
|
/** Read only. Legacy plaintext is redacted in the returned in-memory copy but intentionally not migrated
|
|
458
487
|
* here: listing/resuming must not perform an unlocked write. The next explicit save atomically migrates it. */
|