@staix/agent-hub 0.6.3 → 0.7.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.
@@ -51,13 +51,15 @@ export class CodexPeer extends BasePeer {
51
51
  private threadId = "";
52
52
  private readonly activeTurns = new Set<string>();
53
53
  private nextId = -1;
54
- private readonly pending = new Map<number, { resolve: (result?: any) => void; reject: (e: Error) => void }>();
54
+ private readonly pending = new Map<number, { resolve: (result?: any) => void; reject: (e: Error) => void; deliveryId?: string; kind?: "deliver" | "steer" }>();
55
55
  private usageTimer: ReturnType<typeof setInterval> | undefined;
56
56
  private injected: Envelope | undefined; // the hub envelope that started the current turn, if any
57
57
  private lastAnswer = "";
58
58
  private readonly deltas = new Map<string, string[]>();
59
59
  private primed = false;
60
60
  private readonly steers = new Set<number>(); // request ids of turn/steer calls app-server has not answered yet
61
+ private readonly turnDeliveries = new Map<string, Set<string>>();
62
+ private readonly unboundDeliveries = new Set<string>();
61
63
 
62
64
  constructor(
63
65
  id: PeerId,
@@ -129,9 +131,10 @@ export class CodexPeer extends BasePeer {
129
131
  }
130
132
 
131
133
  /** Resolves when app-server accepts the turn. Only called while idle, i.e. a thread exists and no turn runs. */
132
- deliver(envs: Envelope[]): Promise<void> {
134
+ deliver(envs: Envelope[], deliveryId?: string): Promise<void> {
133
135
  const link = this.link;
134
136
  if (this.state !== "idle" || !link || link.up.readyState !== WebSocket.OPEN) {
137
+ if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: `${this.id} is not injectable` });
135
138
  return Promise.reject(new Error(`${this.id} is not injectable`));
136
139
  }
137
140
  const text = this.render(envs);
@@ -139,13 +142,21 @@ export class CodexPeer extends BasePeer {
139
142
  this.setState("busy"); // claim the turn now so the bus stops draining
140
143
  return new Promise<void>((resolve, reject) => {
141
144
  this.pending.set(id, {
142
- resolve: () => {
145
+ deliveryId, kind: "deliver",
146
+ resolve: (result) => {
143
147
  this.primed = true;
144
148
  this.injected = replyParent(envs);
149
+ const nativeTurn = typeof result?.turn?.id === "string" ? result.turn.id : undefined;
150
+ if (deliveryId) {
151
+ if (nativeTurn) this.addTurnDelivery(nativeTurn, deliveryId);
152
+ else this.unboundDeliveries.add(deliveryId);
153
+ this.delivery({ id: deliveryId, state: "accepted" });
154
+ }
145
155
  resolve();
146
156
  },
147
157
  reject: (e) => {
148
158
  if (!this.activeTurns.size) this.setState("idle");
159
+ if (deliveryId) this.delivery({ id: deliveryId, state: this.knownRejection(e) ? "failed_safe" : "needs_review", reason: e.message });
149
160
  reject(e);
150
161
  },
151
162
  });
@@ -160,25 +171,32 @@ export class CodexPeer extends BasePeer {
160
171
  }
161
172
 
162
173
  /** `important` while a turn runs: feed it into that turn. Rejects when there is no steerable turn or app-server refuses. */
163
- steer(envs: Envelope[]): Promise<void> {
174
+ steer(envs: Envelope[], deliveryId?: string): Promise<void> {
164
175
  const link = this.link;
165
176
  const expectedTurnId = [...this.activeTurns].reverse().find((t) => !t.startsWith("unknown:"));
166
177
  if (!link || link.up.readyState !== WebSocket.OPEN || !expectedTurnId) {
178
+ if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: `${this.id} has no steerable turn` });
167
179
  return Promise.reject(new Error(`${this.id} has no steerable turn`));
168
180
  }
169
181
  const id = this.nextId--;
170
182
  this.steers.add(id);
171
183
  return new Promise<void>((resolve, reject) => {
172
184
  this.pending.set(id, {
185
+ deliveryId, kind: "steer",
173
186
  resolve: () => {
174
187
  this.steers.delete(id);
175
188
  this.primed = true;
176
189
  // The turn now answers these too; the highest hop wins so a steer cannot reset the hop cap.
177
190
  this.injected = replyParent(this.injected ? [this.injected, ...envs] : envs);
191
+ if (deliveryId) {
192
+ this.addTurnDelivery(expectedTurnId, deliveryId);
193
+ this.delivery({ id: deliveryId, state: "accepted" });
194
+ }
178
195
  resolve();
179
196
  },
180
197
  reject: (e) => {
181
198
  this.steers.delete(id);
199
+ if (deliveryId) this.delivery({ id: deliveryId, state: this.knownRejection(e) ? "failed_safe" : "needs_review", reason: e.message });
182
200
  reject(e);
183
201
  },
184
202
  });
@@ -194,6 +212,9 @@ export class CodexPeer extends BasePeer {
194
212
  this.link?.up.send(JSON.stringify({ method: "turn/interrupt", id: this.nextId--, params: { threadId: this.threadId, turnId } }));
195
213
  }
196
214
  this.activeTurns.clear();
215
+ for (const ids of this.turnDeliveries.values()) for (const id of ids) this.delivery({ id, state: "needs_review", reason: "Codex turn watchdog timeout" });
216
+ for (const id of this.unboundDeliveries) this.delivery({ id, state: "needs_review", reason: "Codex turn watchdog timeout" });
217
+ this.turnDeliveries.clear(); this.unboundDeliveries.clear();
197
218
  this.injected = undefined;
198
219
  this.lastAnswer = "";
199
220
  this.abandonSteers("turn went silent");
@@ -209,6 +230,15 @@ export class CodexPeer extends BasePeer {
209
230
  }
210
231
  }
211
232
 
233
+ private addTurnDelivery(turnId: string, deliveryId: string): void {
234
+ const ids = this.turnDeliveries.get(turnId) ?? new Set<string>();
235
+ ids.add(deliveryId); this.turnDeliveries.set(turnId, ids); this.unboundDeliveries.delete(deliveryId);
236
+ }
237
+
238
+ private knownRejection(error: Error): boolean {
239
+ return /turn in progress|no active turn|not injectable|rejected|usage limit/i.test(error.message);
240
+ }
241
+
212
242
  private async spawnAppServer(): Promise<string> {
213
243
  const port = this.opts.appPort;
214
244
  const probe = () => fetch(`http://127.0.0.1:${port}/healthz`).then((r) => r.ok, () => false);
@@ -259,8 +289,14 @@ export class CodexPeer extends BasePeer {
259
289
  this.link = undefined;
260
290
  this.threadId = "";
261
291
  this.activeTurns.clear();
262
- for (const p of this.pending.values()) p.reject(new Error("codex TUI detached"));
292
+ for (const p of this.pending.values()) {
293
+ if (p.deliveryId) this.delivery({ id: p.deliveryId, state: "needs_review", reason: "Codex TUI detached" });
294
+ p.reject(new Error("codex TUI detached"));
295
+ }
263
296
  this.pending.clear();
297
+ for (const ids of this.turnDeliveries.values()) for (const id of ids) this.delivery({ id, state: "needs_review", reason: "Codex TUI detached" });
298
+ for (const id of this.unboundDeliveries) this.delivery({ id, state: "needs_review", reason: "Codex TUI detached" });
299
+ this.turnDeliveries.clear(); this.unboundDeliveries.clear();
264
300
  this.setState("offline");
265
301
  }
266
302
 
@@ -319,7 +355,9 @@ export class CodexPeer extends BasePeer {
319
355
  }
320
356
  if (link !== this.link || params.threadId !== this.threadId) return;
321
357
  if (method === "turn/started") {
322
- this.activeTurns.add(params.turn?.id ?? `unknown:${Date.now()}`);
358
+ const nativeTurn = params.turn?.id ?? `unknown:${Date.now()}`;
359
+ this.activeTurns.add(nativeTurn);
360
+ for (const id of [...this.unboundDeliveries]) this.addTurnDelivery(nativeTurn, id);
323
361
  this.lastAnswer = "";
324
362
  this.setState("busy");
325
363
  } else if (method === "item/agentMessage/delta") {
@@ -334,13 +372,18 @@ export class CodexPeer extends BasePeer {
334
372
  // Conclusions only: skip commentary, keep the last answer of the turn (phase may be absent).
335
373
  if (item.phase !== "commentary" && text.trim()) this.lastAnswer = text.trim();
336
374
  } else if (method === "turn/completed") {
337
- this.activeTurns.delete(params.turn?.id);
375
+ const nativeTurn = params.turn?.id;
376
+ this.activeTurns.delete(nativeTurn);
338
377
  for (const id of this.activeTurns) if (id.startsWith("unknown:")) this.activeTurns.delete(id);
339
378
  if (params.turn?.status === "failed") {
340
379
  this.opts.log?.(`[${this.id}] turn failed: ${params.turn.error?.message ?? "unknown error"}`);
341
380
  }
342
381
  if (this.activeTurns.size) return;
343
382
  this.abandonSteers("turn completed");
383
+ if (typeof nativeTurn === "string") {
384
+ for (const id of this.turnDeliveries.get(nativeTurn) ?? []) this.delivery({ id, state: params.turn?.status === "failed" ? "needs_review" : "completed", ...(params.turn?.status === "failed" ? { reason: params.turn?.error?.message ?? "Codex turn failed" } : {}) });
385
+ this.turnDeliveries.delete(nativeTurn);
386
+ }
344
387
  const inReplyTo = this.injected;
345
388
  this.injected = undefined;
346
389
  this.deltas.clear();
@@ -57,6 +57,7 @@ export class LocalPeer extends BasePeer {
57
57
  private readonly sessionId = `agent-hub-local-${randomUUID()}`;
58
58
  private turn = 0; // generation guard, as in acp.ts: a turn aborted by the watchdog must not touch the next one
59
59
  private abort: AbortController | undefined;
60
+ private activeDeliveryId: string | undefined;
60
61
  private readonly sandboxProfile: string; // built once: profile() spawns git and must stay off the per-call path
61
62
  /** What served the last call, for `ahub status`. */
62
63
  lastServedBy = "";
@@ -79,6 +80,8 @@ export class LocalPeer extends BasePeer {
79
80
  }
80
81
 
81
82
  async stop(): Promise<void> {
83
+ if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: "turn stopped before settlement" });
84
+ this.activeDeliveryId = undefined;
82
85
  this.turn++;
83
86
  this.abort?.abort();
84
87
  await this.opts.capture?.end();
@@ -86,10 +89,15 @@ export class LocalPeer extends BasePeer {
86
89
  }
87
90
 
88
91
  /** Resolves once the turn is claimed; a turn that cannot reach any model hands the envelopes back through onFailed. */
89
- async deliver(envs: Envelope[]): Promise<void> {
90
- if (this.state !== "idle") throw new Error(`${this.id} is ${this.state}`);
92
+ async deliver(envs: Envelope[], deliveryId?: string): Promise<void> {
93
+ if (this.state !== "idle") {
94
+ if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: `${this.id} is ${this.state}` });
95
+ throw new Error(`${this.id} is ${this.state}`);
96
+ }
91
97
  const turn = ++this.turn;
98
+ this.activeDeliveryId = deliveryId;
92
99
  this.setState("busy");
100
+ if (deliveryId) this.delivery({ id: deliveryId, state: "accepted" });
93
101
  // The turn works on its own message list and joins the history only as a whole, so a failed or aborted turn can
94
102
  // never leave a tool call without its result (strict servers reject that history forever after).
95
103
  const msgs: ChatMessage[] = [{ role: "user", content: renderDigest(envs, true) }];
@@ -103,24 +111,33 @@ export class LocalPeer extends BasePeer {
103
111
  if (turn !== this.turn) return;
104
112
  if (!policy?.pii) this.commit(msgs); // a PII turn leaves nothing in the history later turns send along
105
113
  if (answer) this.onMessage?.(answer, reply);
114
+ if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "completed" });
106
115
  })
107
116
  .catch((e: Error) => {
108
117
  if (turn !== this.turn) return; // aborted by the watchdog or stop(): nothing to report, nothing was committed
109
118
  this.opts.log?.(`[${this.id}] turn failed: ${e.message}`);
110
- if (!progress.sideEffects) return this.onFailed?.(envs); // nothing happened yet: safe to redeliver
119
+ if (!progress.sideEffects) {
120
+ if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: e.message });
121
+ else this.onFailed?.(envs); // legacy delivery: safe to redeliver
122
+ return;
123
+ }
111
124
  // Tools already changed things. Redelivering would redo approved writes and commits, so report instead.
112
125
  // A model call is the only thing that throws here, and every tool call before it has its result: msgs is consistent.
113
126
  const note = `(turn failed after ${progress.sideEffects} tool call(s) with side effects: ${e.message.slice(0, 200)}. The work may be partial; check before repeating it.) ${progress.last}`.trim();
114
127
  msgs.push({ role: "assistant", content: note });
115
128
  if (!policy?.pii) this.commit(msgs);
116
129
  this.onMessage?.(note, reply);
130
+ if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "needs_review", reason: e.message });
117
131
  })
118
132
  .finally(() => {
119
133
  if (turn === this.turn && this.state === "busy") this.setState("idle");
134
+ if (turn === this.turn && this.activeDeliveryId === deliveryId) this.activeDeliveryId = undefined;
120
135
  });
121
136
  }
122
137
 
123
138
  protected override onWatchdog(): void {
139
+ if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: "turn watchdog timeout" });
140
+ this.activeDeliveryId = undefined;
124
141
  this.turn++;
125
142
  this.abort?.abort();
126
143
  super.onWatchdog();
@@ -51,6 +51,7 @@ export class PiPeer extends BasePeer {
51
51
  private persistedOnce = false;
52
52
  private activeEnvs: Envelope[] = [];
53
53
  private activeTools = 0;
54
+ private readonly activeDeliveryIds = new Set<string>();
54
55
  private agentRunning = false;
55
56
  private owner = randomUUID();
56
57
  private _tuiLaunch?: PiTuiLaunch;
@@ -206,6 +207,8 @@ export class PiPeer extends BasePeer {
206
207
  }
207
208
 
208
209
  async stop(): Promise<void> {
210
+ for (const id of this.activeDeliveryIds) this.delivery({ id, state: "needs_review", reason: "Pi session stopped before settlement" });
211
+ this.activeDeliveryIds.clear();
209
212
  this.stopping = true;
210
213
  this.clearOwnerMonitor();
211
214
  if (this.opts.mode === "tui") {
@@ -240,8 +243,11 @@ export class PiPeer extends BasePeer {
240
243
  this.server?.stop(true); this.server = undefined; this.setState("offline");
241
244
  }
242
245
 
243
- async deliver(envs: Envelope[]): Promise<void> {
244
- if (this.state !== "idle" || this.stopping) throw new Error(`${this.id} is not ready`);
246
+ async deliver(envs: Envelope[], deliveryId?: string): Promise<void> {
247
+ if (this.state !== "idle" || this.stopping) {
248
+ if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: `${this.id} is not ready` });
249
+ throw new Error(`${this.id} is not ready`);
250
+ }
245
251
  this.setState("busy"); this.activeEnvs = envs; this.currentReply = replyParent(envs); this.settledText = "";
246
252
  let attempted = false;
247
253
  try {
@@ -249,23 +255,39 @@ export class PiPeer extends BasePeer {
249
255
  if (requested) await this.setRequestedModel(requested);
250
256
  this.noteActivity();
251
257
  attempted = true;
258
+ if (deliveryId) this.activeDeliveryIds.add(deliveryId);
252
259
  if (this.opts.mode === "tui") await this.sendTui({ type: "prompt", message: renderDigest(envs, true) });
253
260
  else await this.sendRpc({ type: "prompt", message: renderDigest(envs, true) });
261
+ if (deliveryId && this.activeDeliveryIds.has(deliveryId)) this.delivery({ id: deliveryId, state: "accepted" });
254
262
  } catch (error) {
255
- if (!attempted) { this.activeEnvs = []; this.setState("idle"); throw error; }
263
+ if (!attempted) {
264
+ this.activeEnvs = []; this.setState("idle");
265
+ if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: error instanceof Error ? error.message : String(error) });
266
+ throw error;
267
+ }
256
268
  // The prompt may have reached Pi even if its acknowledgement was lost.
257
269
  // Fence the owner before reporting failure; never return it to the bus for replay.
258
- await this.terminateFailedTurn(error instanceof Error ? error : new Error(String(error)));
270
+ const reason = error instanceof Error ? error.message : String(error);
271
+ if (deliveryId && /process is not running/i.test(reason)) {
272
+ this.activeDeliveryIds.delete(deliveryId);
273
+ this.delivery({ id: deliveryId, state: "failed_safe", reason });
274
+ }
275
+ await this.terminateFailedTurn(error instanceof Error ? error : new Error(reason));
259
276
  }
260
277
  }
261
278
 
262
- async steer(envs: Envelope[]): Promise<void> {
263
- if (this.state !== "busy" || this.stopping || envs.some((e) => e.kind !== "chat" || e.private || e.priority !== "important")) throw new Error("Pi workflow/private messages must queue");
279
+ async steer(envs: Envelope[], deliveryId?: string): Promise<void> {
280
+ if (this.state !== "busy" || this.stopping || envs.some((e) => e.kind !== "chat" || e.private || e.priority !== "important")) {
281
+ if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: "Pi workflow/private messages must queue" });
282
+ throw new Error("Pi workflow/private messages must queue");
283
+ }
264
284
  const reply = replyParent([...this.activeEnvs, ...envs]);
265
285
  this.activeEnvs.push(...envs); this.currentReply = reply;
266
286
  try {
287
+ if (deliveryId) this.activeDeliveryIds.add(deliveryId);
267
288
  if (this.opts.mode === "tui") await this.sendTui({ type: "steer", message: renderDigest(envs, true) });
268
289
  else await this.sendRpc({ type: "steer", message: renderDigest(envs, true) });
290
+ if (deliveryId && this.activeDeliveryIds.has(deliveryId)) this.delivery({ id: deliveryId, state: "accepted" });
269
291
  } catch (error) {
270
292
  await this.terminateFailedTurn(error instanceof Error ? error : new Error(String(error)));
271
293
  }
@@ -288,7 +310,7 @@ export class PiPeer extends BasePeer {
288
310
  if ((this.opts.sessionId && this.sessionId !== this.opts.sessionId) || (this.opts.sessionFile && this.sessionFile !== this.opts.sessionFile)) { this.opts.log?.(`[${this.id}] Pi session identity mismatch`); return; }
289
311
  this.startOwnerMonitor(); if (this.opts.mode === "tui") this.setState("idle");
290
312
  }
291
- if (event.type === "session_shutdown") { this.stopping = true; this.ownerClaimed = false; this.clearOwnerMonitor(); this.resolveTuiExit?.(); this.resolveTuiExit = undefined; this.setState("offline"); }
313
+ if (event.type === "session_shutdown") { this.stopping = true; this.ownerClaimed = false; this.clearOwnerMonitor(); this.resolveTuiExit?.(); this.resolveTuiExit = undefined; this.fail(new Error("Pi session shut down before settlement")); }
292
314
  if (event.type === "agent_start") { this.noteActivity(); this.agentRunning = true; this.setState("busy"); }
293
315
  if (event.type === "activity" && this.state === "busy") this.touch();
294
316
  if (event.type === "agent_end") {
@@ -307,6 +329,8 @@ export class PiPeer extends BasePeer {
307
329
  if (cancelled) this.onMessage?.("Pi turn cancelled; inspect any partial effects before continuing.", reply);
308
330
  else if (error) void this.opts.onTurnFailure?.(this.activeEnvs, error);
309
331
  else if (text) this.onMessage?.(text, reply);
332
+ for (const id of this.activeDeliveryIds) this.delivery({ id, state: error || cancelled ? "needs_review" : "completed", ...(error || cancelled ? { reason: error || "Pi turn cancelled; partial effects are possible" } : {}) });
333
+ this.activeDeliveryIds.clear();
310
334
  this.currentReply = undefined; this.activeEnvs = []; if (this.state === "busy" && !this.activeTools) this.setState("idle");
311
335
  }
312
336
  }
@@ -346,6 +370,8 @@ export class PiPeer extends BasePeer {
346
370
  for (const pending of this.pending.values()) pending.reject(error);
347
371
  this.pending.clear();
348
372
  const envs = this.activeEnvs; this.activeEnvs = [];
373
+ for (const id of this.activeDeliveryIds) this.delivery({ id, state: "needs_review", reason: error.message });
374
+ this.activeDeliveryIds.clear();
349
375
  if (envs.length) {
350
376
  this.onMessage?.("Pi turn failed; inspect its session and any partial effects before continuing.", { inReplyTo: this.currentReply });
351
377
  void this.opts.onTurnFailure?.(envs, error.message).catch(() => this.opts.log?.("Pi failure handoff could not be completed"));
@@ -0,0 +1,8 @@
1
+ /** Validate option-like tokens without discarding message text before the separator. */
2
+ export function freeText(words: string[], usage: string): string {
3
+ const end = words.indexOf("--");
4
+ const before = end === -1 ? words : words.slice(0, end);
5
+ const flag = before.find((a) => a.startsWith("--"));
6
+ if (flag) throw new Error(`the message would absorb the flag "${flag}"; put it after "--" to send it as text; usage: ${usage}`);
7
+ return (end === -1 ? words : [...before, ...words.slice(end + 1)]).join(" ");
8
+ }
package/src/cli/main.ts CHANGED
@@ -17,6 +17,7 @@ import { buildLaunch, UNATTENDED_WARNING } from "./launch.ts";
17
17
  import { nextStep, parseList, pluginState, type InstalledPlugin, type Marketplace } from "./setup.ts";
18
18
  import { CLASSES } from "../hub/board.ts";
19
19
  import { VERSION } from "../version.ts";
20
+ import { freeText } from "./free-text.ts";
20
21
  import { createInterface } from "node:readline/promises";
21
22
  import { assertLifecycleAvailable, readOperation } from "../hub/recovery-store.ts";
22
23
  import { childEnv } from "../hub/child-process.ts";
@@ -66,6 +67,9 @@ const USAGE = `agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one
66
67
  ahub ui [--no-open] open the local dashboard (or print its one-time link)
67
68
  ahub tail live stream of messages, states and permission requests
68
69
  ahub permit <id> <option> answer a permission request shown by tail ("deny" cancels)
70
+ ahub queue list [--peer <id>] [--json] inspect durable deliveries
71
+ ahub queue show <delivery-id> inspect one delivery and revision
72
+ ahub queue resolve <delivery-id> --action completed|retry|discard --reason <text>
69
73
  ahub status | logs [-f] | doctor | kill`;
70
74
 
71
75
  const argv = process.argv.slice(2);
@@ -446,7 +450,7 @@ const commands: Record<string, () => Promise<void> | void> = {
446
450
  say: async () => {
447
451
  const lead = args.findIndex((a) => !/^@[a-z][a-z0-9-]*$/.test(a)); // only leading @tokens are recipients
448
452
  const to = args.slice(0, lead === -1 ? args.length : lead).map((a) => a.slice(1));
449
- const body = lead === -1 ? "" : args.slice(lead).join(" ");
453
+ const body = lead === -1 ? "" : freeText(args.slice(lead), "ahub say [@peer ...] <text>");
450
454
  const hub = await connect();
451
455
  const res = await hub.request({ t: "send", body, to });
452
456
  hub.close();
@@ -455,6 +459,36 @@ const commands: Record<string, () => Promise<void> | void> = {
455
459
  console.log(res.targets.length ? `queued for: ${res.targets.join(", ")}` : "no peers attached; nothing delivered");
456
460
  },
457
461
 
462
+ queue: async () => {
463
+ const [operation = "list", ...rest] = args;
464
+ const hub = await connect();
465
+ try {
466
+ if (operation === "list") {
467
+ const { one, rest: flags } = takeFlags(rest, ["--peer"], []);
468
+ if (flags.some((flag) => flag !== "--json")) fail("usage: ahub queue list [--peer <id>] [--json]");
469
+ const result = await hub.request({ t: "queue", op: "list", ...(one["--peer"] ? { peer: one["--peer"] } : {}) });
470
+ if (!result.ok) fail(result.error);
471
+ if (flags.includes("--json")) return console.log(JSON.stringify(result.deliveries, null, 2));
472
+ for (const row of result.deliveries) console.log(`${row.id} ${row.peer} ${row.state} revision ${row.revision}${row.important ? " important" : ""}`);
473
+ if (!result.deliveries.length) console.log("no retained deliveries");
474
+ return;
475
+ }
476
+ const [id, ...flags] = rest;
477
+ if (!id || !["show", "resolve"].includes(operation)) fail("usage: ahub queue show <id> | resolve <id> --action completed|retry|discard --reason <text>");
478
+ const shown = await hub.request({ t: "queue", op: "show", id });
479
+ if (!shown.ok) fail(shown.error);
480
+ if (operation === "show") {
481
+ if (flags.length) fail("usage: ahub queue show <id>");
482
+ return console.log(JSON.stringify(shown.delivery, null, 2));
483
+ }
484
+ const { one, rest: extra } = takeFlags(flags, ["--action", "--reason"], []);
485
+ if (extra.length || !["completed", "retry", "discard"].includes(one["--action"] ?? "") || !one["--reason"]?.trim()) fail("usage: ahub queue resolve <id> --action completed|retry|discard --reason <text>");
486
+ const result = await hub.request({ t: "queue", op: "resolve", id, revision: shown.delivery.revision, action: one["--action"], reason: one["--reason"] });
487
+ if (!result.ok) fail(result.error);
488
+ console.log(JSON.stringify(result.delivery, null, 2));
489
+ } finally { hub.close(); }
490
+ },
491
+
458
492
  tail: async () => {
459
493
  const hub = await connect();
460
494
  hub.onPush = (msg) => {
@@ -528,7 +562,7 @@ const commands: Record<string, () => Promise<void> | void> = {
528
562
  console.log(await taskOp("hub_review", { id: Number(id), verdict, note: note.join(" ") }));
529
563
  },
530
564
 
531
- remember: async () => console.log(await taskOp("hub_remember", { text: args.join(" ") })),
565
+ remember: async () => console.log(await taskOp("hub_remember", { text: freeText(args, "ahub remember <text>") })),
532
566
 
533
567
  ask: async () => {
534
568
  const remember = args.includes("--remember");
@@ -571,7 +605,9 @@ const commands: Record<string, () => Promise<void> | void> = {
571
605
  const hub = await connect();
572
606
  const { status } = await hub.request({ t: "status" });
573
607
  hub.close();
608
+ if (args.includes("--json")) return console.log(JSON.stringify(status, null, 2));
574
609
  console.log(`hub pid ${status.pid}, control 127.0.0.1:${status.controlPort}, ${status.cwd}`);
610
+ if (status.deliveryError) console.log(` delivery storage: ${status.deliveryError}; dispatch is stopped`);
575
611
  const peers = Object.entries(status.peers as Record<string, PeerRow>);
576
612
  for (const [id, p] of peers) console.log(peerLine(id, p));
577
613
  const models = (status as any).models?.backends as BackendRow[] | undefined;
@@ -611,6 +647,20 @@ const commands: Record<string, () => Promise<void> | void> = {
611
647
  }
612
648
  const up = await healthy();
613
649
  row(up, "ahub daemon", up ? readControl(stateDir)!.url : "not running (ahub up)");
650
+ if (up) {
651
+ const hub = await connect();
652
+ try {
653
+ const { status } = await hub.request({ t: "status" });
654
+ const pending = Object.values(status.peers as Record<string, PeerRow>).reduce((n, peer) => n + (peer.needsReview ?? 0), 0);
655
+ row(!status.deliveryError && pending === 0, "delivery recovery", status.deliveryError ?? (pending ? `${pending} deliveries need review; run ahub queue list` : "journal healthy; no deliveries need review"));
656
+ const observed = await hub.request({ t: "recovery", op: "inspect", expectedInstanceId: status.instanceId });
657
+ for (const peer of Object.values(observed.recovery?.peers ?? {}) as { id: string; state: string; sessionId?: string; threadId?: string }[]) {
658
+ if (!["claude", "codex"].includes(peer.id) || peer.state === "offline") continue;
659
+ const identity = peer.id === "claude" ? peer.sessionId : peer.threadId;
660
+ row(!!identity, `${peer.id} recovery ID`, identity ? "recorded" : `missing; verify the current conversation and reconnect with ahub ${peer.id} before upgrading`);
661
+ }
662
+ } finally { hub.close(); }
663
+ }
614
664
  const plugin = pluginState(parseList<InstalledPlugin>(spawnSync("claude", ["plugin", "list", "--json"], { encoding: "utf8" }).stdout ?? ""), join(import.meta.dir, "..", ".."));
615
665
  row(plugin.state === "current", "claude plugin", plugin.state === "missing" ? "missing: run ahub setup" : plugin.state === "current" ? `agent-hub@agent-hub ${plugin.version}` : `agent-hub@agent-hub is stale (${plugin.why}): run ahub setup`);
616
666
 
@@ -1,6 +1,6 @@
1
1
  // What `ahub status` prints for a peer and for a model backend, kept pure so it can be checked.
2
2
 
3
- export interface PeerRow { state?: string; queued?: number; paused?: string; servedBy?: string; requestedModel?: string }
3
+ export interface PeerRow { state?: string; queued?: number; queuedImportant?: number; needsReview?: number; oldestQueuedAt?: number; attached?: boolean; paused?: string; servedBy?: string; requestedModel?: string }
4
4
  export interface BackendRow { kind?: string; alias?: string; state?: string; active?: number; requestedModel?: string; actualModel?: string; provider?: string }
5
5
 
6
6
  /** Aliases are already namespaced ("dgx/coding"); only an alias that is not gets its kind in front of it. */
@@ -12,6 +12,11 @@ export function backendLabel(backend: BackendRow): string {
12
12
 
13
13
  export function peerLine(id: string, p: PeerRow): string {
14
14
  return ` ${id.padEnd(8)} ${(p.state ?? "unknown").padEnd(8)} queued ${p.queued ?? 0}` +
15
+ // Which queued messages the peer will not wait out the batch window for (issue #41).
16
+ (p.queuedImportant ? ` (${p.queuedImportant} important)` : "") +
17
+ (p.needsReview ? ` needs review ${p.needsReview}` : "") +
18
+ (p.oldestQueuedAt !== undefined ? ` oldest ${Math.max(0, Math.floor((Date.now() - p.oldestQueuedAt) / 1000))}s` : "") +
19
+ (p.attached === false ? " disconnected" : "") +
15
20
  (p.paused ? ` (${p.paused})` : "") +
16
21
  (p.servedBy ? ` last call: ${p.servedBy}` : "") +
17
22
  // Which backend the peer asked for on its last turn: without it, telling a Pi DGX turn from an MLX one
@@ -66,6 +66,10 @@ export interface RecoveryBlocker {
66
66
  message: string;
67
67
  peer?: TerminalPeer;
68
68
  handle?: string;
69
+ /** Stable terminal reference when Orca supplied one, for operator reconciliation. */
70
+ terminalReference?: string;
71
+ /** Human action that can resolve the blocker without guessing or retrying an effect. */
72
+ nextAction?: string;
69
73
  }
70
74
 
71
75
  export interface TerminalInspection {
@@ -399,7 +403,13 @@ function launchFor(peer: TerminalPeer, projectRoot: string, sessionId: string, o
399
403
  }
400
404
 
401
405
  function blocker(code: RecoveryBlocker["code"], message: string, peer?: TerminalPeer, handle?: string): RecoveryBlocker {
402
- return { code, message, ...(peer ? { peer } : {}), ...(handle ? { handle } : {}) };
406
+ const nextAction = code === "missing-session" ? "reconnect the original native session and rerun recovery" :
407
+ code === "ownership-unknown" ? "confirm the terminal owner in Orca, then resume recovery" :
408
+ code === "ambiguous-terminal" ? "close or identify the duplicate terminal, then resume recovery" :
409
+ code === "ambiguous-create" ? "inspect the existing terminal and attach the original session before resuming" :
410
+ code === "terminal-unready" ? "finish or cancel the active terminal turn, then resume recovery" :
411
+ "inspect the named terminal and resume recovery after the identity is verified";
412
+ return { code, message, ...(peer ? { peer } : {}), ...(handle ? { handle, terminalReference: handle } : {}), nextAction };
403
413
  }
404
414
 
405
415
  function verifyBindingData(actual: Record<string, unknown>, binding: TerminalBinding): RecoveryBlocker | undefined {
@@ -547,6 +557,36 @@ export async function createTerminal(binding: TerminalBinding, options?: Command
547
557
  const config = normalizeOptions(options);
548
558
  try {
549
559
  const worktreeSelector = binding.worktreeId.startsWith("id:") ? binding.worktreeId : `id:${binding.worktreeId}`;
560
+ // A lost create reply is an uncertain effect. Before issuing another create,
561
+ // reconcile terminals already present in the captured worktree. An exact
562
+ // session match is safe to adopt; any other occupant requires an operator
563
+ // decision rather than risking a duplicate native session.
564
+ const existing = listTerminals(await run(config.runner, ["terminal", "list", "--json"])).filter((terminal) =>
565
+ rootMatches(terminal.worktreePath ?? terminal.projectRoot, binding.projectRoot) &&
566
+ nestedString(terminal, ["worktreeId"]) === binding.worktreeId,
567
+ );
568
+ if (existing.length > 0) {
569
+ const matches: TerminalBinding[] = [];
570
+ for (const listed of existing) {
571
+ const handle = nestedString(listed, ["handle"]);
572
+ if (!handle) continue;
573
+ try {
574
+ const shown = terminalObject(await run(config.runner, ["terminal", "show", "--terminal", handle, "--json"]));
575
+ if (identityFrom(shown) === binding.peer && sessionFrom(shown) === binding.sessionId) {
576
+ const incarnationId = nestedString(shown, ["incarnationId"]);
577
+ const worktreeId = nestedString(shown, ["worktreeId"]);
578
+ if (incarnationId && worktreeId) matches.push({ ...binding, handle, incarnationId, worktreeId });
579
+ }
580
+ } catch { /* An unreadable candidate remains an ambiguity below. */ }
581
+ }
582
+ if (matches.length === 1) {
583
+ const recovered = matches[0]!;
584
+ const idle = await waitForIdle(recovered, timeoutMs, config);
585
+ if (!idle.satisfied) return { created: false, ready: false, manualRequired: true, blockers: idle.blockers };
586
+ return { created: true, ready: true, manualRequired: false, binding: recovered, newBinding: recovered, blockers: [] };
587
+ }
588
+ return { created: false, ready: false, manualRequired: true, blockers: [blocker("ambiguous-create", `an existing terminal in worktree ${binding.worktreeId} may be the result of an earlier create; refusing another create`, binding.peer)] };
589
+ }
550
590
  const value = await run(config.runner, ["terminal", "create", "--worktree", worktreeSelector, "--command", binding.launch.command, "--title", `${binding.peer} recovery`, "--json"]);
551
591
  let created = terminalObject(value);
552
592
  const createdHandle = nestedString(created, ["handle"]);
@@ -1,7 +1,7 @@
1
1
  import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
- import { ControlClient, PROTOCOL, readControl } from "../hub/control-client.ts";
4
+ import { ControlClient, PROTOCOL, RECOVERY_SOURCE_PROTOCOLS, readControl } from "../hub/control-client.ts";
5
5
  import { inspectProject } from "../hub/lifecycle.ts";
6
6
  import type { Project } from "../hub/registry.ts";
7
7
  import { hubHome } from "../hub/project.ts";
@@ -41,7 +41,7 @@ export async function inspectRecovery(project: Project): Promise<Inspection> {
41
41
  const base = await inspectProject(project);
42
42
  const control = readControl(project.stateDir);
43
43
  const sourceProtocol = control?.protocol;
44
- const legacySupported = sourceProtocol === 8;
44
+ const legacySupported = sourceProtocol !== undefined && RECOVERY_SOURCE_PROTOCOLS.includes(sourceProtocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number]);
45
45
  if (base.state !== "running" && !legacySupported) return {
46
46
  state: base.state, peers: [], blockers: base.state === "stopped" ? [] : [base.state === "incompatible" ? "manual-bootstrap-required: source lacks the recovery contract; use its matching CLI" : base.error ?? base.state],
47
47
  ...(control?.instanceId ? { instanceId: control.instanceId } : {}), ...(control?.protocol ? { protocol: control.protocol } : {}),
@@ -84,7 +84,7 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
84
84
  if (existsSync(managerFile)) {
85
85
  try {
86
86
  const manager = JSON.parse(readFileSync(managerFile, "utf8"));
87
- if (![8, PROTOCOL].includes(manager.protocol)) body.blockers.push("manager requires manual bootstrap with its matching CLI before protocol-9 recovery");
87
+ if (!RECOVERY_SOURCE_PROTOCOLS.includes(manager.protocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number])) body.blockers.push("manager requires manual bootstrap with its matching CLI before protocol-10 recovery");
88
88
  } catch { body.blockers.push("manager ownership manifest is unreadable"); }
89
89
  }
90
90
  for (const project of projects) {
@@ -94,24 +94,24 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
94
94
  catch { source = { state: "unavailable", peers: [], blockers: ["source recovery metadata could not be authenticated"] }; }
95
95
  if (source.state === "stopped" || source.state === "missing") continue;
96
96
  const blockers = [...source.blockers];
97
- if (![8, PROTOCOL].includes(source.protocol ?? 0) || source.state !== "running") blockers.push("manual-bootstrap-required: an authenticated recovery-capable source is required");
97
+ if (!RECOVERY_SOURCE_PROTOCOLS.includes(source.protocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number]) || source.state !== "running") blockers.push("manual-bootstrap-required: an authenticated protocol-9 or protocol-10 source is required");
98
98
  if (source.recovery?.operationId && source.recovery.phase !== "released") blockers.push(`existing recovery operation ${source.recovery.operationId} must be resolved first`);
99
99
  const sessions: { codex?: string; claude?: string; pi?: SessionRef } = {};
100
100
  for (const peer of source.peers) {
101
101
  if (peer.state === "offline") continue;
102
102
  if (peer.id === "codex" || peer.id === "claude") {
103
103
  const session = peer.id === "codex" ? peer.threadId : peer.sessionId;
104
- if (!session) blockers.push(`${peer.id}: original conversation ID is unknown; manual-required`);
104
+ if (!session) blockers.push(`${peer.id}: original conversation ID is unknown; manual-required; next action: reconnect the original native session and make a new recovery plan`);
105
105
  else sessions[peer.id] = session;
106
106
  } else if (peer.id === "pi" && peer.args?.mode === "tui") {
107
- if (!peer.sessionId) blockers.push("pi: original session ID is unknown; manual-required");
107
+ if (!peer.sessionId) blockers.push("pi: original session ID is unknown; manual-required; next action: reconnect the original Pi session and make a new recovery plan");
108
108
  else sessions.pi = { sessionId: peer.sessionId, ...(peer.sessionFile ? { sessionFile: peer.sessionFile } : {}), ...(peer.args.backend ? { backend: peer.args.backend } : {}), ...(peer.args.model ? { model: peer.args.model } : {}) };
109
109
  } else if (peer.id !== "kimi" && peer.id !== "local" && peer.id !== "pi") blockers.push(`${peer.id}: no automatic recovery adapter`);
110
110
  }
111
111
  const terminals = await inspectTerminals(project.root, sessions, {
112
112
  ...terminalOptions(run), stateDir: project.stateDir, instanceId: source.instanceId,
113
113
  });
114
- blockers.push(...terminals.blockers.map((b) => b.message));
114
+ blockers.push(...terminals.blockers.map((b) => `${b.message}${b.terminalReference ? ` (terminal ${b.terminalReference})` : ""}${b.nextAction ? `; next action: ${b.nextAction}` : ""}`));
115
115
  body.projects.push({ project, source, terminals: terminals.bindings, blockers });
116
116
  }
117
117
  if (!body.projects.length) body.blockers.push("no running registered projects in scope");
@@ -169,7 +169,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
169
169
  };
170
170
  const control = (project: Project, op: string, id: string, instance: string) => {
171
171
  const sourceProtocol = readControl(project.stateDir)?.protocol;
172
- const protocol = sourceProtocol === 8 ? 8 : PROTOCOL;
172
+ const protocol = sourceProtocol !== undefined && RECOVERY_SOURCE_PROTOCOLS.includes(sourceProtocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number]) ? sourceProtocol : PROTOCOL;
173
173
  return rpc(project, { t: "recovery", op, operationId: id, expectedInstanceId: instance }, protocol).then(() => {});
174
174
  };
175
175
  const revalidateTerminal = async (planned: PlannedProject, progress: ProjectProgress, saved: TerminalBinding, exact: boolean): Promise<TerminalBinding> => {