@staix/agent-hub 0.7.4 → 0.7.5

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
@@ -2,6 +2,10 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.7.5
6
+
7
+ - A zero-turn Claude session no longer wedges an upgrade after restore either: the commit snapshot records whether the session ever persisted a transcript, coordinator verification re-derives persistence from the terminal binding exactly like the restore gate did, and daemon readiness tolerates a fresh session while the original transcript is absent (snapshots from older sources are re-derived from disk). A session with a transcript keeps the strict identity check in all three places. The status line tee now records the transcript path Claude reports (#64).
8
+
5
9
  ## 0.7.4
6
10
 
7
11
  - A Claude session that never persisted a transcript (zero turns) no longer wedges an upgrade at restore: when the original transcript is absent, the coordinator accepts the fresh session the operator attached, because nothing was preserved and nothing is lost. Sessions with a transcript keep the strict identity check; Codex stays strict in all cases (#21).
package/README.md CHANGED
@@ -4,7 +4,7 @@ Native multi-agent hub for one developer's machine: Claude Code, Codex, Kimi Cod
4
4
  hub-owned local-LLM worker collaborate as peers in independent project directories, with
5
5
  task-aware model routing (Switchyard) in front of a self-hosted gateway (OmniRoute).
6
6
 
7
- Status: 0.7.4, control protocol 10. Durable delivery records distinguish queued
7
+ Status: 0.7.5, control protocol 10. Durable delivery records distinguish queued
8
8
  work from uncertain execution. The [smoke checklist](docs/smoke.md) records
9
9
  verified paths and remaining prerequisites.
10
10
 
@@ -28,7 +28,7 @@ cd <your project> && ahub init && ahub up && ahub tail
28
28
  Or install the same version from GitHub:
29
29
 
30
30
  ```bash
31
- bun add -g github:STAIxBWLB/agent-hub#v0.7.4 && ahub setup
31
+ bun add -g github:STAIxBWLB/agent-hub#v0.7.5 && ahub setup
32
32
  ```
33
33
 
34
34
  The installed commands remain `ahub` and `agent-hub`.
@@ -1,6 +1,6 @@
1
1
  # Operations guide
2
2
 
3
- This guide describes ahub 0.7.4 and control protocol 10. Live verification
3
+ This guide describes ahub 0.7.5 and control protocol 10. Live verification
4
4
  results and remaining prerequisites are recorded separately in [the smoke ledger](smoke.md).
5
5
 
6
6
  ## Install and start
@@ -182,27 +182,27 @@ For an upgrade from 0.6.4 (protocol 9) or 0.7.0, use the patched coordinator wit
182
182
  replacing the global CLI prematurely. Run from the project directory:
183
183
 
184
184
  ```bash
185
- bunx --package @staix/agent-hub@0.7.4 ahub upgrade --to 0.7.4 --dry-run
186
- bunx --package @staix/agent-hub@0.7.4 ahub upgrade --to 0.7.4 --yes
185
+ bunx --package @staix/agent-hub@0.7.5 ahub upgrade --to 0.7.5 --dry-run
186
+ bunx --package @staix/agent-hub@0.7.5 ahub upgrade --to 0.7.5 --yes
187
187
  ```
188
188
 
189
189
  The coordinator verifies and retains the exact target package, preserves its
190
190
  own source, and promotes the global CLI only after restored projects pass
191
191
  readback. Do not use the old protocol-9 coordinator to target protocol 10.
192
192
 
193
- Once the installed CLI is 0.7.4, review the current project or all registered
193
+ Once the installed CLI is 0.7.5, review the current project or all registered
194
194
  projects first:
195
195
 
196
196
  ```bash
197
197
  ahub restart --dry-run
198
- ahub upgrade --to 0.7.4 --dry-run
198
+ ahub upgrade --to 0.7.5 --dry-run
199
199
  ```
200
200
 
201
201
  Apply only after reviewing the plan:
202
202
 
203
203
  ```bash
204
204
  ahub restart --yes
205
- ahub upgrade --to 0.7.4 --yes
205
+ ahub upgrade --to 0.7.5 --yes
206
206
  ahub recovery status <operation-id>
207
207
  ahub recovery resume <operation-id>
208
208
  ahub recovery abort <operation-id>
@@ -219,6 +219,9 @@ package digest, terminal binding, and phase receipt. An uncertain terminal
219
219
  creation is never repeated blindly. If Claude shows its development-channel confirmation screen, confirm it in
220
220
  the captured terminal, then run `ahub recovery resume <operation-id>`; resume must not open a duplicate terminal.
221
221
  Kimi and local sessions may start fresh with preserved routing and task context.
222
+ A Claude session that never persisted a transcript (zero turns) also starts fresh:
223
+ there is nothing to resume, so the upgrade accepts the new session id; a session
224
+ with a transcript must come back with its original id.
222
225
  Previously stopped projects stay stopped; only the reviewed running projects
223
226
  are upgraded.
224
227
 
@@ -17,7 +17,7 @@ ahub setup # installs the Claude Code channel plu
17
17
  Or use the matching GitHub release:
18
18
 
19
19
  ```bash
20
- bun add -g github:STAIxBWLB/agent-hub#v0.7.4
20
+ bun add -g github:STAIxBWLB/agent-hub#v0.7.5
21
21
  ahub setup
22
22
  ```
23
23
 
package/docs/smoke.md CHANGED
@@ -629,3 +629,67 @@ A source-build smoke on Apple silicon/macOS 27.0 with Ollama 0.34.2 verified:
629
629
  The live harness is opt-in and is not called by the hermetic repository
630
630
  check. `AHUB_SMOKE_PROJECT` selects a project configuration without starting
631
631
  its daemon or replaying its queue. It deliberately forbids remote inference.
632
+
633
+
634
+ ## 0.7.2-0.7.4 attended upgrade smoke (issue #21, 2026-09-27)
635
+
636
+ Attended registry package/plugin upgrade on a scratch project
637
+ (`~/ahub-smoke`, Orca-managed worktree) with claude, codex and kimi attached
638
+ to a 0.7.1 hub installed from npm. Pre-upgrade state: task #1 in_progress
639
+ (owner codex, reviewer claude), kimi manually paused with one important
640
+ envelope queued, live Claude session and live Codex thread captured by the
641
+ dry-run plan. This run closes the attended leg left open by the 0.7.1
642
+ cutover; it also exposed three recovery defects that became 0.7.3 and 0.7.4.
643
+
644
+ ### What the runs proved
645
+
646
+ - The coordinator blocked safely, twice, when the Codex TUI detached
647
+ mid-prepare: "no terminal was closed", the 0.7.1 source kept running, and
648
+ the board, pause and queue were verified unchanged afterwards. Blocked
649
+ operations were resumable or cleanly resolvable once their cause was
650
+ removed.
651
+ - The fifth attempt (0.7.3 coordinator, codex offline at plan time) drove
652
+ prepare, commit, source stop, staged-target start and native peer restore
653
+ without operator intervention; the new daemon reported version 0.7.3 with
654
+ a new instanceId, kimi still paused with its queued envelope intact, and
655
+ the task board unchanged. Claude restoration paused at the documented
656
+ attended step (development-channel confirmation in the captured terminal),
657
+ then resumed.
658
+ - Final state after completion: daemon, CLI and plugin all 0.7.4
659
+ (pid 12483, new instanceId, protocol 10), claude idle, kimi idle with
660
+ queued 0. The important envelope queued at 03:16 on 0.7.1 was delivered
661
+ after resume and answered by kimi at 04:53 ("Received the upgrade
662
+ preservation check envelope"). Task #1 remains in_progress on the board.
663
+
664
+ ### Defects found and fixed by this smoke
665
+
666
+ - A peer detaching between prepare and commit wedged the operation:
667
+ readiness never recovered, resume refused, abort refused. Fixed in 0.7.3
668
+ (PR #60): an offline peer no longer fails the identity comparison, and an
669
+ already-exited terminal closes as a no-op.
670
+ - A zero-turn Claude session (no transcript on disk) made restore fail with
671
+ "No conversation found" and wedged the pending-restore identity gate.
672
+ Fixed in 0.7.4 (PR #62): with the original transcript absent, a fresh
673
+ operator-attached session is accepted.
674
+ - The same zero-turn case still cannot pass the coordinator's verify step
675
+ or the daemon's release gate; tracked as issue #64. This operation was
676
+ completed manually (restart snapshot removed, daemon restarted), which is
677
+ an operator action, not a designed path.
678
+
679
+ ### Environmental limits recorded
680
+
681
+ - An active native Codex TUI could not be kept alive on this machine: the
682
+ Orca runtime closed three `ahub codex` terminals in-process
683
+ (`origin: in-process`, `ptyKilled: true`) 35-90 seconds after each attach,
684
+ while the Claude terminal survived. The Codex leg therefore ran with
685
+ codex offline at plan time, as in the 0.7.1 cutover; this run does not
686
+ claim an active native Codex TUI restoration either.
687
+ - The recovery runner for an existing operation always executes the
688
+ operation's preserved source, so a coordinator fix released after an
689
+ operation was created does not reach that operation's resume. The
690
+ sourceRoot digest guard correctly refused a hand-patched receipt.
691
+ - `ahub setup` is interactive (one confirmation gates all steps); piping an
692
+ answer works, running it detached does not.
693
+
694
+ Issue [#12](https://github.com/STAIxBWLB/agent-hub/issues/12) remains open
695
+ for off-campus Access credentials and a natural near-limit budget pause.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staix/agent-hub",
3
- "version": "0.7.4",
3
+ "version": "0.7.5",
4
4
  "description": "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-hub",
3
- "version": "0.7.4",
3
+ "version": "0.7.5",
4
4
  "description": "Channel between Claude Code and the agent-hub daemon: peer messages from Codex, Kimi and the local worker arrive as channel events; hub_send replies.",
5
5
  "author": {
6
6
  "name": "Young Joon Lee",
@@ -15727,7 +15727,7 @@ class ControlClient {
15727
15727
  // package.json
15728
15728
  var package_default = {
15729
15729
  name: "@staix/agent-hub",
15730
- version: "0.7.4",
15730
+ version: "0.7.5",
15731
15731
  description: "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
15732
15732
  license: "MIT",
15733
15733
  type: "module",
@@ -25,7 +25,10 @@ try {
25
25
  if (sessionId && dir) {
26
26
  mkdirSync(dir, { recursive: true });
27
27
  const file = join(dir, "claude-session.json");
28
- writeFileSync(`${file}.tmp`, JSON.stringify({ at: Date.now(), sessionId, ...(process.env.AGENTHUB_INSTANCE_ID ? { instanceId: process.env.AGENTHUB_INSTANCE_ID } : {}), ...(process.env.AGENTHUB_LAUNCH_ID ? { launchId: process.env.AGENTHUB_LAUNCH_ID } : {}) }), { mode: 0o600 });
28
+ // The transcript path lets the hub tell a zero-turn session (file not yet created, so
29
+ // `claude --resume` can never work) from one whose conversation must be preserved (#64).
30
+ const transcriptPath = typeof parsed.transcript_path === "string" && parsed.transcript_path ? parsed.transcript_path : undefined;
31
+ writeFileSync(`${file}.tmp`, JSON.stringify({ at: Date.now(), sessionId, ...(transcriptPath ? { transcriptPath } : {}), ...(process.env.AGENTHUB_INSTANCE_ID ? { instanceId: process.env.AGENTHUB_INSTANCE_ID } : {}), ...(process.env.AGENTHUB_LAUNCH_ID ? { launchId: process.env.AGENTHUB_LAUNCH_ID } : {}) }), { mode: 0o600 });
29
32
  chmodSync(`${file}.tmp`, 0o600);
30
33
  renameSync(`${file}.tmp`, file);
31
34
  }
@@ -352,7 +352,13 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
352
352
  const peer = live.peers.find((p) => p.id === old.id);
353
353
  if (!peer || !["idle", "busy", "paused"].includes(peer.state)) throw new Error(`${old.id}: peer reattachment not verified`);
354
354
  if (old.id === "codex" && peer.threadId !== old.threadId) throw new Error("Codex resumed a different conversation");
355
- if (old.id === "claude" && peer.sessionId !== old.sessionId) throw new Error("Claude resumed a different conversation");
355
+ if (old.id === "claude" && peer.sessionId !== old.sessionId) {
356
+ // #64: the restore gate already accepted a fresh session when the original never
357
+ // persisted a transcript. Re-derive persistence from the binding exactly like that
358
+ // gate did, so a first turn landing after the plan was made keeps the check strict.
359
+ const binding = (planned.terminals as TerminalBinding[]).find((t) => t.peer === "claude");
360
+ if (!binding || binding.sessionId !== old.sessionId || claudeTranscriptExists(binding)) throw new Error("Claude resumed a different conversation");
361
+ }
356
362
  if (old.id === "pi" && (peer.sessionId !== old.sessionId || (old.sessionFile && peer.sessionFile !== old.sessionFile))) throw new Error("Pi resumed a different conversation");
357
363
  }
358
364
  for (const original of planned.terminals as TerminalBinding[]) {
package/src/hub/daemon.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createHash, randomBytes, randomUUID } from "node:crypto";
2
2
  import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { join, resolve } from "node:path";
4
+ import { homedir } from "node:os";
4
5
  import type { ServerWebSocket } from "bun";
5
6
  import { AcpPeer, type PermissionRequest } from "../adapters/acp.ts";
6
7
  import { CodexPeer } from "../adapters/codex-appserver.ts";
@@ -480,27 +481,44 @@ export async function startDaemon(opts: DaemonOptions) {
480
481
  // Kimi/local rebuild a fresh worker with task context on the target; native
481
482
  // Claude and Pi sessions must keep their exact identities across restoration.
482
483
  const freshWorker = recoveryPhase === "restored" && (saved.id === "kimi" || saved.id === "local");
483
- if (!freshWorker && saved.sessionId && saved.sessionId !== now.sessionId) return false;
484
+ if (!freshWorker && saved.sessionId && saved.sessionId !== now.sessionId) {
485
+ // #64: a Claude session that never persisted a transcript cannot be resumed, so the
486
+ // restore gate accepted a fresh session and nothing it stood for is lost. Snapshots
487
+ // written before this flag existed are re-derived from disk.
488
+ const unpersistedClaude = saved.id === "claude" && (saved.sessionPersisted === false || (saved.sessionPersisted === undefined && !claudeTranscriptPersisted(saved.sessionId)));
489
+ if (!unpersistedClaude) return false;
490
+ }
484
491
  return true;
485
492
  });
486
493
  };
487
- const claudeSessionId = () => {
494
+ const claudeSession = (): { sessionId?: string; transcriptPath?: string } => {
488
495
  try {
489
496
  const value = JSON.parse(readFileSync(join(opts.stateDir, "claude-session.json"), "utf8"));
490
- if (value.instanceId !== instanceId) return undefined;
497
+ if (value.instanceId !== instanceId) return {};
491
498
  try {
492
499
  const records = JSON.parse(readFileSync(join(opts.stateDir, "terminal-recovery.json"), "utf8"));
493
500
  const current = Array.isArray(records) ? records.find((row) => row?.peer === "claude" && row?.projectRoot === opts.cwd && row?.instanceId === instanceId) : undefined;
494
- if (current?.launchId && value.launchId !== current.launchId) return undefined;
501
+ if (current?.launchId && value.launchId !== current.launchId) return {};
495
502
  } catch { /* no managed terminal record: the instance fence is still enforced */ }
496
- return typeof value.sessionId === "string" && value.sessionId ? value.sessionId : undefined;
497
- } catch { return undefined; }
503
+ return {
504
+ ...(typeof value.sessionId === "string" && value.sessionId ? { sessionId: value.sessionId } : {}),
505
+ ...(typeof value.transcriptPath === "string" && value.transcriptPath ? { transcriptPath: value.transcriptPath } : {}),
506
+ };
507
+ } catch { return {}; }
508
+ };
509
+ /** Claude persists projects/<slug>/<sessionId>.jsonl only with the first turn. Prefer the
510
+ * path Claude itself reported through the status line; fall back to the slug computation. */
511
+ const claudeTranscriptPersisted = (sessionId: string): boolean => {
512
+ const reported = claudeSession();
513
+ if (reported.sessionId === sessionId && reported.transcriptPath) return existsSync(reported.transcriptPath);
514
+ const config = process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
515
+ return existsSync(join(config, "projects", opts.cwd.replace(/[^a-zA-Z0-9]/g, "-"), `${sessionId}.jsonl`));
498
516
  };
499
517
  const recoveryPeers = (): Record<string, RestartPeerSnapshot> => Object.fromEntries([...bus.peers].map(([id, peer]) => {
500
518
  const metadata = peer.recoveryMetadata?.() ?? {};
501
519
  const row: RestartPeerSnapshot = { id, state: peer.state, queueIds: bus.queueIds(id), ...(metadata.launch ? { launch: metadata.launch as Record<string, unknown> } : {}) };
502
520
  if (typeof metadata.threadId === "string") row.threadId = metadata.threadId;
503
- const sessionId = id === "claude" ? claudeSessionId() : metadata.sessionId;
521
+ const sessionId = id === "claude" ? claudeSession().sessionId : metadata.sessionId;
504
522
  if (typeof sessionId === "string" && sessionId) row.sessionId = sessionId;
505
523
  return [id, row];
506
524
  }));
@@ -991,6 +1009,8 @@ export async function startDaemon(opts: DaemonOptions) {
991
1009
  const peers = (recoveryPeerSnapshot ?? Object.values(currentPeers)).map((saved) => ({
992
1010
  ...saved,
993
1011
  queueIds: bus.queueIds(saved.id),
1012
+ // Recorded at commit, not prepare: a first turn landing in between flips it (#64).
1013
+ ...(saved.id === "claude" && saved.sessionId ? { sessionPersisted: claudeTranscriptPersisted(saved.sessionId) } : {}),
994
1014
  }));
995
1015
  const snapshot: RestartSnapshot = {
996
1016
  schemaVersion: 1,
@@ -18,6 +18,10 @@ export interface RestartPeerSnapshot {
18
18
  /** Safe native correlation metadata. Never include prompt/message text here. */
19
19
  threadId?: string;
20
20
  sessionId?: string;
21
+ /** Claude only: false when the session never persisted a transcript (zero turns), so it
22
+ * cannot be resumed and a fresh session is accepted instead. Absent in snapshots written
23
+ * before this flag; those are re-derived from disk (#64). */
24
+ sessionPersisted?: boolean;
21
25
  }
22
26
 
23
27
  export interface RestartSnapshot {