@nanhara/hara 0.122.7 → 0.123.1

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.
@@ -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) ? bundle.roles.filter((r) => r && r.name && r.system) : [];
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
- for (const r of roles)
158
- writeFileSync(join(dir, `${r.name}.md`), renderRoleMd(r), "utf8");
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
- const expanded = p.replace(/^~(?=\/|$)/, process.env.HOME ?? "~");
97
- const abs = isAbsolute(expanded) ? resolve(expanded) : resolve(cwd, expanded);
98
- const root = resolve(cwd);
99
- return abs !== root && !abs.startsWith(root + "/");
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
  }
@@ -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?} → (streams events, then) {reply,usage}
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?} → {}
@@ -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
- const outcome = await runAgent(s.history, {
365
- provider: s.provider,
366
- ctx: {
367
- cwd: s.meta.cwd,
368
- sandbox: deps.sandbox,
369
- todoScope: sessionId,
370
- spawn: (t, role, signal) => deps.spawnSubagent(s.provider, s.meta.cwd, s.projectContext, s.stats, t, role, signal),
371
- ui: sink,
372
- },
373
- approval: s.approval,
374
- confirm,
375
- autoApprove: s.autoApprove,
376
- projectContext: s.projectContext,
377
- memory: memoryDigest(s.meta.cwd),
378
- continuationSession: s.continuationSession,
379
- stats: s.stats,
380
- signal: turnAbort.signal,
381
- onProviderTurn: (turn) => {
382
- s.pendingProviderTurns += 1;
383
- const settled = () => {
384
- s.pendingProviderTurns = Math.max(0, s.pendingProviderTurns - 1);
385
- if (closing)
386
- hub.releaseIdle();
387
- };
388
- void turn.then(settled, settled);
389
- },
390
- onToolRun: (toolRun) => {
391
- s.pendingToolRuns += 1;
392
- const settled = () => {
393
- s.pendingToolRuns = Math.max(0, s.pendingToolRuns - 1);
394
- // `abort === null` means the logical turn already returned. Keep the session busy/locked until
395
- // every late side-effect-capable Promise has physically stopped.
396
- if (s.pendingToolRuns === 0 && s.abort === null)
397
- s.busy = false;
398
- if (closing)
399
- hub.releaseIdle();
400
- };
401
- void toolRun.then(settled, settled);
402
- },
403
- guardian: turnGuardian,
404
- ...(deps.runLimits?.(s.meta.cwd) ?? {}),
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, { sessionId: r.session.meta.id, model: r.session.meta.model, history: historyForClient(r.session.history) }));
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
  }
@@ -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 s = { meta: prior.meta, history: [...prior.history], 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
+ 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) {
@@ -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. */