@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.
- package/CHANGELOG.md +14 -0
- package/README.md +16 -15
- package/docs/operations.md +217 -0
- package/docs/quickstart.md +121 -0
- package/docs/security.md +57 -0
- package/docs/smoke.md +525 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +675 -0
- package/docs/specs/2026-09-19-multi-project-design.md +95 -0
- package/docs/specs/2026-09-20-durable-delivery-design.md +67 -0
- package/docs/specs/2026-09-20-pi-local-models-design.md +40 -0
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +151 -0
- package/package.json +2 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +32 -11
- package/src/adapters/acp.ts +40 -7
- package/src/adapters/claude-channel.ts +22 -5
- package/src/adapters/codex-appserver.ts +50 -7
- package/src/adapters/local-worker.ts +20 -3
- package/src/adapters/pi.ts +33 -7
- package/src/cli/free-text.ts +8 -0
- package/src/cli/main.ts +52 -2
- package/src/cli/status-lines.ts +6 -1
- package/src/cli/terminal-recovery.ts +41 -1
- package/src/cli/upgrade-runtime.ts +8 -8
- package/src/hub/bus.ts +307 -37
- package/src/hub/control-client.ts +5 -3
- package/src/hub/daemon.ts +109 -20
- package/src/hub/delivery-journal.ts +830 -0
- package/src/hub/envelope.ts +8 -0
- package/src/hub/manager.ts +6 -4
- package/src/hub/peers.ts +16 -3
- package/src/hub/restart.ts +2 -0
- package/src/ui/index.html +7 -3
|
@@ -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
|
-
|
|
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())
|
|
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
|
-
|
|
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
|
-
|
|
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")
|
|
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)
|
|
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();
|
package/src/adapters/pi.ts
CHANGED
|
@@ -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)
|
|
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) {
|
|
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
|
-
|
|
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"))
|
|
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.
|
|
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)
|
|
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
|
|
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
|
|
package/src/cli/status-lines.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 (!
|
|
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 (!
|
|
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
|
|
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> => {
|