@nanhara/hara 0.128.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.
@@ -12,6 +12,60 @@ const lark = (larkNs.default ?? larkNs);
12
12
  import { chunkText, outboundTransferTimeoutMs, PerChatOutboundLane, withOutboundDeadline, } from "./telegram.js";
13
13
  import { InboundMediaBudget, cleanupTransientMedia, savePrivateResponse } from "./media.js";
14
14
  import { GatewayEventSpool, gatewayRuntimeScope } from "./runtime-state.js";
15
+ import { redactKnownSecrets } from "../security/secrets.js";
16
+ const FEISHU_WS_HOUR_MS = 60 * 60_000;
17
+ const FEISHU_WS_DAY_MS = 24 * FEISHU_WS_HOUR_MS;
18
+ const FEISHU_WS_HOURLY_ALERT = 5;
19
+ const FEISHU_WS_DAILY_ALERT = 25;
20
+ /** Process-local WS lifecycle accounting. PM2/systemd retains the structured log across restarts; keeping
21
+ * raw connection history out of Hara state avoids another privacy-sensitive durable file. */
22
+ export class FeishuWsHealthMonitor {
23
+ now;
24
+ connectedAt;
25
+ disconnectedAt;
26
+ disconnects = [];
27
+ total = 0;
28
+ lastAlertAt = Number.NEGATIVE_INFINITY;
29
+ constructor(now = Date.now) {
30
+ this.now = now;
31
+ }
32
+ ready() {
33
+ this.connectedAt = this.now();
34
+ this.disconnectedAt = undefined;
35
+ }
36
+ disconnect() {
37
+ const at = this.now();
38
+ this.disconnects = this.disconnects.filter((value) => at - value <= FEISHU_WS_DAY_MS);
39
+ this.disconnects.push(at);
40
+ this.total += 1;
41
+ const hourCount = this.disconnects.filter((value) => at - value <= FEISHU_WS_HOUR_MS).length;
42
+ const dayCount = this.disconnects.length;
43
+ const connectedForMs = this.connectedAt === undefined ? undefined : Math.max(0, at - this.connectedAt);
44
+ this.connectedAt = undefined;
45
+ this.disconnectedAt = at;
46
+ const unhealthy = hourCount >= FEISHU_WS_HOURLY_ALERT || dayCount >= FEISHU_WS_DAILY_ALERT;
47
+ const alert = unhealthy && at - this.lastAlertAt >= FEISHU_WS_HOUR_MS;
48
+ if (alert)
49
+ this.lastAlertAt = at;
50
+ return { total: this.total, hourCount, dayCount, connectedForMs, alert };
51
+ }
52
+ reconnected() {
53
+ const at = this.now();
54
+ const disconnectedForMs = this.disconnectedAt === undefined ? undefined : Math.max(0, at - this.disconnectedAt);
55
+ this.connectedAt = at;
56
+ this.disconnectedAt = undefined;
57
+ return disconnectedForMs;
58
+ }
59
+ }
60
+ function wsDuration(ms) {
61
+ if (ms === undefined)
62
+ return "unknown";
63
+ if (ms < 60_000)
64
+ return `${Math.max(0, Math.round(ms / 1_000))}s`;
65
+ if (ms < 60 * 60_000)
66
+ return `${Math.round(ms / 60_000)}m`;
67
+ return `${Math.round(ms / (60 * 60_000))}h`;
68
+ }
15
69
  /** Normalize a Feishu message's parsed content by type → text + any media keys (pure; download done by caller). */
16
70
  export function parseFeishuContent(messageType, content) {
17
71
  if (messageType === "text")
@@ -178,6 +232,8 @@ export function feishuAdapter(appId, appSecret) {
178
232
  // gateway go deaf. Turning it on means: no inbound frame within pingTimeout seconds of a ping → presumed dead
179
233
  // → reconnect. handshakeTimeoutMs stops a stuck DNS/proxy handshake from hanging. Lifecycle logs give
180
234
  // visibility so a reconnect is observable instead of a mystery silence.
235
+ const wsHealth = new FeishuWsHealthMonitor();
236
+ let failActiveStart;
181
237
  const wsClient = new lark.WSClient({
182
238
  appId,
183
239
  appSecret,
@@ -185,9 +241,29 @@ export function feishuAdapter(appId, appSecret) {
185
241
  autoReconnect: true,
186
242
  wsConfig: { pingTimeout: 10 },
187
243
  handshakeTimeoutMs: 15_000,
188
- onReconnecting: () => console.error("hara feishu: ⟳ ws reconnecting…"),
189
- onReconnected: () => console.error("hara feishu: ✓ ws reconnected"),
190
- onError: (err) => console.error(`hara feishu: ws error — ${err?.message ?? err}`),
244
+ onReady: () => {
245
+ wsHealth.ready();
246
+ console.error("hara feishu: ws connected");
247
+ },
248
+ onReconnecting: () => {
249
+ const health = wsHealth.disconnect();
250
+ console.error(`hara feishu: ws disconnected #${health.total} · ${health.hourCount}/1h · ${health.dayCount}/24h · ` +
251
+ `previous connection ${wsDuration(health.connectedForMs)} · auto-reconnecting (SDK does not expose close code/reason)`);
252
+ if (health.alert) {
253
+ console.error("hara feishu: ALERT WebSocket disconnect frequency is unhealthy; check duplicate bot instances, host network/proxy, " +
254
+ "and adjacent SDK error/watchdog logs. Automatic reconnect remains active.");
255
+ }
256
+ },
257
+ onReconnected: () => {
258
+ console.error(`hara feishu: ✓ ws reconnected after ${wsDuration(wsHealth.reconnected())}`);
259
+ },
260
+ onError: (err) => {
261
+ const safe = redactKnownSecrets(String(err?.message ?? err), [appId, appSecret]).text
262
+ .replace(/\s+/gu, " ")
263
+ .slice(0, 300);
264
+ console.error(`hara feishu: ALERT ws reconnect entered terminal failure — ${safe || "unknown SDK error"}`);
265
+ failActiveStart?.(new Error("Feishu WebSocket entered terminal failure; inspect the redacted gateway log"));
266
+ },
191
267
  });
192
268
  // Generated SDK methods discard unknown request options before they reach axios, so passing `signal` there
193
269
  // is not cooperative cancellation. REST and media use native fetch instead; the SDK remains responsible for
@@ -385,22 +461,31 @@ export function feishuAdapter(appId, appSecret) {
385
461
  });
386
462
  },
387
463
  async start(onMessage, signal, shouldDownload) {
464
+ const runAbort = new AbortController();
465
+ const relayAbort = () => {
466
+ runAbort.abort(signal.reason instanceof Error ? signal.reason : new Error("Feishu gateway cancelled"));
467
+ };
468
+ if (signal.aborted)
469
+ relayAbort();
470
+ else
471
+ signal.addEventListener("abort", relayAbort, { once: true });
472
+ const runSignal = runAbort.signal;
388
473
  const spool = await GatewayEventSpool.open(gatewayRuntimeScope("feishu-inbound", appId));
389
474
  const locallyCompleted = new Set();
390
475
  const cleanupFailures = new Map();
391
476
  const retryStateFailures = new Map();
392
477
  const postAckCleanups = new Map();
393
478
  const runWorker = async () => {
394
- while (!signal.aborted) {
479
+ while (!runSignal.aborted) {
395
480
  const item = await spool.nextReady();
396
481
  if (!item) {
397
- await waitForFeishuWork(250, signal);
482
+ await waitForFeishuWork(250, runSignal);
398
483
  continue;
399
484
  }
400
485
  try {
401
486
  if (!locallyCompleted.has(item.id)) {
402
- const m = await toInbound(downloadResource, item.payload, await ensureBotOpenId(signal), signal, shouldDownload);
403
- if (signal.aborted) {
487
+ const m = await toInbound(downloadResource, item.payload, await ensureBotOpenId(runSignal), runSignal, shouldDownload);
488
+ if (runSignal.aborted) {
404
489
  await spool.release(item.id);
405
490
  return;
406
491
  }
@@ -409,7 +494,7 @@ export function feishuAdapter(appId, appSecret) {
409
494
  if (cleanup)
410
495
  postAckCleanups.set(item.id, cleanup);
411
496
  }
412
- if (signal.aborted) {
497
+ if (runSignal.aborted) {
413
498
  await spool.release(item.id);
414
499
  return;
415
500
  }
@@ -433,7 +518,7 @@ export function feishuAdapter(appId, appSecret) {
433
518
  }
434
519
  }
435
520
  catch {
436
- if (signal.aborted) {
521
+ if (runSignal.aborted) {
437
522
  await spool.release(item.id);
438
523
  return;
439
524
  }
@@ -449,7 +534,7 @@ export function feishuAdapter(appId, appSecret) {
449
534
  console.error("hara feishu: ALERT durable spool cleanup suspended after 5 failures; the completed item is retained for startup recovery");
450
535
  return; // keep the in-memory lease: no busy loop and no agent replay in this process
451
536
  }
452
- await waitForFeishuWork(Math.min(30_000, 2_000 * (2 ** (failures - 1))), signal);
537
+ await waitForFeishuWork(Math.min(30_000, 2_000 * (2 ** (failures - 1))), runSignal);
453
538
  await spool.release(item.id);
454
539
  continue;
455
540
  }
@@ -476,7 +561,7 @@ export function feishuAdapter(appId, appSecret) {
476
561
  console.error("hara feishu: ALERT durable spool retry-state persistence suspended after 5 failures; event retained for startup recovery");
477
562
  return;
478
563
  }
479
- await waitForFeishuWork(Math.min(30_000, 2_000 * (2 ** (failures - 1))), signal);
564
+ await waitForFeishuWork(Math.min(30_000, 2_000 * (2 ** (failures - 1))), runSignal);
480
565
  await spool.release(item.id);
481
566
  }
482
567
  }
@@ -492,43 +577,59 @@ export function feishuAdapter(appId, appSecret) {
492
577
  "im.message.receive_v1": async (data) => {
493
578
  // Feishu requires a long-connection callback within three seconds. Durably enqueue first, then ACK;
494
579
  // workers run the potentially minutes-long agent task independently and survive process restarts.
495
- if (signal.aborted)
580
+ if (runSignal.aborted)
496
581
  throw new Error("Feishu gateway cancelled");
497
582
  const write = spool.enqueue(feishuEventSpoolId(data), data);
498
583
  persistenceWrites.add(write);
499
584
  void write.then(() => persistenceWrites.delete(write), () => persistenceWrites.delete(write));
500
585
  try {
501
- await withOutboundDeadline("Feishu event persistence", signal, 2_500, async () => write);
586
+ await withOutboundDeadline("Feishu event persistence", runSignal, 2_500, async () => write);
502
587
  }
503
588
  catch (error) {
504
- if (!signal.aborted) {
589
+ if (!runSignal.aborted) {
505
590
  console.error(`hara feishu: ALERT event could not be durably queued before ACK — ${error instanceof Error ? error.message : String(error)}`);
506
591
  }
507
592
  throw error;
508
593
  }
509
594
  },
510
595
  });
511
- wsClient.start({ eventDispatcher }); // runs its own background long-connection (auto-reconnect + watchdog)
512
- // Keep the adapter alive until the gateway aborts, then CLOSE the WSClient so its socket + timers release
513
- // the event loop and the process actually exits on SIGTERM (previously the live connection pinned the
514
- // loop, so only kill -9 worked — and a hard kill leaves a dirty disconnect that Feishu can throttle).
515
- await new Promise((resolve) => {
516
- if (signal.aborted)
596
+ const terminalFailure = new Promise((_resolve, reject) => {
597
+ failActiveStart = (error) => {
598
+ reject(error);
599
+ runAbort.abort(error);
600
+ };
601
+ });
602
+ const stopped = new Promise((resolve) => {
603
+ if (runSignal.aborted)
517
604
  return resolve();
518
- signal.addEventListener("abort", () => resolve(), { once: true });
605
+ runSignal.addEventListener("abort", () => resolve(), { once: true });
519
606
  });
520
607
  try {
521
- wsClient.close();
608
+ // The SDK owns ordinary reconnect/backoff. A terminal callback is different: keeping this process alive
609
+ // but deaf defeats PM2/systemd recovery, so reject start after draining the durable inbound workers.
610
+ void Promise.resolve(wsClient.start({ eventDispatcher })).catch((error) => {
611
+ const safe = redactKnownSecrets(String(error instanceof Error ? error.message : error), [appId, appSecret]).text;
612
+ failActiveStart?.(new Error(`Feishu WebSocket start failed: ${safe.slice(0, 300)}`));
613
+ });
614
+ await Promise.race([stopped, terminalFailure]);
522
615
  }
523
- catch {
524
- /* best-effort clean shutdown */
616
+ finally {
617
+ failActiveStart = undefined;
618
+ runAbort.abort(new Error("Feishu gateway stopped"));
619
+ signal.removeEventListener("abort", relayAbort);
620
+ try {
621
+ wsClient.close();
622
+ }
623
+ catch {
624
+ /* best-effort clean shutdown */
625
+ }
626
+ // Do not release the gateway instance lease while an old worker or an ACK-persistence write can still
627
+ // mutate the spool. A replacement process must never race this process's final CAS write or run the same
628
+ // durable item concurrently.
629
+ while (persistenceWrites.size)
630
+ await Promise.allSettled([...persistenceWrites]);
631
+ await workersSettled;
525
632
  }
526
- // Do not release the gateway instance lease while an old worker or an ACK-persistence write can still
527
- // mutate the spool. A replacement process must never race this process's final CAS write or run the same
528
- // durable item concurrently.
529
- while (persistenceWrites.size)
530
- await Promise.allSettled([...persistenceWrites]);
531
- await workersSettled;
532
633
  },
533
634
  };
534
635
  }
package/dist/index.js CHANGED
@@ -71,7 +71,7 @@ function renderBgJobs() {
71
71
  }
72
72
  import { qwenDeviceLogin, loadQwenToken } from "./providers/qwen-oauth.js";
73
73
  import { loadAgentContext, hasAgentsMd, INIT_PROMPT, findProjectRoot } from "./context/agents-md.js";
74
- import { homeWorkspaceActionError, isUnsafeProjectWorkspace, resolveWorkspaceSwitch, } from "./context/workspace-scope.js";
74
+ import { homeWorkspaceActionError, isUnsafeProjectWorkspace, resolveWorkspaceSwitch, suggestedProjectWorkspace, } from "./context/workspace-scope.js";
75
75
  import { getEmbedder } from "./search/embed.js";
76
76
  import { collectRepoChunksAsync, collectDirChunksAsync, buildIndex, indexPath, indexExists } from "./search/semindex.js";
77
77
  import { searchHybrid } from "./search/hybrid.js";
@@ -3001,6 +3001,66 @@ program.action(async (opts) => {
3001
3001
  // hook above — see setFlagOverride() + resolveActive() in profile.ts. activeId() / loadActiveProfile()
3002
3002
  // pick it up automatically. `HARA_PROFILE` env still works as a transient override (one slot lower
3003
3003
  // in the priority chain than --profile).
3004
+ // An interactive launch from Home gets an explicit, pre-provider handoff. Candidate discovery is limited
3005
+ // to paths the user already established through interactive sessions/project registration; Hara never
3006
+ // enumerates Home and never silently promotes a readable child into the workspace.
3007
+ if (!opts.print
3008
+ && !opts.cwd
3009
+ && !opts.resume
3010
+ && stdin.isTTY
3011
+ && stdout.isTTY
3012
+ && isUnsafeProjectWorkspace(process.cwd())) {
3013
+ let candidate;
3014
+ try {
3015
+ const recent = listSessions()
3016
+ .filter((session) => session.source === undefined || session.source === "interactive")
3017
+ .map((session) => session.cwd);
3018
+ candidate = suggestedProjectWorkspace([
3019
+ ...recent,
3020
+ ...loadProjects().map((project) => project.path),
3021
+ ]);
3022
+ }
3023
+ catch {
3024
+ // A damaged optional history/registry must not weaken the Home boundary or block startup.
3025
+ }
3026
+ const startupUsesTui = process.env.HARA_TUI !== "0";
3027
+ if (candidate && startupUsesTui) {
3028
+ // A readline question before Ink mounts leaves stdin unreadable on some terminals. The small Ink
3029
+ // confirmation unmounts cleanly and is the same handoff used by the first-run AGENTS.md prompt.
3030
+ if (await askConfirm(`Home is protected as personal-data scope. Switch to recent project ${displaySessionCwd(candidate)}?`)) {
3031
+ process.chdir(candidate);
3032
+ }
3033
+ }
3034
+ else if (!candidate && startupUsesTui) {
3035
+ out(c.yellow("Home is protected as personal-data scope; no recent project is available to offer safely. ")
3036
+ + c.dim("Start with `hara --cwd /path/to/project`, or use `/cd /path/to/project` after startup.\n"));
3037
+ }
3038
+ else {
3039
+ const rl = createInterface({ input: stdin, output: stdout });
3040
+ try {
3041
+ if (candidate) {
3042
+ const answer = (await rl.question(c.yellow(`Home is protected as personal-data scope. Switch to recent project ${displaySessionCwd(candidate)}? `)
3043
+ + c.dim("[Y/n] "))).trim().toLowerCase();
3044
+ if (answer === "" || answer === "y" || answer === "yes")
3045
+ process.chdir(candidate);
3046
+ }
3047
+ else {
3048
+ const answer = (await rl.question(c.yellow("Home is protected as personal-data scope. Enter a project directory to switch now")
3049
+ + c.dim(" (leave empty to stay at Home): "))).trim();
3050
+ if (answer) {
3051
+ const switched = resolveWorkspaceSwitch(answer, process.cwd());
3052
+ if (switched.ok)
3053
+ process.chdir(switched.cwd);
3054
+ else
3055
+ out(c.red(`(${switched.error})\n`));
3056
+ }
3057
+ }
3058
+ }
3059
+ finally {
3060
+ rl.close();
3061
+ }
3062
+ }
3063
+ }
3004
3064
  // Resolve addressable headless roles BEFORE loading config/provider/MCP. A qualified project role is
3005
3065
  // an execution-home selection, so every downstream route (credentials, model, AGENTS.md, tools) must be
3006
3066
  // constructed from that home rather than from the shell directory that happened to launch hara.
@@ -281,8 +281,77 @@ function validGitSource(source) {
281
281
  if (!/^(?:https:\/\/|ssh:\/\/|git@)[^\s\0]+$/u.test(url)) {
282
282
  throw new Error("git source must use an https://, ssh://, or git@ URL");
283
283
  }
284
+ if (url.startsWith("https://") || url.startsWith("ssh://")) {
285
+ let parsed;
286
+ try {
287
+ parsed = new URL(url);
288
+ }
289
+ catch {
290
+ throw new Error("git source contains an invalid URL");
291
+ }
292
+ const secretAuthority = url.startsWith("https://")
293
+ ? Boolean(parsed.username || parsed.password)
294
+ : Boolean(parsed.password);
295
+ if (secretAuthority || parsed.search || parsed.hash) {
296
+ throw new Error("git source must not embed credentials or query/fragment secrets; configure a Git credential helper or SSH key instead");
297
+ }
298
+ }
284
299
  return url;
285
300
  }
301
+ /** Turn git's often credential-bearing stderr into a bounded, actionable diagnosis. Never echo the URL,
302
+ * stderr, usernames, tokens, credential-helper output, or the original command line. */
303
+ export function pluginGitCloneFailure(kind, error) {
304
+ const value = error;
305
+ const stderr = Buffer.isBuffer(value?.stderr)
306
+ ? value.stderr.toString("utf8")
307
+ : typeof value?.stderr === "string"
308
+ ? value.stderr
309
+ : "";
310
+ const diagnostic = stderr.toLowerCase();
311
+ const label = kind === "github" ? "GitHub plugin repository" : "plugin Git repository";
312
+ if (value?.code === "ENOENT") {
313
+ return `Could not clone the ${label}: Git is not installed or is not available on PATH. Install Git, then retry.`;
314
+ }
315
+ if (value?.code === "ETIMEDOUT"
316
+ || value?.signal === "SIGTERM"
317
+ || value?.killed === true
318
+ || /timed out|connection timed out/u.test(diagnostic)) {
319
+ return `Could not clone the ${label}: the network operation exceeded Hara's 2-minute limit. Check connectivity/proxy settings, then retry.`;
320
+ }
321
+ if (/could not resolve host|failed to connect|connection refused|network is unreachable|connection reset/u.test(diagnostic)) {
322
+ return `Could not clone the ${label}: Git could not reach the remote host. Check DNS, network, VPN, and Git proxy settings, then retry.`;
323
+ }
324
+ if (/authentication failed|permission denied|access denied|could not read username|terminal prompts disabled|publickey|http basic/u.test(diagnostic)) {
325
+ return (`Could not clone the ${label}: authentication or repository access was denied. ` +
326
+ (kind === "github"
327
+ ? "For a private repository, authenticate GitHub (`gh auth login` then `gh auth setup-git`) or use `git:git@github.com:owner/repository.git` with a working SSH key. "
328
+ : "Configure credentials for that Git host or use an SSH URL backed by a working key. ") +
329
+ "Do not put a token in the plugin URL.");
330
+ }
331
+ if (/repository not found|not found|could not read from remote repository/u.test(diagnostic)) {
332
+ return (`Could not clone the ${label}: it does not exist or the current Git identity cannot access it. ` +
333
+ (kind === "github"
334
+ ? "Check owner/repository spelling; for a private repository run `gh auth login` and `gh auth setup-git`, or use a working SSH URL. "
335
+ : "Check the repository URL and credentials. ") +
336
+ "Do not put a token in the plugin URL.");
337
+ }
338
+ return (`Could not clone the ${label}. Run an equivalent \`git clone\` yourself to diagnose the remote, ` +
339
+ "then retry Hara after Git credentials/network access work. No remote stderr was shown because it may contain credentials.");
340
+ }
341
+ function clonePluginRepository(url, staging, kind) {
342
+ try {
343
+ execFileSync("git", ["clone", "--depth", "1", url, staging], {
344
+ encoding: "utf8",
345
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
346
+ maxBuffer: 256 * 1024,
347
+ stdio: ["ignore", "ignore", "pipe"],
348
+ timeout: 2 * 60_000,
349
+ });
350
+ }
351
+ catch (error) {
352
+ throw new Error(pluginGitCloneFailure(kind, error));
353
+ }
354
+ }
286
355
  function populateStaging(source, staging) {
287
356
  if (source.startsWith("file:")) {
288
357
  const requested = resolve(source.slice("file:".length));
@@ -301,10 +370,10 @@ function populateStaging(source, staging) {
301
370
  const repo = source.slice("github:".length);
302
371
  if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repo))
303
372
  throw new Error("github source must be owner/repository");
304
- execFileSync("git", ["clone", "--depth", "1", `https://github.com/${repo}.git`, staging], { stdio: "ignore" });
373
+ clonePluginRepository(`https://github.com/${repo}.git`, staging, "github");
305
374
  }
306
375
  else if (source.startsWith("git:")) {
307
- execFileSync("git", ["clone", "--depth", "1", validGitSource(source), staging], { stdio: "ignore" });
376
+ clonePluginRepository(validGitSource(source), staging, "git");
308
377
  }
309
378
  else {
310
379
  throw new Error("source must be file:<path>, github:<owner/repo>, or git:<url>");
@@ -11,6 +11,12 @@ import { sameOpenedFileIdentity } from "../fs-identity.js";
11
11
  const PRIVATE_TREES = new Set(["sessions", "checkpoints", "index", "gateway", "cron", "weixin", "tool-results", "plugin-receipts", "artifacts"]);
12
12
  const tightenedHomes = new Set();
13
13
  const DEFAULT_MIGRATION_CAP = 50_000;
14
+ export class PrivateStateConflictError extends Error {
15
+ constructor(message, options) {
16
+ super(message, options);
17
+ this.name = "PrivateStateConflictError";
18
+ }
19
+ }
14
20
  function isMissing(error) {
15
21
  return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
16
22
  }
@@ -397,13 +403,16 @@ export function writePrivateStateBytesOnceSync(binding, bytes) {
397
403
  * Crash-safe, no-follow, compare-and-swap replacement for one bound private state file. Existing entries
398
404
  * are move-claimed before verification so a concurrent alias/replacement is never overwritten silently.
399
405
  */
400
- export function writePrivateStateFileSync(binding, text) {
406
+ export function writePrivateStateFileSync(binding, text, options = {}) {
401
407
  const { directory, path } = binding;
402
408
  verifyPrivateDirectory(directory);
403
409
  if (resolve(path) !== join(directory.path, checkedPrivateComponent(basename(path)))) {
404
410
  throw new Error(`private Hara state file is outside '${directory.path}'`);
405
411
  }
406
412
  const existing = readPrivateStateFileSnapshotSync(path);
413
+ if (options.expectedText !== undefined && existing?.text !== options.expectedText) {
414
+ throw new PrivateStateConflictError(`private Hara state file no longer matches the expected version: '${path}'`);
415
+ }
407
416
  verifyPrivateDirectory(directory);
408
417
  const temp = join(directory.path, `.hara-private-${process.pid}-${randomUUID()}.tmp`);
409
418
  let fd;
@@ -438,8 +447,9 @@ export function writePrivateStateFileSync(binding, text) {
438
447
  linkSync(temp, path);
439
448
  }
440
449
  catch (error) {
441
- if (error?.code === "EEXIST")
442
- throw new Error(`private Hara state file changed before create: '${path}'`);
450
+ if (error?.code === "EEXIST") {
451
+ throw new PrivateStateConflictError(`private Hara state file changed before create: '${path}'`, { cause: error });
452
+ }
443
453
  throw error;
444
454
  }
445
455
  }
@@ -450,8 +460,9 @@ export function writePrivateStateFileSync(binding, text) {
450
460
  renameSync(path, claimed);
451
461
  }
452
462
  catch (error) {
453
- if (error?.code === "ENOENT")
454
- throw new Error(`private Hara state file changed before replace: '${path}'`);
463
+ if (error?.code === "ENOENT") {
464
+ throw new PrivateStateConflictError(`private Hara state file changed before replace: '${path}'`, { cause: error });
465
+ }
455
466
  throw error;
456
467
  }
457
468
  let verifiedClaim = false;
@@ -462,8 +473,9 @@ export function writePrivateStateFileSync(binding, text) {
462
473
  && claimedSnapshot.mode === existing.mode
463
474
  && claimedSnapshot.nlink === existing.nlink
464
475
  && claimedSnapshot.text === existing.text);
465
- if (!verifiedClaim)
466
- throw new Error(`private Hara state file changed before replace: '${path}'`);
476
+ if (!verifiedClaim) {
477
+ throw new PrivateStateConflictError(`private Hara state file changed before replace: '${path}'`);
478
+ }
467
479
  }
468
480
  catch (error) {
469
481
  try {
@@ -29,6 +29,10 @@
29
29
  // automation.toggle {id,enabled} → {id,enabled}
30
30
  // automation.delete {id} → {id,deleted}
31
31
  // artifact.import {sourcePath,title?,kind?} → {artifact,currentRevision,content}
32
+ // artifact.commit {artifactId,baseRevisionId,sourcePath,taskRunId?,changedPaths?}
33
+ // → {artifact,currentRevision,content}
34
+ // artifact.revert {artifactId,baseRevisionId,targetRevisionId,taskRunId?}
35
+ // → {artifact,currentRevision,content}
32
36
  // artifact.list {} → {artifacts,invalid,truncated}
33
37
  // artifact.get {artifactId} → {artifact,currentRevision,content}
34
38
  // artifact.revisions {artifactId} → {artifactId,revisions}
@@ -38,7 +42,7 @@
38
42
  // Server → client notifications (all carry sessionId):
39
43
  // event.text / event.reasoning {delta} · event.tool {name,preview} · event.diff {text}
40
44
  // event.notice {text} · event.turn_end {reply,usage,error?} · approval.request {approvalId,question}
41
- // 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,…}
42
46
  // authoritative execution plane; clients feature-detect it via capabilities.events
43
47
  export const PROTOCOL_VERSION = 1;
44
48
  /** JSON-RPC error codes: standard ones plus hara-specific (-320xx). */
@@ -52,6 +56,7 @@ export const ERR = {
52
56
  BUSY: -32002, // requested operation conflicts with active server/session work
53
57
  NO_SESSION: -32003, // unknown/expired sessionId
54
58
  LOCKED: -32004, // session held by another live hara process (single-writer lock)
59
+ CONFLICT: -32005, // optimistic version/base no longer matches the current object
55
60
  };
56
61
  /** Parse one inbound text frame into a request. Returns {error} (never throws) on malformed input —
57
62
  * the transport turns that into a PARSE/INVALID error response. */