@staix/agent-hub 0.12.34 → 0.12.36
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 +27 -0
- package/README.md +8 -6
- package/docs/agent-notes/adapters.md +6 -1
- package/docs/agent-notes/budget.md +4 -2
- package/docs/agent-notes/bus.md +5 -1
- package/docs/agent-notes/daemon.md +11 -3
- package/docs/agent-notes/local-worker.md +1 -1
- package/docs/agent-notes/tasks.md +15 -2
- package/docs/agent-notes/tests.md +5 -1
- package/docs/gsd-collaboration-analysis.md +5 -1
- package/docs/operations.md +97 -19
- package/docs/quickstart.md +8 -4
- package/docs/sdlc.md +100 -34
- package/docs/security.md +7 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +237 -7
- package/docs/verification/2026-10-18-0.12.35.md +49 -0
- package/docs/verification/2026-10-18-493-claude-busy-channel.md +91 -0
- package/docs/verification/2026-10-19-0.12.36.md +63 -0
- package/docs/verified.json +26 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +15781 -14850
- package/src/adapters/acp.ts +3 -1
- package/src/adapters/claude-channel.ts +59 -29
- package/src/adapters/claude-print.ts +230 -0
- package/src/adapters/codex-appserver.ts +1 -1
- package/src/adapters/definitions/builtin-metadata.ts +17 -1
- package/src/adapters/definitions/native-cli-entry-claude.ts +17 -3
- package/src/adapters/definitions/native-cli-entry-codex.ts +13 -5
- package/src/adapters/definitions/native-cli-entry-jcode.ts +6 -2
- package/src/adapters/definitions/native-cli-entry-kimi.ts +18 -12
- package/src/adapters/definitions/native-cli-entry-local.ts +3 -2
- package/src/adapters/definitions/native-cli-entry-pi.ts +8 -6
- package/src/adapters/definitions/native-cli-entry-qwen.ts +3 -2
- package/src/adapters/definitions/native-cli-entry-types.ts +3 -0
- package/src/adapters/definitions/native-factories.ts +7 -0
- package/src/adapters/definitions/native-recovery.ts +2 -2
- package/src/adapters/definitions/qwen-native.ts +2 -2
- package/src/adapters/local-worker.ts +3 -2
- package/src/adapters/pi.ts +3 -1
- package/src/adapters/registry-types.ts +11 -2
- package/src/adapters/registry.ts +36 -8
- package/src/cli/help.ts +5 -0
- package/src/cli/identity.ts +5 -4
- package/src/cli/launch.ts +45 -7
- package/src/cli/main.ts +136 -29
- package/src/cli/preview.ts +16 -5
- package/src/cli/recovery-guidance.ts +39 -22
- package/src/cli/reset.ts +4 -2
- package/src/cli/statusline-tee.ts +5 -2
- package/src/cli/terminal-recovery.ts +73 -16
- package/src/cli/upgrade-restore-state.ts +16 -7
- package/src/cli/upgrade-runtime.ts +83 -52
- package/src/cli/upgrade.ts +43 -27
- package/src/hub/board.ts +29 -7
- package/src/hub/budget.ts +55 -3
- package/src/hub/bus.ts +12 -6
- package/src/hub/claude-state.ts +37 -0
- package/src/hub/conductor.ts +16 -10
- package/src/hub/config-apply.ts +13 -2
- package/src/hub/config-reload.ts +11 -1
- package/src/hub/config-trust.ts +1 -1
- package/src/hub/control-client.ts +1 -1
- package/src/hub/cost-governance.ts +76 -0
- package/src/hub/crash.ts +14 -5
- package/src/hub/daemon.ts +473 -194
- package/src/hub/execution-budget.ts +3 -3
- package/src/hub/hub-tools.ts +36 -6
- package/src/hub/instance-budget.ts +73 -0
- package/src/hub/instance-config.ts +47 -0
- package/src/hub/instance-execution.ts +50 -0
- package/src/hub/instances.ts +114 -0
- package/src/hub/lifecycle-schema.ts +7 -3
- package/src/hub/permission-mode.ts +7 -7
- package/src/hub/review-brief.ts +175 -0
- package/src/hub/roster.ts +7 -6
- package/src/hub/routing.ts +97 -18
- package/src/hub/sdlc-queue.ts +2 -2
- package/src/hub/sdlc.ts +286 -39
- package/src/hub/settings.ts +34 -6
- package/src/hub/snapshots.ts +40 -9
- package/src/hub/start-mode.ts +7 -4
- package/src/hub/status-roster.ts +18 -0
- package/src/hub/task-sweep.ts +143 -0
- package/src/hub/tasks.ts +595 -47
- package/src/hub/text-projection.ts +7 -3
- package/src/hub/usage.ts +57 -20
- package/src/hub/workflow.ts +30 -52
- package/src/pi/launch.ts +4 -3
- package/src/ui/index.html +1 -1
- package/templates/AGENTS.block.md +2 -1
package/src/hub/config-apply.ts
CHANGED
|
@@ -8,6 +8,8 @@ import { catalogAssignments } from "./roles.ts";
|
|
|
8
8
|
import { resolveProfile } from "./profiles.ts";
|
|
9
9
|
import { taskSweepConfig } from "./task-sweep.ts";
|
|
10
10
|
import { projectionConfig } from "./text-projection.ts";
|
|
11
|
+
import { validateInstances, type InstancesConfig } from "./instances.ts";
|
|
12
|
+
import { peerRosterConfig } from "./roster.ts";
|
|
11
13
|
|
|
12
14
|
export type ApplyClass = "live" | "next use" | "restart";
|
|
13
15
|
export interface ApplyRule { applies: ApplyClass; reason?: string }
|
|
@@ -108,6 +110,7 @@ const schema: Record<string, Check> = {
|
|
|
108
110
|
...Object.assign({}, ...ADAPTERS.map(adapter => adapter.configuration?.schema?.(nativeValidators) ?? {})),
|
|
109
111
|
watchdog_ms: number(1, 2_147_483_647), kimi_cmd: argv, qwen_cmd: argv, jcode_cmd: argv, codex_bin: text, jcode_home: text, jcode_provider_profile: text,
|
|
110
112
|
permission_modes: v => { permissionDefaults(v); }, protect_instruction_files: bool,
|
|
113
|
+
instances: v => { validateInstances(v); },
|
|
111
114
|
batch_max: number(1, 100_000, true), batch_ms: number(0, 2_147_483_647), queue_cap: number(1, 1_000_000, true),
|
|
112
115
|
memory: block({ enabled: bool, worker_url: text, inject_tokens: number(0, 1_000_000, true), brief_items: number(0, 10_000, true), native: bool,
|
|
113
116
|
short_ttl_days: number(1, 3650, true), sync_interval_min: number(0, 1440, true), suggest_edges: bool }),
|
|
@@ -138,7 +141,14 @@ const schema: Record<string, Check> = {
|
|
|
138
141
|
review: block({ adaptive: bool, min_reviews: number(1, Number.MAX_SAFE_INTEGER, true) }), recovery: block({ auto_resume_after_crash: bool }),
|
|
139
142
|
capabilities: map(strings), coordination: words(["advisory", "turn-free"]), experiments: block({ stale_notices: words(["drop", "deliver"]) }),
|
|
140
143
|
};
|
|
141
|
-
export function validateRawConfig(raw: unknown): asserts raw is Record<string, unknown> {
|
|
144
|
+
export function validateRawConfig(raw: unknown, declarations?: InstancesConfig): asserts raw is Record<string, unknown> {
|
|
145
|
+
const value = object(raw, "config");
|
|
146
|
+
const instances = validateInstances(value.instances ?? declarations);
|
|
147
|
+
if (!Object.keys(instances).length) return block(schema)(raw, "config");
|
|
148
|
+
block({ ...schema, permission_modes: v => { permissionDefaults(v, instances); },
|
|
149
|
+
roster: v => { peerRosterConfig(v, instances); }, peers: v => { peerStartConfig(v, instances); },
|
|
150
|
+
})(raw, "config");
|
|
151
|
+
}
|
|
142
152
|
export function validateEffectiveConfig(value: unknown): asserts value is HubConfig {
|
|
143
153
|
const raw = { ...object(value, "config") };
|
|
144
154
|
delete raw.ignored; delete raw.retired; delete raw.permission_default_sources;
|
|
@@ -149,6 +159,7 @@ export function validateEffectiveConfig(value: unknown): asserts value is HubCon
|
|
|
149
159
|
}
|
|
150
160
|
|
|
151
161
|
const restart: Record<string, string> = {
|
|
162
|
+
instances: "Native instance identity, worktree, ports and account bindings are fixed at daemon startup",
|
|
152
163
|
...Object.assign({}, ...ADAPTERS.map(adapter => adapter.configuration?.restartReasons ?? {})),
|
|
153
164
|
memory: "Open memory consumers, selected profile, client and sync timer must be quiesced together",
|
|
154
165
|
omniroute: "Credentials and gateway clients are bound to relay ownership", mlx: "Model runtime ownership and device resources belong to this daemon",
|
|
@@ -174,7 +185,7 @@ export function configApply(key: string): ApplyRule {
|
|
|
174
185
|
if (shape?.fields) { if (!Object.hasOwn(shape.fields, part)) return fail(key, "unknown configuration key"); check = shape.fields[part]; }
|
|
175
186
|
else if (shape?.item) { if (shape.keys && !shape.keys.includes(part)) return fail(key, "unknown configuration key"); check = shape.item; }
|
|
176
187
|
else if (index === 0 && (special[top]?.includes(part) || top === "checks" && /^[a-z][a-z0-9_]{0,47}$/.test(part))) break;
|
|
177
|
-
else if (["permission_modes", "catalog_roles", "profile_overrides", "text_projection", "peers"].includes(top)) break;
|
|
188
|
+
else if (["permission_modes", "catalog_roles", "profile_overrides", "text_projection", "peers", "instances"].includes(top)) break;
|
|
178
189
|
else return fail(key, "unknown configuration key");
|
|
179
190
|
}
|
|
180
191
|
}
|
package/src/hub/config-reload.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { join } from "node:path";
|
|
|
5
5
|
import { validateRawConfig, type ConfigLayer } from "./config-apply.ts";
|
|
6
6
|
import { parseOverlay, parseRouting, type Routing } from "./routing.ts";
|
|
7
7
|
import { CLASSES } from "./board.ts";
|
|
8
|
+
import { validateInstances } from "./instances.ts";
|
|
8
9
|
|
|
9
10
|
export const CONFIG_SOURCE_FILES = ["config.json", "config.local.json", "routing.toml", "routing.local.toml"] as const;
|
|
10
11
|
export type ConfigSourceName = typeof CONFIG_SOURCE_FILES[number];
|
|
@@ -90,6 +91,15 @@ function validateRoutingRaw(value: unknown): void {
|
|
|
90
91
|
/** Strict reloads never use routing's last-good fallback. Startup can retain its existing fallback explicitly. */
|
|
91
92
|
export function parseConfigSources(texts: ConfigSourceTexts, fallbackRouting?: Routing): ConfigSources {
|
|
92
93
|
const layers: ConfigLayer[] = [], trustLayers: ConfigSources["trustLayers"] = [];
|
|
94
|
+
let localRaw: Record<string, unknown> = {};
|
|
95
|
+
try { localRaw = JSON.parse(texts["config.local.json"] ?? "{}"); }
|
|
96
|
+
catch { if (!fallbackRouting) throw new Error(".agenthub/config.local.json: invalid JSON object"); }
|
|
97
|
+
let declared;
|
|
98
|
+
try { declared = validateInstances(localRaw?.instances); }
|
|
99
|
+
catch (error) {
|
|
100
|
+
if (!fallbackRouting) throw new Error(`.agenthub/config.local.json: ${(error as Error).message}`);
|
|
101
|
+
declared = validateInstances(undefined);
|
|
102
|
+
}
|
|
93
103
|
for (const name of CONFIG_SOURCE_FILES) {
|
|
94
104
|
const text = texts[name];
|
|
95
105
|
if (text === null) continue;
|
|
@@ -104,7 +114,7 @@ export function parseConfigSources(texts: ConfigSourceTexts, fallbackRouting?: R
|
|
|
104
114
|
}
|
|
105
115
|
if (name === "config.json" || name === "config.local.json") {
|
|
106
116
|
if (!fallbackRouting) {
|
|
107
|
-
try { validateRawConfig(raw); }
|
|
117
|
+
try { validateRawConfig(raw, declared); }
|
|
108
118
|
catch (error) { throw new Error(`.agenthub/${name}: ${(error as Error).message}`); }
|
|
109
119
|
}
|
|
110
120
|
layers.push({ name, raw });
|
package/src/hub/config-trust.ts
CHANGED
|
@@ -29,7 +29,7 @@ export const MACHINE_LOCAL = [
|
|
|
29
29
|
"terminal.open", // the command a hub-made TUI start runs to open a terminal (#269)
|
|
30
30
|
] as const;
|
|
31
31
|
/** Legacy exported field list stays compatible; new policies use the complete list below. */
|
|
32
|
-
export const ALL_MACHINE_LOCAL = [...MACHINE_LOCAL, "protect_instruction_files", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "text_projection"] as const;
|
|
32
|
+
export const ALL_MACHINE_LOCAL = [...MACHINE_LOCAL, "protect_instruction_files", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "text_projection", "instances"] as const;
|
|
33
33
|
|
|
34
34
|
/** A known repository, including an unreadable marker, must never be mistaken for a plain directory. */
|
|
35
35
|
export function configRepositoryPresent(cwd: string): boolean {
|
|
@@ -8,7 +8,7 @@ export function stateDirFor(cwd: string): string {
|
|
|
8
8
|
|
|
9
9
|
/** Control WS wire version. 2 = `deliver` carries `envs` (digests); 3 = `tools` role and task messages; 4 = budget messages and `hub_checkpoint`; 5 = `ask`; 6 = console-only `ui` session bootstrap; 8 = controlled recovery; 9 = Pi bridge metadata; 10 = durable delivery receipts; 11 = queue hold diagnostics; 12 = generation-bound channel settlement and execution budgets; 13 = turn-free facts (`facts` with tool, session and transcript binding, `silenced`) and per-recipient send results (issue #108). 14 = native context-window status and session-bound context checkpoint request ids (issue #185). 15 = conductor tools and permission deadlines/closed events (issues #190, #194). 16 = a Claude session without channel evidence: hello `channel: false` and `inbox` (issue #205). The plugin is installed apart from the daemon, so they can drift. */
|
|
10
10
|
export const PROTOCOL = 16;
|
|
11
|
-
export const DAEMON_FEATURES = ["task_cancel", "recovery_deferred_hub"] as const;
|
|
11
|
+
export const DAEMON_FEATURES = ["task_cancel", "recovery_deferred_hub", "peer_instances"] as const;
|
|
12
12
|
/** Protocols a current coordinator may authenticate while upgrading a running source. */
|
|
13
13
|
export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, 15, PROTOCOL] as const;
|
|
14
14
|
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** S10 cost evidence is metadata only. Legacy scope and incomplete readback remain unknown. */
|
|
2
|
+
export interface ReviewScope { scope: "none" | "delta" | "full"; reviewers: number }
|
|
3
|
+
export interface CostPlan { review?: ReviewScope; ci?: "focused" | "full" | "reuse" }
|
|
4
|
+
export interface ReviewCost extends CostPlan { head?: string }
|
|
5
|
+
export interface CostTask { repo: string; pr: number; private?: boolean; history: { event: string; at: number; reviewCost?: ReviewCost }[] }
|
|
6
|
+
export interface CostPr { repo: string; pr: number; state: "OPEN" | "CLOSED" | "MERGED"; mergedAt?: number; complete: boolean; ciRuns?: { id: number; attempt: number }[]; reviews?: number }
|
|
7
|
+
export interface CostRow { repo: string; pr: number; reviewRounds: number; ciRuns: number | null; ciReruns: number | null; fullReReviews: number; deltaReviews: number; reviewMs: number | null; unknownReviewScopes: number; githubReviews: number | null; complete: boolean }
|
|
8
|
+
export interface CostReport { rows: CostRow[]; truncated: boolean }
|
|
9
|
+
export function cleanCostPlan(input: unknown): CostPlan {
|
|
10
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("invalid cost scope");
|
|
11
|
+
const value = input as Record<string, unknown>;
|
|
12
|
+
const result: CostPlan = {};
|
|
13
|
+
if (value.review !== undefined) {
|
|
14
|
+
const review = value.review as Record<string, unknown>;
|
|
15
|
+
if (!review || typeof review !== "object" || Array.isArray(review) || Object.keys(review).some(key => !["scope", "reviewers"].includes(key))
|
|
16
|
+
|| !["none", "delta", "full"].includes(review.scope as string) || !Number.isSafeInteger(review.reviewers)
|
|
17
|
+
|| (review.scope === "none" ? review.reviewers !== 0 : (review.reviewers as number) < 1 || (review.reviewers as number) > 8)) throw new Error("invalid review cost scope");
|
|
18
|
+
result.review = { scope: review.scope as ReviewScope["scope"], reviewers: review.reviewers as number };
|
|
19
|
+
}
|
|
20
|
+
if (value.ci !== undefined) {
|
|
21
|
+
if (!["focused", "full", "reuse"].includes(value.ci as string)) throw new Error("invalid CI cost scope");
|
|
22
|
+
result.ci = value.ci as CostPlan["ci"];
|
|
23
|
+
}
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
const validRepo = (repo: string) => /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) && !repo.startsWith("-");
|
|
27
|
+
const key = (repo: string, pr: number) => `${repo.toLowerCase()}#${pr}`;
|
|
28
|
+
/** One round is a board review handoff, not one reviewer in a multi-reviewer round. CI attempts share a run id. */
|
|
29
|
+
export function costMetrics(tasks: CostTask[], prs: CostPr[]): CostReport {
|
|
30
|
+
const withheld = new Set(tasks.filter(task => task.private).map(task => key(task.repo, task.pr)));
|
|
31
|
+
const rows: CostRow[] = [];
|
|
32
|
+
let truncated = tasks.length > 500 || prs.length > 200, historyLeft = 10_000;
|
|
33
|
+
const seen = new Set<string>();
|
|
34
|
+
for (const pr of prs.slice(0, 200)) {
|
|
35
|
+
const identity = key(pr.repo, pr.pr);
|
|
36
|
+
if (!validRepo(pr.repo) || !Number.isSafeInteger(pr.pr) || pr.pr < 1 || pr.state !== "MERGED" || withheld.has(identity) || seen.has(identity)) continue;
|
|
37
|
+
if (!tasks.some(task => !task.private && key(task.repo, task.pr) === identity)) continue;
|
|
38
|
+
seen.add(identity);
|
|
39
|
+
const rounds: { at: number; scope?: ReviewScope["scope"]; elapsed?: number }[] = [];
|
|
40
|
+
let complete = pr.complete && Number.isFinite(pr.mergedAt);
|
|
41
|
+
for (const task of tasks.slice(0, 500).filter(task => key(task.repo, task.pr) === identity)) {
|
|
42
|
+
const history = task.history.slice(0, Math.max(0, historyLeft)); historyLeft -= history.length;
|
|
43
|
+
if (history.length < task.history.length) { complete = false; truncated = true; }
|
|
44
|
+
let pending: typeof rounds[number] | undefined;
|
|
45
|
+
for (const event of [...history].sort((a, b) => a.at - b.at)) {
|
|
46
|
+
if (!Number.isFinite(event.at) || event.at < 0 || !Number.isFinite(pr.mergedAt) || event.at > pr.mergedAt!) continue;
|
|
47
|
+
if (event.event === "done") {
|
|
48
|
+
pending = { at: event.at, scope: event.reviewCost?.review?.scope }; rounds.push(pending);
|
|
49
|
+
} else if (pending && ["approved", "changes_requested"].includes(event.event)) { pending.elapsed = event.at - pending.at; pending = undefined; }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
rounds.sort((a, b) => a.at - b.at);
|
|
53
|
+
const attempts = new Map<number, number>();
|
|
54
|
+
if (pr.ciRuns) for (const run of pr.ciRuns.slice(0, 100)) {
|
|
55
|
+
if (!Number.isSafeInteger(run.id) || run.id < 1 || !Number.isSafeInteger(run.attempt) || run.attempt < 1 || run.attempt > 10_000) { complete = false; continue; }
|
|
56
|
+
attempts.set(run.id, Math.max(attempts.get(run.id) ?? 0, run.attempt));
|
|
57
|
+
}
|
|
58
|
+
if (pr.ciRuns && pr.ciRuns.length > 100) { complete = false; truncated = true; }
|
|
59
|
+
if (!pr.ciRuns || pr.reviews === undefined || rounds.some(round => round.elapsed === undefined || round.scope === undefined)) complete = false;
|
|
60
|
+
rows.push({ repo: pr.repo.toLowerCase(), pr: pr.pr, reviewRounds: rounds.length,
|
|
61
|
+
ciRuns: pr.ciRuns ? attempts.size : null, ciReruns: pr.ciRuns ? [...attempts.values()].reduce((sum, n) => sum + n - 1, 0) : null,
|
|
62
|
+
fullReReviews: rounds.filter((round, index) => index > 0 && round.scope === "full").length,
|
|
63
|
+
deltaReviews: rounds.filter(round => round.scope === "delta").length,
|
|
64
|
+
reviewMs: rounds.every(round => round.elapsed !== undefined) ? rounds.reduce((sum, round) => sum + round.elapsed!, 0) : null,
|
|
65
|
+
unknownReviewScopes: rounds.filter(round => round.scope === undefined).length, githubReviews: pr.reviews ?? null, complete });
|
|
66
|
+
}
|
|
67
|
+
return { rows: rows.sort((a, b) => a.repo.localeCompare(b.repo) || a.pr - b.pr), truncated };
|
|
68
|
+
}
|
|
69
|
+
export function costBandProposals(report: CostReport, upper: Partial<Record<"reviewRounds" | "ciRuns" | "ciReruns" | "fullReReviews" | "deltaReviews" | "reviewMs", number>>): { repo: string; pr: number; metric: string; observed: number; upper: number }[] {
|
|
70
|
+
if (report.truncated) return [];
|
|
71
|
+
return report.rows.filter(row => row.complete).flatMap(row => Object.entries(upper).flatMap(([metric, limit]) => {
|
|
72
|
+
if (!["reviewRounds", "ciRuns", "ciReruns", "fullReReviews", "deltaReviews", "reviewMs"].includes(metric) || !Number.isFinite(limit) || limit! < 0) return [];
|
|
73
|
+
const observed = row[metric as keyof CostRow];
|
|
74
|
+
return typeof observed === "number" && observed > limit! ? [{ repo: row.repo, pr: row.pr, metric, observed, upper: limit! }] : [];
|
|
75
|
+
}));
|
|
76
|
+
}
|
package/src/hub/crash.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { definitionOf } from "../adapters/registry.ts";
|
|
1
|
+
import { definitionOf, type KindMap } from "../adapters/registry.ts";
|
|
2
2
|
import { chmodSync, existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import type { JournalDelivery } from "./delivery-journal.ts";
|
|
@@ -56,17 +56,26 @@ const str = (v: unknown) => (typeof v === "string" && v ? v : undefined);
|
|
|
56
56
|
* What can come back after a crash. `resume` holds the start arguments for peers the hub launches itself (Kimi through
|
|
57
57
|
* ACP `session/load`, Pi through its session file, the local worker afresh); the others say what the user has to do.
|
|
58
58
|
*/
|
|
59
|
-
export function crashPlan(records: SessionRecord[]): { peer: string; resume?: Record<string, string>; how: string; fresh?: Record<string, string>; tui?: true }[] {
|
|
59
|
+
export function crashPlan(records: SessionRecord[], instances?: KindMap): { peer: string; resume?: Record<string, string>; how: string; fresh?: Record<string, string>; tui?: true }[] {
|
|
60
60
|
return records.map(({ peer, meta }) => {
|
|
61
61
|
const launch = (meta.launch && typeof meta.launch === "object" ? meta.launch : {}) as Record<string, unknown>;
|
|
62
62
|
const model = str(launch.model);
|
|
63
|
-
const adapter = definitionOf(peer);
|
|
63
|
+
const adapter = definitionOf(peer, instances);
|
|
64
|
+
const extra = adapter !== undefined && definitionOf(peer) === undefined;
|
|
65
|
+
const command = extra ? `ahub ${adapter.cli.command} --as ${peer}` : `ahub ${peer}`;
|
|
64
66
|
if (adapter?.session.resumeById) {
|
|
65
67
|
const sessionId = adapter.session.crashIdentity?.(meta).sessionId;
|
|
66
|
-
if (!sessionId) return { peer, how: `${peer}: saved ACP session identity unavailable; start it deliberately with
|
|
68
|
+
if (!sessionId) return { peer, how: `${peer}: saved ACP session identity unavailable; start it deliberately with ${command}` };
|
|
67
69
|
return { peer, resume: { sessionId, ...(model ? { model } : {}) }, how: `${peer}: the recorded conversation can be loaded again (ACP session/load)` };
|
|
68
70
|
}
|
|
69
|
-
|
|
71
|
+
const native = adapter?.session.crashPlan?.(meta);
|
|
72
|
+
if (!native) return { peer, how: `${peer}: reconnects by itself if its client is still running` };
|
|
73
|
+
if (!extra) return { peer, ...native };
|
|
74
|
+
const prefix = `${adapter.kind}:`;
|
|
75
|
+
let how = native.how.startsWith(prefix) ? `${peer}:${native.how.slice(prefix.length)}` : `${peer}: ${native.how}`;
|
|
76
|
+
// Manual launch guidance selects this instance. Recorded headless resume prose can contain an opaque id, never a command.
|
|
77
|
+
if (!native.resume || native.tui) how = how.replace(`ahub ${adapter.cli.command}`, command);
|
|
78
|
+
return { peer, ...native, how };
|
|
70
79
|
});
|
|
71
80
|
}
|
|
72
81
|
|