@nanhara/hara 0.126.1 → 0.127.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 CHANGED
@@ -5,6 +5,24 @@ All notable changes to `@nanhara/hara`.
5
5
  > Versioning (pre-1.0, SemVer-style): the **minor** (middle) number bumps for a **new feature**; the
6
6
  > **patch** (last) number bumps for **optimizations/fixes of existing features**.
7
7
 
8
+ ## 0.127.0 — 2026-07-20 — structured context and typed task lifecycle
9
+
10
+ - **Prompt assembly is now an explicit, ordered runtime layer.** Cache-stable policy, tool, project, and
11
+ session context are separated from turn-specific execution state and memory digests, deduplicated, and
12
+ assembled in a deterministic order. Anthropic requests preserve a stable cache prefix while other
13
+ providers continue to receive the same complete system contract.
14
+ - **Conversation and execution state now travel on separate protocol planes.** Authenticated Serve clients
15
+ can negotiate the versioned `event.task_state` stream for running, waiting, paused, completed, and blocked
16
+ work, with stable task/turn identities, phases, accepted task briefs, checkpoints, approvals, outcomes,
17
+ restore snapshots, and explicit stop/finish transitions.
18
+ - **Mid-turn input uses expected-turn steering.** `session.steer` durably accepts an update only for the
19
+ intended live turn, so Desktop and other clients can distinguish a real refinement from the next queued
20
+ task without parsing model prose or losing an end-of-turn race.
21
+ - Ambient lifecycle events never include command or path previews, and resumed client history removes the
22
+ internal steering-triage wrapper before display. Detailed tool output remains confined to the explicit
23
+ conversation surface.
24
+ - Upgrade with `npm i -g @nanhara/hara@0.127.0`.
25
+
8
26
  ## 0.126.1 — 2026-07-19 — verified plugin packages and ownership-safe lifecycle
9
27
 
10
28
  - **Plugin packages now fail closed at staging.** Hara validates a bounded manifest schema, portable IDs and
@@ -24,6 +24,7 @@ import { prepareHistoryForModel } from "./context-budget.js";
24
24
  import { rolesDigest } from "../org/roles.js";
25
25
  import { applyTaskBrief } from "../session/task.js";
26
26
  import { askUserTool } from "../tools/ask_user.js";
27
+ import { PromptAssembler } from "./prompt.js";
27
28
  /** File tools whose `path` input marks the file as "recently worked with" (post-compaction restore). */
28
29
  const FILE_TOUCH_TOOLS = new Set(["read_file", "edit_file", "write_file"]);
29
30
  /** Engine-owned, non-authority helpers. Role filters still govern every deferred target activated by
@@ -61,8 +62,7 @@ export function needsConfirm(kind, mode) {
61
62
  return kind === "exec";
62
63
  return true; // suggest: confirm edits and exec
63
64
  }
64
- const HARA_SYSTEM = (cwd) => `You are hara, a coding agent running in the user's terminal.
65
- Working directory: ${cwd}
65
+ const HARA_SYSTEM = () => `You are hara, a coding agent running in the user's terminal.
66
66
  Be concise and direct. Use the provided tools to read files, edit/write files, and run shell
67
67
  commands. Prefer small, verifiable steps; edit existing files with edit_file rather than rewriting
68
68
  them whole. Batch INDEPENDENT tool calls in a single response — especially reads (read_file / grep /
@@ -153,8 +153,9 @@ const CONTINUATION_SYSTEM = "# Existing-session continuity\n" +
153
153
  "re-inventory the workspace, or summarize files merely to understand what happened before. Follow the latest user request " +
154
154
  "and reuse prior conclusions and tool results. Inspect files only when the latest request requires it. If the working " +
155
155
  "directory is Home, ask the user to start Hara from a concrete project instead of enumerating Home or its children.";
156
- function composeSystem(cwd, projectContext, override, memory, continuationSession = false, executionContext, intake) {
157
- const head = override ? `${override}\n\nWorking directory: ${cwd}` : HARA_SYSTEM(cwd);
156
+ export function composeSystem(cwd, projectContext, override, memory, continuationSession = false, executionContext, intake) {
157
+ const assembler = new PromptAssembler();
158
+ assembler.add("core", "static", "core", override || HARA_SYSTEM());
158
159
  const skills = skillsDigest(cwd);
159
160
  const roles = override ? "" : rolesDigest(cwd);
160
161
  const intakeContext = !intake?.enabled
@@ -176,15 +177,17 @@ function composeSystem(cwd, projectContext, override, memory, continuationSessio
176
177
  "the interpreted goal, intent, constraints, acceptance checks, and short steps. Use intent `answer` " +
177
178
  "for a direct answer, `investigate` for evidence gathering/diagnosis, and `change` when the user asked " +
178
179
  "you to modify or deliver something. Do not claim completion until the acceptance checks are verified.");
179
- return (head +
180
- gatewayNote() +
181
- (continuationSession ? `\n\n${CONTINUATION_SYSTEM}` : "") +
182
- (executionContext ? `\n\n${executionContext}` : "") +
183
- intakeContext +
184
- (projectContext ? `\n\n# Project context (AGENTS.md)\n${projectContext}` : "") +
185
- (memory ? `\n\n# Memory (durable — facts/decisions/prefs you've saved; use memory_search/get for more)\n${memory}` : "") +
186
- (roles ? `\n\n# Specialist roles (metadata only — use \`agent\` with a role id for bounded read-only expertise)\n${roles}` : "") +
187
- (skills ? `\n\n# Skills (capabilities you can load — call the \`skill\` tool with the id for full instructions before using one)\n${skills}` : ""));
180
+ assembler
181
+ .add("working-directory", "session", "runtime", `Working directory: ${cwd}`)
182
+ .add("gateway-channel", "session", "channel", gatewayNote())
183
+ .add("continuation", "session", "task", continuationSession ? CONTINUATION_SYSTEM : "")
184
+ .add("execution", "session", "task", executionContext)
185
+ .add("project", "session", "project", projectContext ? `# Project context (AGENTS.md)\n${projectContext}` : "")
186
+ .add("memory", "session", "memory", memory ? `# Memory (durable — facts/decisions/prefs you've saved; use memory_search/get for more)\n${memory}` : "")
187
+ .add("roles", "session", "role", roles ? `# Specialist roles (metadata only — use \`agent\` with a role id for bounded read-only expertise)\n${roles}` : "")
188
+ .add("skills", "session", "skill", skills ? `# Skills (capabilities you can load — call the \`skill\` tool with the id for full instructions before using one)\n${skills}` : "")
189
+ .add("task-intake", "turn", "task", intakeContext);
190
+ return assembler.build();
188
191
  }
189
192
  const RUN_STOPPED = Symbol("agent-run-stopped");
190
193
  const REPEATED_FAILURE_LIMIT = 3;
@@ -618,7 +621,8 @@ async function runAgentInner(history, opts, life) {
618
621
  ? [...baseSpecs, ...runExtraTools.map((t) => ({ name: t.name, description: t.description, input_schema: t.input_schema }))]
619
622
  : baseSpecs;
620
623
  const sink = ctx.ui; // TUI mode: route output to ink instead of stdout
621
- const system = composeSystem(ctx.cwd, opts.projectContext, opts.systemOverride, opts.memory, opts.continuationSession, opts.executionContext, { enabled: !!opts.taskIntake, brief: intakeTask?.brief });
624
+ const assembledSystem = composeSystem(ctx.cwd, opts.projectContext, opts.systemOverride, opts.memory, opts.continuationSession, opts.executionContext, { enabled: !!opts.taskIntake, brief: intakeTask?.brief });
625
+ const system = assembledSystem.text;
622
626
  const prepared = prepareHistoryForModel(history, {
623
627
  model: activeProvider.model,
624
628
  system,
@@ -714,6 +718,7 @@ async function runAgentInner(history, opts, life) {
714
718
  }
715
719
  return activeProvider.turn({
716
720
  system,
721
+ systemParts: assembledSystem.parts,
717
722
  history: prepared.history,
718
723
  tools: specs,
719
724
  // Any stream chunk keeps the connection considered alive — even suppressed reasoning_content, so a
@@ -0,0 +1,48 @@
1
+ import { createHash } from "node:crypto";
2
+ const STABILITY_ORDER = {
3
+ static: 0,
4
+ session: 1,
5
+ turn: 2,
6
+ };
7
+ function promptDigest(content) {
8
+ return createHash("sha256").update(content).digest("hex").slice(0, 16);
9
+ }
10
+ /** Assemble the model's runtime context as a governed object instead of an ever-growing string.
11
+ *
12
+ * Ordering is intentionally monotonic. If a caller tries to place stable material after a dynamic task
13
+ * suffix, fail during assembly instead of silently destroying provider prefix-cache locality. */
14
+ export class PromptAssembler {
15
+ parts = [];
16
+ ids = new Set();
17
+ lastStability = "static";
18
+ add(id, stability, source, content) {
19
+ const normalizedId = id.trim();
20
+ const normalizedContent = content?.trim();
21
+ if (!normalizedContent)
22
+ return this;
23
+ if (!normalizedId)
24
+ throw new Error("system prompt part id must not be empty");
25
+ if (this.ids.has(normalizedId))
26
+ throw new Error(`duplicate system prompt part id: ${normalizedId}`);
27
+ if (this.parts.length && STABILITY_ORDER[stability] < STABILITY_ORDER[this.lastStability]) {
28
+ throw new Error(`system prompt part '${normalizedId}' (${stability}) cannot follow ${this.lastStability} context`);
29
+ }
30
+ this.ids.add(normalizedId);
31
+ this.lastStability = stability;
32
+ this.parts.push({
33
+ id: normalizedId,
34
+ stability,
35
+ source,
36
+ content: normalizedContent,
37
+ digest: promptDigest(normalizedContent),
38
+ });
39
+ return this;
40
+ }
41
+ build() {
42
+ const parts = this.parts.map((part) => ({ ...part }));
43
+ return {
44
+ text: parts.map((part) => part.content).join("\n\n"),
45
+ parts,
46
+ };
47
+ }
48
+ }
@@ -56,13 +56,14 @@ const CACHE = { type: "ephemeral" };
56
56
  * conversation prefix FROM CACHE instead of re-billing + re-processing the whole prompt — the single
57
57
  * biggest latency + cost win as history grows (uncached, a long session pays full input every turn).
58
58
  *
59
- * Anthropic caps breakpoints at 4 and orders the cache prefix `tools → system → messages`, so a mark
60
- * on `system` already caches tools+system (the big static block) in one shot. We then spend up to two
61
- * rolling marks near the message tail: the last message (so THIS turn's full prefix is written) and one
62
- * ~2 back (so the NEXT turn which appends new messages still finds a long cached prefix to read).
59
+ * Anthropic caps breakpoints at 4 and orders the cache prefix `tools → system → messages`. Structured Hara
60
+ * prompts spend up to two marks on the static and session boundaries, leaving the changing turn suffix
61
+ * uncached; legacy strings keep one system mark. We then spend up to two rolling marks near the message
62
+ * tail: the last message (so THIS turn's full prefix is written) and one ~2 back (so the NEXT turn which
63
+ * appends new messages — still finds a long cached prefix to read).
63
64
  * Mutates `messages` in place (toAnthropic hands us a fresh array each turn); returns the request-shaped
64
65
  * system. Exported pure for tests. */
65
- export function applyCacheControl(system, messages) {
66
+ export function applyCacheControl(system, messages, systemParts) {
66
67
  const mark = (m) => {
67
68
  if (typeof m.content === "string") {
68
69
  if (m.content.length)
@@ -80,7 +81,29 @@ export function applyCacheControl(system, messages) {
80
81
  for (const i of idxs)
81
82
  mark(messages[i]);
82
83
  // Only cache system when it's substantial enough to matter (an empty text block would 400).
83
- const cachedSystem = system ? [{ type: "text", text: system, cache_control: CACHE }] : system;
84
+ let cachedSystem = system;
85
+ const renderedParts = systemParts?.map((part) => part.content).join("\n\n");
86
+ if (system && systemParts?.length && renderedParts === system) {
87
+ const sections = [];
88
+ for (const part of systemParts) {
89
+ const tail = sections.at(-1);
90
+ if (tail?.stability === part.stability)
91
+ tail.text += `\n\n${part.content}`;
92
+ else
93
+ sections.push({ stability: part.stability, text: part.content });
94
+ }
95
+ cachedSystem = sections.map((section, index) => ({
96
+ type: "text",
97
+ // Anthropic concatenates adjacent text blocks directly. Preserve the authoritative rendered string
98
+ // byte-for-byte across our cache boundaries.
99
+ text: section.text + (index < sections.length - 1 ? "\n\n" : ""),
100
+ ...(section.stability === "turn" ? {} : { cache_control: CACHE }),
101
+ }));
102
+ }
103
+ else if (system) {
104
+ // Legacy/custom callers still receive the original single cached system block.
105
+ cachedSystem = [{ type: "text", text: system, cache_control: CACHE }];
106
+ }
84
107
  return { system: cachedSystem, messages };
85
108
  }
86
109
  /** Anthropic models whose only valid `thinking` setting is `{type: "adaptive"}` — they reject any
@@ -116,9 +139,9 @@ export function createAnthropicProvider(opts) {
116
139
  return {
117
140
  id: "anthropic",
118
141
  model: opts.model,
119
- async turn({ system, history, tools, onText, onReasoning, signal }) {
142
+ async turn({ system, systemParts, history, tools, onText, onReasoning, signal }) {
120
143
  const thinking = buildThinkingParam(opts.model, opts.reasoningEffort);
121
- const { system: cachedSystem, messages } = applyCacheControl(system, toAnthropic(history));
144
+ const { system: cachedSystem, messages } = applyCacheControl(system, toAnthropic(history), systemParts);
122
145
  const stream = client.messages.stream({
123
146
  model: opts.model,
124
147
  max_tokens: 32000,
@@ -33,6 +33,8 @@
33
33
  // Server → client notifications (all carry sessionId):
34
34
  // event.text / event.reasoning {delta} · event.tool {name,preview} · event.diff {text}
35
35
  // event.notice {text} · event.turn_end {reply,usage,error?} · approval.request {approvalId,question}
36
+ // event.task_state {version,taskId,turnId,objective,state,taskStatus,phase,checkpoint,…}
37
+ // authoritative execution plane; clients feature-detect it via capabilities.events
36
38
  export const PROTOCOL_VERSION = 1;
37
39
  /** JSON-RPC error codes: standard ones plus hara-specific (-320xx). */
38
40
  export const ERR = {
@@ -27,10 +27,11 @@ import { loadJobs, addJob, removeJob, setEnabled } from "../cron/store.js";
27
27
  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
- import { disposeTodoScope, restoreTodos, serializeTodos } from "../tools/todo.js";
30
+ import { disposeTodoScope, onTodosChange, restoreTodos, serializeTodos } from "../tools/todo.js";
31
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
+ import { taskLifecycleEvent } from "./task-events.js";
34
35
  import { readModelContextFileSync } from "../fs-read.js";
35
36
  import { optionalPosixOpenFlag } from "../fs-open-flags.js";
36
37
  import { sameOpenedFileIdentity } from "../fs-identity.js";
@@ -228,8 +229,15 @@ export function lastAssistantText(history) {
228
229
  export function historyForClient(history) {
229
230
  const out = [];
230
231
  for (const m of history) {
231
- if (m.role === "user")
232
- out.push({ role: "user", text: m.content });
232
+ if (m.role === "user") {
233
+ const steeringPrefix = `${INTERJECT_PREFIX}\n\n`;
234
+ out.push({
235
+ role: "user",
236
+ text: m.content.startsWith(steeringPrefix)
237
+ ? m.content.slice(steeringPrefix.length)
238
+ : m.content,
239
+ });
240
+ }
233
241
  else if (m.role === "assistant" && m.text)
234
242
  out.push({ role: "assistant", text: m.text });
235
243
  // tool results are omitted — clients see live tool events; persisted detail stays in the store
@@ -333,6 +341,11 @@ export async function startServe(opts, deps) {
333
341
  if (ws.readyState === ws.OPEN)
334
342
  ws.send(frame);
335
343
  };
344
+ const broadcastTaskState = (session, activity) => {
345
+ if (!session.task)
346
+ return;
347
+ broadcast("event.task_state", { ...taskLifecycleEvent(session.meta.id, session.task, session.meta.todos ?? [], activity) });
348
+ };
336
349
  // Discovery file — the desktop shell reads this to find the running server (like a pid/port file).
337
350
  const discoveryDir = join(deps.discoveryHome ?? homedir(), ".hara");
338
351
  const discoveryPath = join(discoveryDir, "serve.json");
@@ -413,13 +426,37 @@ export async function startServe(opts, deps) {
413
426
  s.busy = false;
414
427
  throw error;
415
428
  }
429
+ let lastTaskStateSignature = "";
430
+ const emitTaskState = (activity, todos = s.meta.todos ?? []) => {
431
+ if (!s.task)
432
+ return;
433
+ const event = taskLifecycleEvent(sessionId, s.task, todos, activity);
434
+ const { at: _at, ...stableEvent } = event;
435
+ const signature = JSON.stringify(stableEvent);
436
+ if (signature === lastTaskStateSignature)
437
+ return;
438
+ lastTaskStateSignature = signature;
439
+ broadcast("event.task_state", { ...event });
440
+ };
416
441
  broadcast("event.turn_start", { sessionId, taskId: s.task.id, turnId: s.task.turnId });
442
+ emitTaskState({ state: "running", phase: "starting" });
417
443
  const historyStart = s.history.length;
418
444
  const before = { input: s.stats.input, output: s.stats.output };
419
445
  const sink = {
420
- text: (d) => broadcast("event.text", { sessionId, delta: d }),
421
- reasoning: (d) => broadcast("event.reasoning", { sessionId, delta: d }),
422
- tool: (name, preview) => broadcast("event.tool", { sessionId, name, preview }),
446
+ text: (d) => {
447
+ emitTaskState({ state: "running", phase: "responding" });
448
+ broadcast("event.text", { sessionId, delta: d });
449
+ },
450
+ reasoning: (d) => {
451
+ emitTaskState({ state: "running", phase: "thinking" });
452
+ broadcast("event.reasoning", { sessionId, delta: d });
453
+ },
454
+ tool: (name, preview) => {
455
+ // The task/status plane is safe for ambient surfaces such as an always-on-top companion.
456
+ // Command/path previews remain on the explicit event.tool transcript plane only.
457
+ emitTaskState({ state: "running", phase: "tool", detail: name });
458
+ broadcast("event.tool", { sessionId, name, preview });
459
+ },
423
460
  diff: (t) => broadcast("event.diff", { sessionId, text: t }),
424
461
  notice: (t) => broadcast("event.notice", { sessionId, text: t }),
425
462
  };
@@ -434,6 +471,13 @@ export async function startServe(opts, deps) {
434
471
  clearTimeout(timer);
435
472
  pendingApprovals.delete(approvalId);
436
473
  signal.removeEventListener("abort", onAbort);
474
+ if (!signal.aborted && s.task?.status === "running") {
475
+ emitTaskState({
476
+ state: "running",
477
+ phase: "thinking",
478
+ detail: v === false ? "Approval denied; continuing safely" : "Approval granted; continuing",
479
+ });
480
+ }
437
481
  resolve(v);
438
482
  };
439
483
  const onAbort = () => finish(false);
@@ -445,15 +489,28 @@ export async function startServe(opts, deps) {
445
489
  // `signal` composes the owning turn cancellation with runAgent's lifecycle cancellation. Listening
446
490
  // only to turnAbort would leave the approval map and Desktop prompt stale after an internal stop.
447
491
  signal.addEventListener("abort", onAbort, { once: true });
492
+ emitTaskState({
493
+ state: "waiting",
494
+ phase: "approval",
495
+ detail: q,
496
+ approval: { id: approvalId, question: q },
497
+ });
448
498
  broadcast("approval.request", { sessionId, approvalId, question: q });
449
499
  }
450
500
  });
501
+ let stopTodoEvents = () => { };
451
502
  try {
452
503
  if (!(await refreshSessionProvider(s))) {
453
504
  throw new Error(`provider not authenticated for pinned model '${s.meta.model}' at ${s.meta.cwd}`);
454
505
  }
455
506
  const turnGuardian = deps.buildGuardian ? await deps.buildGuardian(s.meta.cwd) : deps.guardian;
456
507
  restoreTodos(s.meta.todos, sessionId);
508
+ stopTodoEvents = onTodosChange((todos) => {
509
+ // Keep the session snapshot current while the turn runs. Steering and task-intake checkpoints can
510
+ // then publish/persist the same checklist the model just wrote instead of regressing to turn-start.
511
+ s.meta.todos = serializeTodos(sessionId);
512
+ emitTaskState({ state: "running", phase: "checkpoint" }, s.meta.todos);
513
+ }, sessionId);
457
514
  // Slash skills, CLI parity: "/skill-id request…" expands into the skill-entering message, so a
458
515
  // desktop composer's "/" popup triggers the exact behavior the terminal gets. Unknown ids fall
459
516
  // through as plain text (the model sees what the user typed).
@@ -495,10 +552,12 @@ export async function startServe(opts, deps) {
495
552
  current: () => s.task,
496
553
  onUpdate: (next) => {
497
554
  s.task = next;
555
+ emitTaskState({ state: "running", phase: "checkpoint" }, serializeTodos(sessionId));
498
556
  },
499
557
  onCheckpoint: (next) => {
500
558
  s.task = next;
501
559
  hub.save(s);
560
+ emitTaskState({ state: "running", phase: "checkpoint" }, serializeTodos(sessionId));
502
561
  },
503
562
  },
504
563
  pendingInput: async () => {
@@ -521,6 +580,7 @@ export async function startServe(opts, deps) {
521
580
  s.meta.todos = serializeTodos(sessionId);
522
581
  s.task = finishTaskExecution(s.task, outcome, s.meta.todos, turnAbort.signal.aborted);
523
582
  hub.save(s);
583
+ emitTaskState({ phase: "finished" }, s.meta.todos);
524
584
  const usage = { input: s.stats.input - before.input, output: s.stats.output - before.output };
525
585
  // context watermark rides along with every turn end (codex thread/tokenUsage/updated pattern) —
526
586
  // clients render a meter without an extra round-trip.
@@ -544,10 +604,12 @@ export async function startServe(opts, deps) {
544
604
  if (s.task?.status === "running") {
545
605
  s.task = finishTaskExecution(s.task, { status: "error", error: error instanceof Error ? error.message : String(error) }, s.meta.todos ?? [], turnAbort.signal.aborted);
546
606
  hub.save(s);
607
+ emitTaskState({ phase: "finished" }, s.meta.todos ?? []);
547
608
  }
548
609
  throw error;
549
610
  }
550
611
  finally {
612
+ stopTodoEvents();
551
613
  s.abort = null;
552
614
  s.busy = s.pendingProviderTurns > 0 || s.pendingToolRuns > 0;
553
615
  }
@@ -682,7 +744,7 @@ export async function startServe(opts, deps) {
682
744
  provider: runtime.providerId,
683
745
  model: runtime.model,
684
746
  setupState,
685
- capabilities: { methods },
747
+ capabilities: { methods, events: ["event.task_state"] },
686
748
  }));
687
749
  }
688
750
  if (!authed.has(ws))
@@ -749,6 +811,7 @@ export async function startServe(opts, deps) {
749
811
  return reply(rpcError(id, ERR.INTERNAL, `provider not authenticated for pinned model '${r.session.meta.model}'`));
750
812
  }
751
813
  r.session.projectContext = loadAgentContext(r.session.meta.cwd) || undefined;
814
+ broadcastTaskState(r.session, { phase: "restored" });
752
815
  return reply(rpcResult(id, {
753
816
  sessionId: r.session.meta.id,
754
817
  model: r.session.meta.model,
@@ -791,12 +854,16 @@ export async function startServe(opts, deps) {
791
854
  return reply(rpcError(id, ERR.BUSY, recorded.reason));
792
855
  s.task = recorded.task;
793
856
  hub.save(s); // executable inbox entry is durable before ACK
857
+ broadcastTaskState(s, { state: "running", phase: "steering", detail: "Steering accepted" });
794
858
  return reply(rpcResult(id, { accepted: true, taskId: s.task.id, turnId: s.task.turnId }));
795
859
  }
796
860
  case "session.interrupt": {
797
861
  const s = typeof p.sessionId === "string" ? hub.get(p.sessionId) : undefined;
798
862
  if (!s)
799
863
  return reply(rpcError(id, ERR.NO_SESSION, "no such live session"));
864
+ if (s.abort && s.task?.status === "running") {
865
+ broadcastTaskState(s, { state: "running", phase: "stopping", detail: "Stopping at a safe boundary" });
866
+ }
800
867
  s.abort?.abort();
801
868
  return reply(rpcResult(id, {}));
802
869
  }
@@ -0,0 +1,45 @@
1
+ export const TASK_LIFECYCLE_EVENT_VERSION = 1;
2
+ function bounded(value, max) {
3
+ const normalized = value?.replace(/\s+/g, " ").trim();
4
+ return normalized ? normalized.slice(0, max) : undefined;
5
+ }
6
+ /** Build the single task-state shape consumed by Desktop, IDE clients, and the companion. Conversation
7
+ * streaming remains separate; this event is the authoritative execution/status plane. */
8
+ export function taskLifecycleEvent(sessionId, task, todos = [], activity, at = new Date().toISOString()) {
9
+ const current = todos.find((todo) => todo.status === "in_progress")
10
+ ?? todos.find((todo) => todo.status === "pending");
11
+ const detail = bounded(activity.detail, 500);
12
+ const approval = activity.approval
13
+ ? {
14
+ id: activity.approval.id,
15
+ question: bounded(activity.approval.question, 4_000) ?? "Approval required",
16
+ }
17
+ : undefined;
18
+ // Runtime phases may only refine an actively running task. Once durable state reaches a terminal or
19
+ // resumable boundary, a late notification cannot visually resurrect it as running/waiting.
20
+ const state = task.status === "running"
21
+ ? (activity.state ?? task.status)
22
+ : task.status;
23
+ return {
24
+ version: TASK_LIFECYCLE_EVENT_VERSION,
25
+ sessionId,
26
+ taskId: task.id,
27
+ turnId: task.turnId,
28
+ objective: task.objective,
29
+ state,
30
+ taskStatus: task.status,
31
+ phase: activity.phase,
32
+ at,
33
+ updatedAt: task.updatedAt,
34
+ ...(task.lastOutcome ? { lastOutcome: task.lastOutcome } : {}),
35
+ ...(task.brief ? { brief: { intent: task.brief.intent, goal: task.brief.goal } } : {}),
36
+ checkpoint: {
37
+ done: todos.filter((todo) => todo.status === "done").length,
38
+ total: todos.length,
39
+ ...(current ? { current: bounded(current.activeForm || current.text, 300) } : {}),
40
+ ...(current?.owner ? { owner: bounded(current.owner, 120) } : {}),
41
+ },
42
+ ...(detail ? { detail } : {}),
43
+ ...(approval ? { approval } : {}),
44
+ };
45
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.126.1",
3
+ "version": "0.127.0",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "runtime-bootstrap.cjs"