@nanhara/hara 0.129.0 → 0.130.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,20 @@ 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.130.0 — 2026-07-20 — ordered task-state delivery
9
+
10
+ - Typed `event.task_state` notifications now carry a server-stream identity and a positive,
11
+ monotonically increasing sequence. Desktop and other authenticated Serve clients can reject
12
+ duplicated or stale lifecycle transitions instead of letting a late event overwrite the current
13
+ task, approval, checkpoint, or completion state.
14
+ - Ordering spans every session and every resume within one `hara serve` process. A server restart
15
+ creates a new stream identity, so clients can accept the fresh sequence without confusing it with
16
+ an older connection.
17
+ - Conversation streaming remains independent from execution state, and task event payloads retain
18
+ the same redacted ambient-data boundary. Existing protocol-v1 clients remain compatible because
19
+ the ordering fields are additive.
20
+ - Upgrade with `npm i -g @nanhara/hara@0.130.0`.
21
+
8
22
  ## 0.129.0 — 2026-07-20 — durable revisions and workspace recovery
9
23
 
10
24
  - Authenticated Serve clients can commit a user-selected file as a new immutable Artifact revision with
@@ -42,7 +42,7 @@
42
42
  // Server → client notifications (all carry sessionId):
43
43
  // event.text / event.reasoning {delta} · event.tool {name,preview} · event.diff {text}
44
44
  // event.notice {text} · event.turn_end {reply,usage,error?} · approval.request {approvalId,question}
45
- // event.task_state {version,taskId,turnId,objective,state,taskStatus,phase,checkpoint,…}
45
+ // event.task_state {version,streamId,sequence,taskId,turnId,objective,state,taskStatus,phase,checkpoint,…}
46
46
  // authoritative execution plane; clients feature-detect it via capabilities.events
47
47
  export const PROTOCOL_VERSION = 1;
48
48
  /** JSON-RPC error codes: standard ones plus hara-specific (-320xx). */
@@ -32,7 +32,7 @@ import { disposeTodoScope, onTodosChange, restoreTodos, serializeTodos } from ".
32
32
  import { INTERJECT_PREFIX, disposeReminderScope } from "../agent/reminders.js";
33
33
  import { SessionHub, realStore } from "./sessions.js";
34
34
  import { parseFrame, rpcResult, rpcError, rpcNotify, ERR, PROTOCOL_VERSION } from "./protocol.js";
35
- import { taskLifecycleEvent } from "./task-events.js";
35
+ import { taskLifecycleEvent, } from "./task-events.js";
36
36
  import { readModelContextFileSync } from "../fs-read.js";
37
37
  import { optionalPosixOpenFlag } from "../fs-open-flags.js";
38
38
  import { sameOpenedFileIdentity } from "../fs-identity.js";
@@ -295,6 +295,7 @@ export async function startServe(opts, deps) {
295
295
  // Physical provider/tool work can outlive its logical timeout. Keep a process-level ledger independent
296
296
  // of SessionHub membership so detach/delete cannot make an updater believe the old engine is quiescent.
297
297
  const activeOperations = new Set();
298
+ let taskEventSequence = 0;
298
299
  let closing = false;
299
300
  let closePromise = null;
300
301
  const trackActiveOperation = (operation) => {
@@ -358,10 +359,23 @@ export async function startServe(opts, deps) {
358
359
  if (ws.readyState === ws.OPEN)
359
360
  ws.send(frame);
360
361
  };
362
+ const nextTaskEventCursor = () => {
363
+ const sequence = taskEventSequence + 1;
364
+ if (!Number.isSafeInteger(sequence)) {
365
+ throw new Error("task lifecycle event sequence exhausted");
366
+ }
367
+ return { streamId: instanceId, sequence };
368
+ };
369
+ const publishTaskState = (event) => {
370
+ // Commit the cursor immediately before the synchronous broadcast. Dedupe paths never consume one,
371
+ // while every published event has a unique position in this server-wide stream.
372
+ taskEventSequence = event.sequence;
373
+ broadcast("event.task_state", { ...event });
374
+ };
361
375
  const broadcastTaskState = (session, activity) => {
362
376
  if (!session.task)
363
377
  return;
364
- broadcast("event.task_state", { ...taskLifecycleEvent(session.meta.id, session.task, session.meta.todos ?? [], activity) });
378
+ publishTaskState(taskLifecycleEvent(session.meta.id, session.task, session.meta.todos ?? [], activity, nextTaskEventCursor()));
365
379
  };
366
380
  // Discovery file — the desktop shell reads this to find the running server (like a pid/port file).
367
381
  const discoveryDir = join(deps.discoveryHome ?? homedir(), ".hara");
@@ -448,13 +462,13 @@ export async function startServe(opts, deps) {
448
462
  const emitTaskState = (activity, todos = s.meta.todos ?? []) => {
449
463
  if (!s.task)
450
464
  return;
451
- const event = taskLifecycleEvent(sessionId, s.task, todos, activity);
452
- const { at: _at, ...stableEvent } = event;
465
+ const event = taskLifecycleEvent(sessionId, s.task, todos, activity, nextTaskEventCursor());
466
+ const { at: _at, streamId: _streamId, sequence: _sequence, ...stableEvent } = event;
453
467
  const signature = JSON.stringify(stableEvent);
454
468
  if (signature === lastTaskStateSignature)
455
469
  return;
456
470
  lastTaskStateSignature = signature;
457
- broadcast("event.task_state", { ...event });
471
+ publishTaskState(event);
458
472
  };
459
473
  broadcast("event.turn_start", { sessionId, taskId: s.task.id, turnId: s.task.turnId });
460
474
  emitTaskState({ state: "running", phase: "starting" });
@@ -5,7 +5,14 @@ function bounded(value, max) {
5
5
  }
6
6
  /** Build the single task-state shape consumed by Desktop, IDE clients, and the companion. Conversation
7
7
  * streaming remains separate; this event is the authoritative execution/status plane. */
8
- export function taskLifecycleEvent(sessionId, task, todos = [], activity, at = new Date().toISOString()) {
8
+ export function taskLifecycleEvent(sessionId, task, todos = [], activity, cursor, at = new Date().toISOString()) {
9
+ const streamId = cursor.streamId.trim();
10
+ if (!streamId || streamId.length > 128) {
11
+ throw new Error("task lifecycle streamId must contain 1-128 characters");
12
+ }
13
+ if (!Number.isSafeInteger(cursor.sequence) || cursor.sequence <= 0) {
14
+ throw new Error("task lifecycle sequence must be a positive safe integer");
15
+ }
9
16
  const current = todos.find((todo) => todo.status === "in_progress")
10
17
  ?? todos.find((todo) => todo.status === "pending");
11
18
  const detail = bounded(activity.detail, 500);
@@ -22,6 +29,8 @@ export function taskLifecycleEvent(sessionId, task, todos = [], activity, at = n
22
29
  : task.status;
23
30
  return {
24
31
  version: TASK_LIFECYCLE_EVENT_VERSION,
32
+ streamId,
33
+ sequence: cursor.sequence,
25
34
  sessionId,
26
35
  taskId: task.id,
27
36
  turnId: task.turnId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.129.0",
3
+ "version": "0.130.0",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "runtime-bootstrap.cjs"