@nanhara/hara 0.129.0 → 0.130.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,30 @@ 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.1 — 2026-07-21 — Windows private-state portability
9
+
10
+ - `hara serve` no longer calls the inapplicable POSIX `fchmod` operation on Windows discovery
11
+ directory and file handles. Official Windows standalone builds can now create the authenticated
12
+ `serve.json` record instead of exiting with `EPERM`.
13
+ - The same descriptor-mode boundary is shared by all private-state readers and writers: Windows
14
+ retains file type, identity, no-replace, atomic-write, and symlink/reparse-point checks while
15
+ omitting only POSIX owner bits; macOS and Linux still fail closed if permission tightening fails.
16
+ - Upgrade with `npm i -g @nanhara/hara@0.130.1`.
17
+
18
+ ## 0.130.0 — 2026-07-20 — ordered task-state delivery
19
+
20
+ - Typed `event.task_state` notifications now carry a server-stream identity and a positive,
21
+ monotonically increasing sequence. Desktop and other authenticated Serve clients can reject
22
+ duplicated or stale lifecycle transitions instead of letting a late event overwrite the current
23
+ task, approval, checkpoint, or completion state.
24
+ - Ordering spans every session and every resume within one `hara serve` process. A server restart
25
+ creates a new stream identity, so clients can accept the fresh sequence without confusing it with
26
+ an older connection.
27
+ - Conversation streaming remains independent from execution state, and task event payloads retain
28
+ the same redacted ambient-data boundary. Existing protocol-v1 clients remain compatible because
29
+ the ordering fields are additive.
30
+ - Upgrade with `npm i -g @nanhara/hara@0.130.0`.
31
+
8
32
  ## 0.129.0 — 2026-07-20 — durable revisions and workspace recovery
9
33
 
10
34
  - Authenticated Serve clients can commit a user-selected file as a new immutable Artifact revision with
@@ -0,0 +1,14 @@
1
+ import { fchmodSync } from "node:fs";
2
+ /**
3
+ * Tighten an already-verified descriptor on platforms that implement POSIX ownership modes.
4
+ *
5
+ * Windows Node/Bun may expose `fchmodSync`, but the underlying handle can reject it with EPERM and
6
+ * Windows ACLs do not implement these POSIX owner bits. Callers must retain their type, identity,
7
+ * no-replace, and atomic-write checks; only the inapplicable mode operation is omitted on Windows.
8
+ * POSIX errors deliberately propagate so a failed private-state repair remains fail-closed.
9
+ */
10
+ export function tightenPrivateDescriptorMode(fd, mode, platform = process.platform, writeMode = fchmodSync) {
11
+ if (platform === "win32")
12
+ return;
13
+ writeMode(fd, mode);
14
+ }
@@ -1,12 +1,13 @@
1
1
  // Owner-only migration for Hara's local control plane. New writers should still create private files
2
2
  // directly, but this repairs installations created by older releases and makes ~/.hara non-traversable by
3
3
  // other local users before credentials/session state are read.
4
- import { closeSync, chmodSync, constants, fchmodSync, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readSync, readdirSync, realpathSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
4
+ import { closeSync, chmodSync, constants, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readSync, readdirSync, realpathSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import { homedir } from "node:os";
7
7
  import { basename, dirname, join, resolve } from "node:path";
8
8
  import { FileReadLimitError, MAX_EDIT_READ_BYTES, decodeUtf8Strict, openVerifiedRegularFileNoFollow, verifyOpenedRegularFileSync, } from "../fs-read.js";
9
9
  import { optionalPosixOpenFlag } from "../fs-open-flags.js";
10
+ import { tightenPrivateDescriptorMode } from "../fs-permissions.js";
10
11
  import { sameOpenedFileIdentity } from "../fs-identity.js";
11
12
  const PRIVATE_TREES = new Set(["sessions", "checkpoints", "index", "gateway", "cron", "weixin", "tool-results", "plugin-receipts", "artifacts"]);
12
13
  const tightenedHomes = new Set();
@@ -81,7 +82,7 @@ function verifyAndTightenPrivateDirectory(path) {
81
82
  if (!opened.isDirectory() || opened.dev !== inspected.dev || opened.ino !== inspected.ino) {
82
83
  throw new Error(`private Hara state directory changed while opening: '${path}'`);
83
84
  }
84
- fchmodSync(fd, 0o700);
85
+ tightenPrivateDescriptorMode(fd, 0o700);
85
86
  }
86
87
  finally {
87
88
  closeSync(fd);
@@ -190,13 +191,7 @@ export function readPrivateStateFileSnapshotSync(path, maxBytes = MAX_EDIT_READ_
190
191
  rejectHardLinks: true,
191
192
  protectSensitive: false,
192
193
  });
193
- try {
194
- fchmodSync(fd, 0o600);
195
- }
196
- catch (error) {
197
- if (process.platform !== "win32")
198
- throw error;
199
- }
194
+ tightenPrivateDescriptorMode(fd, 0o600);
200
195
  // chmod may update ctime; capture the authoritative baseline afterwards.
201
196
  info = fstatSync(fd);
202
197
  if (info.size > limit)
@@ -334,13 +329,7 @@ export function writePrivateStateBytesOnceSync(binding, bytes) {
334
329
  try {
335
330
  fd = openSync(temp, "wx", 0o600);
336
331
  writeFileSync(fd, bytes);
337
- try {
338
- fchmodSync(fd, 0o600);
339
- }
340
- catch (error) {
341
- if (process.platform !== "win32")
342
- throw error;
343
- }
332
+ tightenPrivateDescriptorMode(fd, 0o600);
344
333
  fsyncSync(fd);
345
334
  const stagedInfo = fstatSync(fd);
346
335
  if (!stagedInfo.isFile() || stagedInfo.nlink !== 1) {
@@ -422,13 +411,7 @@ export function writePrivateStateFileSync(binding, text, options = {}) {
422
411
  // Bun's Windows numeric-open compatibility can otherwise turn this valid create into a false ENOENT.
423
412
  fd = openSync(temp, "wx", 0o600);
424
413
  writeFileSync(fd, text, "utf8");
425
- try {
426
- fchmodSync(fd, 0o600);
427
- }
428
- catch (error) {
429
- if (process.platform !== "win32")
430
- throw error;
431
- }
414
+ tightenPrivateDescriptorMode(fd, 0o600);
432
415
  fsyncSync(fd);
433
416
  const stagedInfo = fstatSync(fd);
434
417
  if (!stagedInfo.isFile() || stagedInfo.nlink !== 1)
@@ -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). */
@@ -5,7 +5,7 @@
5
5
  // (no import cycle back into the CLI entry).
6
6
  import { WebSocketServer } from "ws";
7
7
  import { randomBytes, randomUUID, timingSafeEqual, createHash } from "node:crypto";
8
- import { closeSync, constants as fsConstants, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmdirSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
8
+ import { closeSync, constants as fsConstants, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmdirSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
9
9
  import { homedir } from "node:os";
10
10
  import { isAbsolute, join, relative, sep } from "node:path";
11
11
  import "../tools/all.js"; // register the full built-in toolset — serve must work as a standalone entry
@@ -32,9 +32,10 @@ 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
+ import { tightenPrivateDescriptorMode } from "../fs-permissions.js";
38
39
  import { sameOpenedFileIdentity } from "../fs-identity.js";
39
40
  import { redactSensitiveText, redactSensitiveValue } from "../security/secrets.js";
40
41
  import { ArtifactStoreError, commitArtifact, getArtifact, importArtifact, listArtifactRevisions, listArtifacts, revertArtifact, } from "../artifacts/store.js";
@@ -81,7 +82,7 @@ const ensurePrivateDiscoveryDir = (dir) => {
81
82
  throw new Error(`${dir} must be a private directory, not a symlink`);
82
83
  // mkdir's mode does not affect a legacy directory. Operate through the verified descriptor so a path
83
84
  // replacement cannot redirect chmod to a symlink target between validation and permission tightening.
84
- fchmodSync(fd, 0o700);
85
+ tightenPrivateDescriptorMode(fd, 0o700);
85
86
  }
86
87
  finally {
87
88
  if (fd !== undefined)
@@ -173,7 +174,7 @@ const writeDiscovery = async (dir, path, record) => {
173
174
  let fd;
174
175
  try {
175
176
  fd = openSync(temp, "wx", 0o600);
176
- fchmodSync(fd, 0o600);
177
+ tightenPrivateDescriptorMode(fd, 0o600);
177
178
  writeFileSync(fd, `${JSON.stringify(record, null, 2)}\n`, "utf8");
178
179
  fsyncSync(fd);
179
180
  closeSync(fd);
@@ -295,6 +296,7 @@ export async function startServe(opts, deps) {
295
296
  // Physical provider/tool work can outlive its logical timeout. Keep a process-level ledger independent
296
297
  // of SessionHub membership so detach/delete cannot make an updater believe the old engine is quiescent.
297
298
  const activeOperations = new Set();
299
+ let taskEventSequence = 0;
298
300
  let closing = false;
299
301
  let closePromise = null;
300
302
  const trackActiveOperation = (operation) => {
@@ -358,10 +360,23 @@ export async function startServe(opts, deps) {
358
360
  if (ws.readyState === ws.OPEN)
359
361
  ws.send(frame);
360
362
  };
363
+ const nextTaskEventCursor = () => {
364
+ const sequence = taskEventSequence + 1;
365
+ if (!Number.isSafeInteger(sequence)) {
366
+ throw new Error("task lifecycle event sequence exhausted");
367
+ }
368
+ return { streamId: instanceId, sequence };
369
+ };
370
+ const publishTaskState = (event) => {
371
+ // Commit the cursor immediately before the synchronous broadcast. Dedupe paths never consume one,
372
+ // while every published event has a unique position in this server-wide stream.
373
+ taskEventSequence = event.sequence;
374
+ broadcast("event.task_state", { ...event });
375
+ };
361
376
  const broadcastTaskState = (session, activity) => {
362
377
  if (!session.task)
363
378
  return;
364
- broadcast("event.task_state", { ...taskLifecycleEvent(session.meta.id, session.task, session.meta.todos ?? [], activity) });
379
+ publishTaskState(taskLifecycleEvent(session.meta.id, session.task, session.meta.todos ?? [], activity, nextTaskEventCursor()));
365
380
  };
366
381
  // Discovery file — the desktop shell reads this to find the running server (like a pid/port file).
367
382
  const discoveryDir = join(deps.discoveryHome ?? homedir(), ".hara");
@@ -448,13 +463,13 @@ export async function startServe(opts, deps) {
448
463
  const emitTaskState = (activity, todos = s.meta.todos ?? []) => {
449
464
  if (!s.task)
450
465
  return;
451
- const event = taskLifecycleEvent(sessionId, s.task, todos, activity);
452
- const { at: _at, ...stableEvent } = event;
466
+ const event = taskLifecycleEvent(sessionId, s.task, todos, activity, nextTaskEventCursor());
467
+ const { at: _at, streamId: _streamId, sequence: _sequence, ...stableEvent } = event;
453
468
  const signature = JSON.stringify(stableEvent);
454
469
  if (signature === lastTaskStateSignature)
455
470
  return;
456
471
  lastTaskStateSignature = signature;
457
- broadcast("event.task_state", { ...event });
472
+ publishTaskState(event);
458
473
  };
459
474
  broadcast("event.turn_start", { sessionId, taskId: s.task.id, turnId: s.task.turnId });
460
475
  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.1",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "runtime-bootstrap.cjs"