@staix/agent-hub 0.12.23 → 0.12.25
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 +109 -1
- package/README.md +8 -3
- package/docs/addons/claude-mem.md +24 -0
- package/docs/addons/codex-plugin.md +8 -0
- package/docs/addons/dot-admit.md +8 -0
- package/docs/addons/open-code-review.md +8 -0
- package/docs/addons/ponytail.md +23 -0
- package/docs/addons/ripwire.md +22 -0
- package/docs/agent-notes/README.md +5 -0
- package/docs/agent-notes/adapters.md +10 -1
- package/docs/agent-notes/bus.md +3 -1
- package/docs/agent-notes/daemon.md +7 -1
- package/docs/agent-notes/local-worker.md +4 -2
- package/docs/agent-notes/tasks.md +8 -0
- package/docs/agent-notes/tests.md +5 -1
- package/docs/bench.md +28 -3
- package/docs/cooperbench.md +4 -0
- package/docs/data-policy.md +36 -0
- package/docs/events.md +3 -0
- package/docs/gsd-collaboration-analysis.md +48 -0
- package/docs/memory.md +231 -0
- package/docs/operations.md +666 -19
- package/docs/plans/2026-10-11-issues-109-sequential.md +81 -0
- package/docs/plans/2026-10-11-routing-evaluation-proposal.md +30 -0
- package/docs/profiles.md +57 -0
- package/docs/quickstart.md +2 -2
- package/docs/research.md +31 -1
- package/docs/roles.md +85 -0
- package/docs/sdlc.md +170 -0
- package/docs/security.md +111 -4
- package/docs/smoke.md +288 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +633 -7
- package/docs/specs/2026-10-12-backlog-implementation-plan.md +186 -0
- package/docs/specs/2026-10-12-office-view-design.md +212 -0
- package/docs/text-projection.md +19 -0
- package/docs/verification/2026-10-11-acp-watch-t0.md +33 -0
- package/docs/verification/2026-10-11-agent-config-inventory.md +20 -0
- package/docs/verification/2026-10-11-jcode-peer-contract.md +27 -0
- package/docs/verification/2026-10-13-0.12.24.md +94 -0
- package/docs/verification/2026-10-13-board-phone.md +31 -0
- package/docs/verification/2026-10-13-fsmonitor-fixture-attribution.md +19 -0
- package/docs/verification/2026-10-14-0.12.24-audit.md +123 -0
- package/docs/verification/2026-10-14-0.12.25.md +27 -0
- package/docs/verified.json +22 -13
- package/package.json +2 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
- package/plugins/agent-hub/server.js +1175 -339
- package/scripts/check-docs.mjs +129 -0
- package/src/adapters/acp.ts +112 -13
- package/src/adapters/claude-channel.ts +48 -11
- package/src/adapters/codex-appserver.ts +11 -0
- package/src/adapters/jcode.ts +110 -0
- package/src/adapters/local-worker.ts +10 -7
- package/src/adapters/pi.ts +171 -23
- package/src/cli/attention.ts +60 -0
- package/src/cli/bench.ts +24 -5
- package/src/cli/board-read.ts +12 -0
- package/src/cli/console-state.ts +103 -17
- package/src/cli/console.ts +54 -8
- package/src/cli/facts-hook.ts +12 -4
- package/src/cli/help.ts +35 -9
- package/src/cli/identity.ts +18 -7
- package/src/cli/init.ts +48 -10
- package/src/cli/jcode-mcp.ts +58 -0
- package/src/cli/kimi-mcp.ts +53 -0
- package/src/cli/launch.ts +16 -1
- package/src/cli/main.ts +392 -57
- package/src/cli/memory.ts +193 -0
- package/src/cli/output.ts +45 -4
- package/src/cli/pet-config.ts +1 -0
- package/src/cli/pet.ts +83 -0
- package/src/cli/preview.ts +38 -5
- package/src/cli/status-lines.ts +1 -1
- package/src/cli/tail-render.ts +1 -0
- package/src/cli/upgrade-runtime.ts +32 -3
- package/src/cli/watch.ts +31 -0
- package/src/hub/activity.ts +53 -0
- package/src/hub/ask.ts +10 -5
- package/src/hub/bench.ts +10 -3
- package/src/hub/board.ts +222 -16
- package/src/hub/bus.ts +48 -9
- package/src/hub/citations.ts +7 -0
- package/src/hub/conductor.ts +12 -7
- package/src/hub/config-trust.ts +4 -2
- package/src/hub/crash.ts +9 -0
- package/src/hub/daemon.ts +914 -120
- package/src/hub/delivery-journal.ts +5 -0
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +12 -2
- package/src/hub/facts.ts +26 -7
- package/src/hub/git.ts +8 -2
- package/src/hub/github-import.ts +28 -0
- package/src/hub/hub-tools.ts +49 -10
- package/src/hub/inference.ts +1 -1
- package/src/hub/lifecycle-schema.ts +17 -0
- package/src/hub/listen.ts +15 -0
- package/src/hub/manager.ts +12 -5
- package/src/hub/path-policy.ts +115 -0
- package/src/hub/peer-catalog.ts +28 -0
- package/src/hub/permission-mode.ts +19 -5
- package/src/hub/planning.ts +70 -0
- package/src/hub/profiles.ts +74 -0
- package/src/hub/projected-diff.ts +73 -0
- package/src/hub/projection-exec.ts +24 -0
- package/src/hub/research.ts +144 -21
- package/src/hub/roles.ts +123 -0
- package/src/hub/roster.ts +23 -0
- package/src/hub/routing.ts +18 -5
- package/src/hub/sdlc-queue.ts +112 -0
- package/src/hub/sdlc-release.ts +125 -0
- package/src/hub/sdlc.ts +250 -0
- package/src/hub/settings.ts +34 -15
- package/src/hub/shipped-roles.ts +25 -0
- package/src/hub/snapshots.ts +89 -15
- package/src/hub/start-mode.ts +7 -5
- package/src/hub/supervision.ts +1 -0
- package/src/hub/task-sweep.ts +1 -1
- package/src/hub/tasks.ts +905 -89
- package/src/hub/text-projection.ts +151 -0
- package/src/hub/tooling.ts +170 -0
- package/src/hub/ui.ts +16 -2
- package/src/hub/workflow.ts +71 -0
- package/src/local/sandbox.ts +6 -4
- package/src/local/tools.ts +11 -6
- package/src/memory/brief.ts +28 -14
- package/src/memory/import.ts +55 -0
- package/src/memory/recall.ts +84 -0
- package/src/memory/store.ts +757 -0
- package/src/memory/sync.ts +145 -0
- package/src/pet/NOTICE.md +10 -0
- package/src/pet/characters.ts +1438 -0
- package/src/pet/config.ts +31 -0
- package/src/pet/frame.ts +17 -0
- package/src/pet/pixels.ts +28 -0
- package/src/pi/extension.ts +40 -5
- package/src/role-text.d.ts +1 -0
- package/src/ui/index.html +843 -46
- package/src/ui/needs.ts +58 -0
- package/src/ui/office-model.ts +440 -0
- package/src/ui/peer-palette.ts +16 -0
- package/src/ui/task-board.ts +49 -0
- package/src/ui/task-progress.ts +5 -1
- package/templates/AGENTS.block.md +3 -2
- package/templates/config.json +5 -1
- package/templates/profiles/dev.json +22 -0
- package/templates/profiles/general.json +26 -0
- package/templates/profiles/research.json +57 -0
- package/templates/profiles/writing.json +28 -0
- package/templates/roles/NOTICE.md +26 -0
- package/templates/roles/dev/backend-engineer.md +26 -0
- package/templates/roles/dev/code-reviewer.md +26 -0
- package/templates/roles/dev/frontend-engineer.md +26 -0
- package/templates/roles/dev/security-reviewer.md +26 -0
- package/templates/roles/dev/software-architect.md +26 -0
- package/templates/roles/dev/test-engineer.md +26 -0
- package/templates/roles/research/analyst.md +26 -0
- package/templates/roles/research/fact-checker.md +26 -0
- package/templates/roles/research/research-lead.md +26 -0
- package/templates/roles/research/researcher.md +26 -0
- package/templates/roles/research/statistician.md +26 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { extractNodes, GOAL_STATES, KINDS, looksSecret, MemoryStore, setMachineLabel, type GoalState, type GoalView, type Kind, type Tier, type View } from "../memory/store.ts";
|
|
2
|
+
import { syncMemory, type SyncOptions } from "../memory/sync.ts";
|
|
3
|
+
import { importClaudeMem } from "../memory/import.ts";
|
|
4
|
+
import { currentRouting, detectSignals } from "../hub/routing.ts";
|
|
5
|
+
import { memoryProjectKey, mentionsPrivatePath, projectChain, promotionScreen, withoutPii } from "../memory/recall.ts";
|
|
6
|
+
import { loadConfig } from "../hub/daemon.ts";
|
|
7
|
+
|
|
8
|
+
/** The project's config, or none when there is no valid one (reading memory needs none). */
|
|
9
|
+
const loadConfigSafe = (cwd: string) => { try { return loadConfig(cwd); } catch { return undefined; } };
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* #258: `ahub memory`, straight on the machine's store (no hub needed). Reading is anyone's; promote, demote, forget,
|
|
13
|
+
* supersede and rebuild are a person's (the identity gate refuses agent shells).
|
|
14
|
+
*/
|
|
15
|
+
export const MEMORY_USAGE = "usage: ahub memory [list] [--tier short|long] [--kind <kind>] [--peer <p>] [--machine <id>] [--since <iso>] [--limit N] | search <words> | show <id> | graph <id|node> [--hops 1|2] | promote|demote|forget <id> | supersede <old> <new> | rebuild | sync [--remote <url>] | machine [--label <name>] | import claude-mem [--db <path>] [--limit N] | suggestions | confirm|reject <suggestion id> [--json]";
|
|
16
|
+
export const GOAL_USAGE = "usage: ahub goal [list] [--all] | show <id> | new <title> [--milestone <text>]... [--depends <id>]... [--shared] | link <id> <task id> | state <id> open|active|blocked|done|dropped | done <id> | take <id> [--force] [--json]";
|
|
17
|
+
|
|
18
|
+
const fail = (message: string): never => { throw new Error(message); };
|
|
19
|
+
const one = (v: View) => `${v.id.slice(0, 8)} ${v.at.slice(0, 16).replace("T", " ")} ${v.tier.padEnd(5)} ${v.kind.padEnd(8)} ${v.peer.padEnd(8)} ${v.scope === "shared" ? "shared " : ""}${`${v.title ? `${v.title}: ` : ""}${v.text}`.replace(/\s+/g, " ").slice(0, 100)}${v.supersededBy ? ` (superseded by ${v.supersededBy.slice(0, 8)})` : ""}`;
|
|
20
|
+
function detail(v: View): string[] {
|
|
21
|
+
return [`${v.id} ${v.kind}, ${v.tier}-term${v.expiresAt ? ` until ${v.expiresAt}` : ""}, ${v.scope}`, `by ${v.peer} on machine ${v.machine}, ${v.at}, project ${v.project}${v.task ? `, task ${v.task}` : ""}`,
|
|
22
|
+
...(v.promotedFrom ? [`promoted from ${v.promotedFrom}`] : []), ...(v.supersededBy ? [`superseded by ${v.supersededBy}`] : []), ...(v.about.length ? [`about: ${v.about.join(", ")}`] : []), "", ...(v.title ? [v.title] : []), v.text];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function memoryCommand(args: string[], cwd: string, opts: { store?: MemoryStore; sync?: Omit<SyncOptions, "remote"> } = {}): Promise<string> {
|
|
26
|
+
const json = args.includes("--json");
|
|
27
|
+
const rest = args.filter((a) => a !== "--json");
|
|
28
|
+
const flag = (name: string) => { const i = rest.indexOf(name); if (i < 0) return undefined; const v = rest[i + 1] ?? fail(`${name} needs a value`); rest.splice(i, 2); return v; };
|
|
29
|
+
const sub = rest[0] && !rest[0].startsWith("--") ? rest.shift()! : "list";
|
|
30
|
+
// The project's private paths (`pii.paths`): a note about one is withheld like a note matching a PII pattern. A config
|
|
31
|
+
// that does not load fails the read closed: its privacy policy is not dropped for agent-readable output.
|
|
32
|
+
const privatePaths = (): string[] => { try { return loadConfig(cwd).pii?.paths ?? []; } catch (e) { return fail(`the project's config does not load (${(e as Error).message}); fix it before reading hub memory`); } };
|
|
33
|
+
// A store opened here promotes this project's notes only through its current policy (privatePaths fails closed).
|
|
34
|
+
// The project's own short-term lifetime, so a deadline this command writes (a demotion) matches what its hub would write.
|
|
35
|
+
const store = opts.store ?? new MemoryStore({ ttlMs: (loadConfigSafe(cwd)?.memory.short_ttl_days ?? 14) * 24 * 3600_000, screen: promotionScreen(memoryProjectKey(cwd), cwd, () => currentRouting(cwd), () => privatePaths()) });
|
|
36
|
+
const actor = "user";
|
|
37
|
+
const project = memoryProjectKey(cwd);
|
|
38
|
+
const need = (v: View | undefined, id: string) => v ?? fail(`no memory entry ${id} (ahub memory list shows the ids)`);
|
|
39
|
+
try {
|
|
40
|
+
switch (sub) {
|
|
41
|
+
case "list": case "search": {
|
|
42
|
+
const tier = flag("--tier"), kind = flag("--kind"), peer = flag("--peer"), machine = flag("--machine"), since = flag("--since"), limit = flag("--limit");
|
|
43
|
+
if (since !== undefined && Number.isNaN(Date.parse(since))) fail(`--since needs a date or time (ISO 8601), not ${since}`);
|
|
44
|
+
if (tier && tier !== "short" && tier !== "long") fail("--tier takes short or long");
|
|
45
|
+
if (kind && !KINDS.includes(kind as Kind)) fail(`--kind takes ${KINDS.join(", ")}`);
|
|
46
|
+
if (limit && !(Number(limit) > 0)) fail("--limit takes a positive number");
|
|
47
|
+
const q = sub === "search" ? rest.join(" ") || fail("usage: ahub memory search <words>") : rest.length ? fail(MEMORY_USAGE) : undefined;
|
|
48
|
+
// Agent shells may run this: notes are screened under this project's current PII policy, as briefs and recall are
|
|
49
|
+
// (a shared note was accepted under another project's patterns). The dashboard shows a person everything.
|
|
50
|
+
const all = store.list({ project, ...(tier ? { tier: tier as Tier } : {}), ...(kind ? { kind: kind as Kind } : {}), ...(peer ? { peer } : {}), ...(machine ? { machine } : {}), ...(since ? { since } : {}), ...(q ? { q } : {}), limit: Number.MAX_SAFE_INTEGER });
|
|
51
|
+
// Screened first, then cut to the limit: withheld entries never crowd out readable ones.
|
|
52
|
+
const readable = withoutPii(all, currentRouting(cwd), cwd, privatePaths()), rows = readable.slice(0, limit ? Number(limit) : 50);
|
|
53
|
+
if (json) return JSON.stringify(rows, null, 2);
|
|
54
|
+
const c = store.counts(project);
|
|
55
|
+
const withheld = all.length - readable.length;
|
|
56
|
+
return [`hub memory for ${project} on machine ${c.machine.label}: ${c.long} long-term, ${c.short} short-term`, ...(rows.length ? rows.map(one) : ["(nothing)"]),
|
|
57
|
+
...(withheld ? [`(${withheld} withheld: they match a PII pattern; the dashboard shows them)`] : [])].join("\n");
|
|
58
|
+
}
|
|
59
|
+
case "show": {
|
|
60
|
+
const id = rest[0] ?? fail("usage: ahub memory show <id>");
|
|
61
|
+
const v = need(store.get(id, project), id);
|
|
62
|
+
if (!withoutPii([v], currentRouting(cwd), cwd, privatePaths()).length) fail(`${v.id.slice(0, 8)} is withheld: it matches a PII pattern (the dashboard shows it)`);
|
|
63
|
+
return json ? JSON.stringify(v, null, 2) : detail(v).join("\n");
|
|
64
|
+
}
|
|
65
|
+
case "graph": {
|
|
66
|
+
const hops = Number(flag("--hops") ?? 1);
|
|
67
|
+
if (hops !== 1 && hops !== 2) fail("--hops takes 1 or 2");
|
|
68
|
+
const start = rest[0] ?? fail("usage: ahub memory graph <id|node>");
|
|
69
|
+
const node = start.includes(":") && !start.startsWith("mem:") ? start : `mem:${need(store.get(start, project), start).id}`;
|
|
70
|
+
const g = store.graph(project, node, hops);
|
|
71
|
+
return json ? JSON.stringify(g, null, 2) : [`${g.nodes.length} nodes, ${g.edges.length} edges around ${node}`, ...g.edges.map((e) => `${e.from} -${e.rel}-> ${e.to}`)].join("\n");
|
|
72
|
+
}
|
|
73
|
+
case "promote": { const v = need(store.get(rest[0] ?? fail(MEMORY_USAGE), project), rest[0]!); if (v.supersededBy) fail(`${v.id.slice(0, 8)} is replaced by ${v.supersededBy.slice(0, 8)}: promote that one instead`); const id = store.promote(v.id, actor, project) ?? fail(v.project !== project ? `${v.id.slice(0, 8)} is another project's note: promote it from that project` : `${v.id.slice(0, 8)} stays short-term: it holds a secret, matches a PII pattern or is about a private path`); return id === v.id ? `${v.id.slice(0, 8)} is already long-term` : `promoted to long-term as ${id.slice(0, 8)}`; }
|
|
74
|
+
case "demote": { const v = need(store.get(rest[0] ?? fail(MEMORY_USAGE), project), rest[0]!); if (v.tier !== "long") return `${v.id.slice(0, 8)} is already short-term`; store.demote(v.id, actor, project); return `demoted ${v.id.slice(0, 8)}: it expires ${store.get(v.id, project)?.expiresAt ?? "with its short-term lifetime"}`; }
|
|
75
|
+
case "forget": { const v = need(store.forget(rest[0] ?? fail(MEMORY_USAGE), actor, project), rest[0]!); return `forgot ${v.id.slice(0, 8)}${v.local ? "" : "; it stays in the sync remote's git history until a person rewrites it (docs/memory.md)"}`; }
|
|
76
|
+
case "supersede": {
|
|
77
|
+
if (rest.length !== 2) fail("usage: ahub memory supersede <old> <new>");
|
|
78
|
+
return store.supersede(rest[0]!, rest[1]!, actor, project) ? `${rest[0]} is superseded by ${rest[1]}` : fail("both ids must name different entries, and the newer one must reach as far: shared for a shared entry, long-term for a long-term one (ahub memory promote it first)");
|
|
79
|
+
}
|
|
80
|
+
case "sync": {
|
|
81
|
+
const remote = flag("--remote");
|
|
82
|
+
if (rest.length) fail("usage: ahub memory sync [--remote <private git url>]");
|
|
83
|
+
const r = await syncMemory(store, { ...opts.sync, ...(remote ? { remote } : {}) });
|
|
84
|
+
if (json) return JSON.stringify(r, null, 2);
|
|
85
|
+
if (!r.ok) fail(`hub memory sync: ${r.error}`);
|
|
86
|
+
return `synced${r.pushed ? " and pushed" : ""}: ${r.entries} long-term entries visible here${r.warning ? `\nnote: ${r.warning}` : ""}`;
|
|
87
|
+
}
|
|
88
|
+
case "import": {
|
|
89
|
+
const db = flag("--db"), limit = flag("--limit");
|
|
90
|
+
if (rest[0] !== "claude-mem" || rest.length !== 1) fail("usage: ahub memory import claude-mem [--db <path>] [--limit N]");
|
|
91
|
+
if (limit && !(Number(limit) > 0)) fail("--limit takes a positive number");
|
|
92
|
+
const routing = currentRouting(cwd);
|
|
93
|
+
const policy = privatePaths(); // a note about a private path never reaches the synced log
|
|
94
|
+
const key = memoryProjectKey(cwd), deny = loadConfigSafe(cwd)?.local.deny ?? [];
|
|
95
|
+
const nodes = (text: string) => extractNodes(text, { cwd, project: key, repo: key.startsWith("github.com/") ? key.slice("github.com/".length) : key, deny });
|
|
96
|
+
const r = importClaudeMem(store, { project: key, chain: projectChain(cwd), nodes, isPii: (text) => detectSignals({ title: text, detail: "", refs: {} }, routing, cwd).includes("pii") || mentionsPrivatePath(text, [], policy, cwd), ...(db ? { db } : {}), ...(limit ? { limit: Number(limit) } : {}) });
|
|
97
|
+
if (json) return JSON.stringify(r, null, 2);
|
|
98
|
+
return `imported ${r.added} of ${r.read} hub notes from claude-mem as long-term entries (skipped: ${r.skipped.project} other projects, ${r.skipped.model} model answers, ${r.skipped.pii} PII, ${r.skipped.secret} secret-looking; the rest were already here)`;
|
|
99
|
+
}
|
|
100
|
+
case "suggestions": {
|
|
101
|
+
const pending = store.suggestions(project).filter((x) => x.status === "suggested");
|
|
102
|
+
if (json) return JSON.stringify(pending, null, 2);
|
|
103
|
+
return pending.length ? pending.map((x) => `${x.id.slice(0, 8)} ${x.from.slice(0, 8)} ${x.rel} ${x.to.slice(0, 8)} (suggested by the hub's model ${x.at}; not evidence until confirmed)`).join("\n") : "no edge suggestions waiting";
|
|
104
|
+
}
|
|
105
|
+
case "confirm": case "reject": {
|
|
106
|
+
const x = store.judge(rest[0] ?? fail(`usage: ahub memory ${sub} <suggestion id>`), sub, actor, project) ?? fail(`no suggestion ${rest[0]} (ahub memory suggestions lists them)`);
|
|
107
|
+
return `${sub === "confirm" ? "confirmed" : "rejected"}: ${x.from.slice(0, 8)} ${x.rel} ${x.to.slice(0, 8)}`;
|
|
108
|
+
}
|
|
109
|
+
case "machine": {
|
|
110
|
+
const label = flag("--label");
|
|
111
|
+
const m = label ? setMachineLabel(store.dir, label) : store.machine;
|
|
112
|
+
return json ? JSON.stringify(m) : `this machine: ${m.id} (${m.label})`;
|
|
113
|
+
}
|
|
114
|
+
case "rebuild": { store.rebuild(); const c = store.counts(); return `rebuilt the index from ${c.segments} segment${c.segments === 1 ? "" : "s"}: ${c.long} long-term, ${c.short} short-term entries`; }
|
|
115
|
+
default: return fail(MEMORY_USAGE);
|
|
116
|
+
}
|
|
117
|
+
} finally { if (!opts.store) store.close(); }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const goalOne = (g: GoalView, me: string) => `${g.id.slice(0, 8)} ${g.state.padEnd(7)} ${g.title}${g.scope === "shared" ? " [shared]" : ""} ${leaseText(g, me)}`;
|
|
121
|
+
function leaseText(g: GoalView, me: string): string {
|
|
122
|
+
const who = (m: string) => m === me ? "this machine" : `machine ${m.slice(0, 8)}`;
|
|
123
|
+
if (g.lease.holder) return `held by ${who(g.lease.holder)} until ${g.lease.until}${g.lease.conflict.length ? `, CONFLICT with ${g.lease.conflict.map(who).join(", ")} (the earlier claim holds; a person settles it with ahub goal take --force)` : ""}`;
|
|
124
|
+
return g.lease.expired ? `free (${who(g.lease.lastHolder!)}'s lease ended ${g.lease.until})` : "free";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* #258: `ahub goal`, long-term tasks. Listing and showing are anyone's; new, link, state, done and take are a
|
|
129
|
+
* person's. A peer links its own board task with hub_task_propose's `goal`.
|
|
130
|
+
*/
|
|
131
|
+
export async function goalCommand(args: string[], cwd: string, opts: { store?: MemoryStore; link?: (goal: string, task: string) => Promise<string> } = {}): Promise<string> {
|
|
132
|
+
const json = args.includes("--json");
|
|
133
|
+
const rest = args.filter((a) => a !== "--json");
|
|
134
|
+
const take = (name: string) => { const out: string[] = []; for (let i = rest.indexOf(name); i >= 0; i = rest.indexOf(name)) { out.push(rest[i + 1] ?? fail(`${name} needs a value`)); rest.splice(i, 2); } return out; };
|
|
135
|
+
const sub = rest[0] && !rest[0].startsWith("--") ? rest.shift()! : "list";
|
|
136
|
+
const store = opts.store ?? new MemoryStore();
|
|
137
|
+
const project = memoryProjectKey(cwd);
|
|
138
|
+
const me = store.machine.id;
|
|
139
|
+
// Only this project's long-term tasks and shared ones, like every other lookup an agent may run.
|
|
140
|
+
const need = (id: string | undefined) => store.goalOf(id?.replace(/^goal:/, "") ?? fail(GOAL_USAGE), project) ?? fail(`no long-term task ${id} for this project (ahub goal list shows them)`);
|
|
141
|
+
// Agent shells may read goals and a goal syncs to every machine: titles and milestones pass the same screen as notes
|
|
142
|
+
// (PII patterns, pii.paths; a config that does not load fails closed).
|
|
143
|
+
const policy = (): string[] => { try { return loadConfig(cwd).pii?.paths ?? []; } catch (e) { return fail(`the project's config does not load (${(e as Error).message}); fix it before using long-term tasks`); } };
|
|
144
|
+
const clean = (title: string, milestones: string[], of = project) => !looksSecret(`${title}\n${milestones.join("\n")}`) && withoutPii([{ title, text: milestones.join("\n"), project: of }], currentRouting(cwd), cwd, policy()).length > 0;
|
|
145
|
+
try {
|
|
146
|
+
switch (sub) {
|
|
147
|
+
case "list": {
|
|
148
|
+
const all = rest.includes("--all");
|
|
149
|
+
const listed = store.goals(project).filter((g) => all || (g.state !== "done" && g.state !== "dropped"));
|
|
150
|
+
const goals = listed.filter((g) => clean(g.title, g.milestones, g.project)), withheld = listed.length - goals.length;
|
|
151
|
+
if (json) return JSON.stringify(goals, null, 2);
|
|
152
|
+
return [...(goals.length ? goals.map((g) => goalOne(g, me)) : [`no ${all ? "" : "open "}long-term tasks for ${project} (ahub goal new <title>)`]),
|
|
153
|
+
...(withheld ? [`(${withheld} withheld: they match a PII pattern or a private path)`] : [])].join("\n");
|
|
154
|
+
}
|
|
155
|
+
case "show": {
|
|
156
|
+
const g = need(rest[0]);
|
|
157
|
+
if (!clean(g.title, g.milestones, g.project)) fail(`${g.id.slice(0, 8)} is withheld: it matches a PII pattern or a private path`);
|
|
158
|
+
if (json) return JSON.stringify(g, null, 2);
|
|
159
|
+
return [goalOne(g, me), `${g.id}, project ${g.project}, created ${g.at} on machine ${g.machine.slice(0, 8)}`,
|
|
160
|
+
...g.milestones.map((m) => `milestone: ${m}`), ...g.dependsOn.map((d) => `depends on ${d.slice(0, 8)}`),
|
|
161
|
+
...(g.links.length ? g.links.map((l) => `board task ${l.task} (linked ${l.at} on machine ${l.machine.slice(0, 8)})`) : ["no board tasks linked yet"])].join("\n");
|
|
162
|
+
}
|
|
163
|
+
case "new": {
|
|
164
|
+
const milestones = take("--milestone"), depends = take("--depends").map((d) => need(d).id);
|
|
165
|
+
const shared = rest.includes("--shared");
|
|
166
|
+
const title = rest.filter((a) => a !== "--shared").join(" ").trim() || fail(GOAL_USAGE);
|
|
167
|
+
if (!clean(title, milestones)) fail("a long-term task syncs to every machine: this one holds a secret, a PII pattern or a private path and is not saved");
|
|
168
|
+
const id = store.goal({ project, scope: shared ? "shared" : "project", title, milestones, dependsOn: depends, actor: "user" });
|
|
169
|
+
return `long-term task ${id.slice(0, 8)} created: ${title}`;
|
|
170
|
+
}
|
|
171
|
+
case "link": {
|
|
172
|
+
const g = need(rest[0]);
|
|
173
|
+
const task = rest[1] ?? fail("usage: ahub goal link <id> <task id>");
|
|
174
|
+
if (!/^\d+$/.test(task)) fail("the task is a board task id of this project");
|
|
175
|
+
return await (opts.link ?? fail("linking needs this project's hub running (ahub up)"))(g.id, task);
|
|
176
|
+
}
|
|
177
|
+
case "state": case "done": {
|
|
178
|
+
const g = need(rest[0]);
|
|
179
|
+
const state = (sub === "done" ? "done" : rest[1]) as GoalState;
|
|
180
|
+
if (!GOAL_STATES.includes(state)) fail(`the state is one of ${GOAL_STATES.join(", ")}`);
|
|
181
|
+
store.goalState(g.id, state, "user");
|
|
182
|
+
return `long-term task ${g.id.slice(0, 8)} is ${state}`;
|
|
183
|
+
}
|
|
184
|
+
case "take": {
|
|
185
|
+
const g = need(rest[0]);
|
|
186
|
+
const r = store.take(g.id, "user", { force: rest.includes("--force") });
|
|
187
|
+
if (!r.ok) fail(`held by machine ${r.holder.slice(0, 8)} until ${r.until}; wait for it to expire or take it over with --force`);
|
|
188
|
+
return `this machine holds long-term task ${g.id.slice(0, 8)} until ${r.until}${rest.includes("--force") ? " (earlier claims are void)" : ""}`;
|
|
189
|
+
}
|
|
190
|
+
default: return fail(GOAL_USAGE);
|
|
191
|
+
}
|
|
192
|
+
} finally { if (!opts.store) store.close(); }
|
|
193
|
+
}
|
package/src/cli/output.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { boardColumns, type BoardPeerInput } from "../ui/task-board.ts";
|
|
2
|
+
import { peerStyle } from "../ui/peer-palette.ts";
|
|
1
3
|
import { cells, TABLES, table, fitLine, wrap, duration, relative, plural, terminalText, initialConsoleState, stateTone, type Span, type Tone } from "./console-state.ts";
|
|
2
4
|
import { taskProgress } from "../ui/task-progress.ts";
|
|
3
5
|
import { backendLabel } from "./status-lines.ts";
|
|
@@ -5,10 +7,15 @@ import { formatReport, formatTaskReport, type Report, type TaskReport } from "..
|
|
|
5
7
|
|
|
6
8
|
export interface DoctorCheck { section: "Tools" | "Hub" | "Config" | "Models" | "Memory"; level: "ok" | "warn" | "fail" | "unknown"; name: string; detail: string }
|
|
7
9
|
export interface StatusOutput {
|
|
8
|
-
|
|
10
|
+
taskWip?: Record<string, { wip: number; wipLimit: number }>;
|
|
11
|
+
pid?: number; controlPort?: number; cwd?: string; version?: string; peers: Record<string, any>; roleInfo?: Record<string, any>; budget?: Record<string, any>;
|
|
9
12
|
tasks?: Record<string, number>; models?: { backends?: any[] }; permissionDefaults?: any[]; deliveryError?: string; crash?: string[]; switchyard?: string;
|
|
10
13
|
}
|
|
11
14
|
export interface BudgetOutput { budget: Record<string, any>; gate?: number }
|
|
15
|
+
export interface RosterRow { peer: string; on: boolean; startMode: string; state: string; queued: number; tasks: number }
|
|
16
|
+
export function renderRoster(data: RosterRow[], columns?: number): Span[][] {
|
|
17
|
+
return rows(["PEER", "ROSTER", "START", "STATE", "Q", "TASKS"], data.map(row => [span(row.peer, "info", true), span(row.on ? "on" : "off", row.on ? "success" : "muted", true), span(row.startMode, undefined, true), span(row.state, stateTone(row.state), true), span(row.queued, undefined, true), span(row.tasks, undefined, true)]), columns);
|
|
18
|
+
}
|
|
12
19
|
export function outputWidth(env: { isTTY: boolean; columns?: number; COLUMNS?: string }): number | undefined {
|
|
13
20
|
if (env.isTTY) return Math.max(80, Number.isFinite(env.columns) ? env.columns! : 80);
|
|
14
21
|
return env.COLUMNS && /^[1-9][0-9]*$/.test(env.COLUMNS) && Number.isSafeInteger(Number(env.COLUMNS)) ? Number(env.COLUMNS) : undefined;
|
|
@@ -19,7 +26,7 @@ const flat = (value: unknown) => terminalText(value).replace(/[\n\t]/g, " ");
|
|
|
19
26
|
const width = (value: string) => Bun.stringWidth(value);
|
|
20
27
|
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
21
28
|
const characters = (value: string) => [...graphemes.segment(value)].map(unit => unit.segment);
|
|
22
|
-
const peerTone = (id: string): Tone
|
|
29
|
+
const peerTone = (id: string): Tone => peerStyle(id).tone;
|
|
23
30
|
const shortId = (id: unknown, columns?: number, full = false) => full || columns === undefined ? String(id) : String(id).slice(0, 8);
|
|
24
31
|
function lines(text: string, columns?: number, tone?: Tone, indent = ""): Span[][] {
|
|
25
32
|
return (columns === undefined ? [flat(indent + text)] : wrap(flat(indent + text), columns)).map(text => [span(text, tone)]);
|
|
@@ -174,6 +181,7 @@ function contextText(p: any, now: number): string {
|
|
|
174
181
|
return `${c.used === null || c.used === undefined ? "unknown" : `${Math.round(c.used * 100)}%`}${c.freshness === "stale" ? " stale" : ""}${typeof c.measuredAt === "number" ? ` ${relative(c.measuredAt, now)}` : ""}${c.source ? ` ${c.source}` : ""}`;
|
|
175
182
|
}
|
|
176
183
|
export function renderStatus(data: StatusOutput, columns?: number, now = Date.now(), full = false): Span[][] {
|
|
184
|
+
if (data.roleInfo) data = { ...data, peers: Object.fromEntries(Object.entries(data.peers).map(([peer, value]) => [peer, { ...value, ...data.roleInfo?.[peer] }])) };
|
|
177
185
|
const out = lines(`agent-hub${data.version ? ` ${data.version}` : ""} pid ${data.pid ?? "unknown"} control 127.0.0.1:${data.controlPort ?? "unknown"}`, columns, "info");
|
|
178
186
|
if (data.cwd) out.push(...lines(data.cwd, columns));
|
|
179
187
|
if (data.deliveryError) out.push(...detail("delivery storage", `${data.deliveryError}; dispatch is stopped`, columns, "failure"));
|
|
@@ -189,6 +197,8 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
|
|
|
189
197
|
let settling = false;
|
|
190
198
|
const peerDetails = Object.entries(data.peers).map(([id, p]) => {
|
|
191
199
|
const out: Span[][] = [];
|
|
200
|
+
if (p.flowRoles) out.push(...detail(`${id} flow roles`, p.flowRoles.join(", ") || "none", columns));
|
|
201
|
+
if (p.catalogRoles) { out.push(...detail(`${id} catalog roles`, Array.isArray(p.catalogRoles.value) ? p.catalogRoles.value.join(", ") || "none" : "unconfirmed", columns)); if (p.catalogRoles.pending !== undefined) out.push(...detail(`${id} pending roles`, p.catalogRoles.pending.join(", ") || "none", columns, "attention")); }
|
|
192
202
|
if (p.liveAccepted?.length) { settling = true; out.push(...detail("settling", p.liveAccepted.map((v: string) => shortId(v, columns, full)).join(", "), columns, "attention")); }
|
|
193
203
|
if (p.heldBy) {
|
|
194
204
|
const note = flat(p.holdNote ?? "");
|
|
@@ -205,7 +215,9 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
|
|
|
205
215
|
: flat(p.paused).replace(/^(budget: .*), resets .+$/, "$1; reset unknown");
|
|
206
216
|
out.push(...detail("paused", pauseText, columns, "attention"));
|
|
207
217
|
}
|
|
208
|
-
if (p.toolsOnly) out.push(...detail("tools-only", flat(p.toolsOnly).replace(/^tools-only:\s*/, ""), columns, "attention"));
|
|
218
|
+
if (p.toolsOnly) out.push(...detail(id === "kimi" && p.pullOnly ? "pull-only" : "tools-only", flat(p.toolsOnly).replace(/^(?:tools-only|pull-only):\s*/, ""), columns, "attention"));
|
|
219
|
+
if (p.pullOnly && id === "kimi") out.push(...detail("kimi last read", typeof p.lastInboxAt === "number" ? relative(p.lastInboxAt, now) : "never", columns, "muted"));
|
|
220
|
+
if (p.permissionNote) out.push(...detail("permission", p.permissionNote, columns, "muted"));
|
|
209
221
|
if (p.requestedModel && p.servedBy && p.requestedModel !== p.servedBy) out.push(...detail("requested model", p.requestedModel, columns));
|
|
210
222
|
if (typeof p.oldestQueuedAt === "number") out.push(...detail(`${id} oldest queued`, relative(p.oldestQueuedAt, now), columns, "muted"));
|
|
211
223
|
return out;
|
|
@@ -213,6 +225,7 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
|
|
|
213
225
|
if (peerRows.length) out.push([], ...rows(head, peerRows, columns, peerDetails));
|
|
214
226
|
else out.push(...lines("no peers attached yet (ahub claude | ahub codex | ahub kimi)", columns));
|
|
215
227
|
if (settling) out.push([], ...lines("settling: the adapter has not confirmed these deliveries yet (Claude: reply or hub_delivery_done). Task state is independent.", columns, "muted"));
|
|
228
|
+
if (data.taskWip) for (const [peer, value] of Object.entries(data.taskWip)) out.push(...detail(`${peer} planner WIP`, `${value.wip}/${value.wipLimit}`, columns));
|
|
216
229
|
if (data.models?.backends?.length) {
|
|
217
230
|
out.push([], ...rows(["MODEL", "STATE", "ACTIVE", "REQUESTED", "ACTUAL", "PROVIDER"], data.models.backends.map(b => [span(backendLabel(b)), span(b.state ?? "unknown", stateTone(b.state ?? "")), span(b.active || "-"), span(b.requestedModel ?? "-"), span(b.actualModel ?? "-"), span(b.provider ?? "-")]), columns));
|
|
218
231
|
for (const b of data.models.backends) for (const [key, label] of [["coolingUntil", "cooling"], ["failingUntil", "dispatch held"]]) if (b[key!]) { const at = Date.parse(b[key!]); out.push(...detail(`${backendLabel(b)} ${label}`, `${Number.isFinite(at) ? relative(at, now) : "unknown"}${key === "coolingUntil" && typeof b.failures === "number" ? ` after ${b.failures} failures` : key === "failingUntil" ? "; no load moves until then" : ""}`, columns, "attention")); }
|
|
@@ -225,7 +238,7 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
|
|
|
225
238
|
export function renderBoard(tasks: any[], columns?: number, now = Date.now(), _full = false, allTasks: any[] | null = tasks): Span[][] {
|
|
226
239
|
if (!tasks.length) return [[span("no tasks")]];
|
|
227
240
|
const s = initialConsoleState(); s.panel = 3; s.tasks = tasks;
|
|
228
|
-
const stages = allTasks === null ? undefined : new Map(taskProgress(allTasks).stages.map(stage => [stage.id, stage]));
|
|
241
|
+
const stages = allTasks === null ? undefined : new Map(taskProgress(boardColumnContext(tasks, allTasks)).stages.map(stage => [stage.id, stage]));
|
|
229
242
|
const data = tasks.map(task => {
|
|
230
243
|
const row = cells(s, task, false, now, stages);
|
|
231
244
|
row[2] = span(row[2]!.text, peerTone(task.owner ?? ""));
|
|
@@ -245,6 +258,34 @@ export function renderBudget(data: BudgetOutput, columns?: number, now = Date.no
|
|
|
245
258
|
for (const [peer, b] of Object.entries(data.budget)) if (b.paused) out.push(...detail(`paused ${peer}`, `${b.paused.reason ?? "budget"}${typeof b.paused.resetsAt === "number" ? `; resumes ${relative(b.paused.resetsAt, now)}` : ""}`, columns, "attention"));
|
|
246
259
|
return out;
|
|
247
260
|
}
|
|
261
|
+
function boardColumnContext(tasks: any[], allTasks: any[] | null): any[] {
|
|
262
|
+
if (allTasks === null) throw new Error("board column dependency context unavailable; retry with a complete snapshot");
|
|
263
|
+
const first = new Map(tasks.map(task => [task.id, task])), context = allTasks.map(task => first.get(task.id) ?? task);
|
|
264
|
+
const included = new Set(context.map(task => task.id));
|
|
265
|
+
for (const task of tasks) if (!included.has(task.id)) context.push(task);
|
|
266
|
+
return context;
|
|
267
|
+
}
|
|
268
|
+
export function boardColumnModel(tasks: any[], peers: Record<string, BoardPeerInput>, allTasks: any[] | null = tasks) {
|
|
269
|
+
const visible = new Set(tasks.map(task => task.id)), context = boardColumnContext(tasks, allTasks);
|
|
270
|
+
const model = boardColumns(context.map(task => ({ ...task, urgent: task.signals?.includes("urgent") === true })), peers);
|
|
271
|
+
return { columns: model.columns.map(column => ({ ...column, ready: column.ready.filter(card => visible.has(card.id)), waiting: column.waiting.filter(card => visible.has(card.id)) })), unknown: model.unknown.filter(id => visible.has(id)) };
|
|
272
|
+
}
|
|
273
|
+
export function renderBoardColumns(tasks: any[], peers: Record<string, BoardPeerInput>, columns?: number, now = Date.now(), full = false, allTasks: any[] | null = tasks): Span[][] {
|
|
274
|
+
const visible = new Set(tasks.map(task => task.id)), byId = new Map(tasks.map(task => [task.id, task]));
|
|
275
|
+
const context = boardColumnContext(tasks, allTasks), model = boardColumnModel(tasks, peers, context), out: Span[][] = [];
|
|
276
|
+
for (const column of model.columns) {
|
|
277
|
+
const ready = column.ready.filter(card => visible.has(card.id)), waiting = column.waiting.filter(card => visible.has(card.id));
|
|
278
|
+
if (out.length) out.push([]); out.push([span(column.label, "strong")]);
|
|
279
|
+
if (!ready.length && !waiting.length) { out.push([span("(empty)", "muted")]); continue; }
|
|
280
|
+
if (column.id === "todo") out.push([span("Ready", "strong")]);
|
|
281
|
+
if (ready.length) out.push(...renderBoard(ready.map(card => byId.get(card.id)), columns, now, full, context));
|
|
282
|
+
if (waiting.length) { out.push([span("Waiting", "strong")]); out.push(...renderBoard(waiting.map(card => byId.get(card.id)), columns, now, full, context)); for (const card of waiting) out.push(...lines(`#${card.id}: ${card.waiting.join("; ")}`, columns)); }
|
|
283
|
+
}
|
|
284
|
+
const unknown = model.unknown.filter(id => visible.has(id));
|
|
285
|
+
if (unknown.length) out.push([], [span("Unknown state", "strong")], ...renderBoard(unknown.map(id => byId.get(id)), columns, now, full, context));
|
|
286
|
+
return out;
|
|
287
|
+
}
|
|
288
|
+
|
|
248
289
|
export function renderDoctor(checks: DoctorCheck[], columns?: number, _now = Date.now(), _full = false): Span[][] {
|
|
249
290
|
const out: Span[][] = [];
|
|
250
291
|
const tone = { ok: "success", warn: "attention", fail: "failure", unknown: "muted" } as const;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { loadPetConfig, type PetConfig } from "../pet/config.ts";
|
package/src/cli/pet.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { recoveryLock, recoveryRunner } from "../hub/recovery-store.ts";
|
|
4
|
+
import { processLiveness } from "../pi/process-signature.ts";
|
|
5
|
+
import { characterAttentionLine } from "./attention.ts";
|
|
6
|
+
import type { CharacterId } from "../pet/frame.ts";
|
|
7
|
+
import type { NeedItem, NeedsSummary } from "../ui/needs.ts";
|
|
8
|
+
|
|
9
|
+
const id = (value: unknown): value is string => typeof value === "string" && /^[A-Za-z0-9_.:-]{1,128}$/.test(value);
|
|
10
|
+
const count = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
11
|
+
|
|
12
|
+
/** Parsed status metadata is copied by allowlist; source text and extra fields are never rendered. */
|
|
13
|
+
export function parseNeedsSummary(raw: unknown): NeedsSummary | undefined {
|
|
14
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return;
|
|
15
|
+
const row = raw as Record<string, unknown>;
|
|
16
|
+
if (!count(row.act) || !count(row.watch) || typeof row.state !== "string" || !["waiting", "blocked", "review", "working", "idle", "sleeping"].includes(row.state)) return;
|
|
17
|
+
let first: NeedItem | undefined;
|
|
18
|
+
if (row.first !== undefined) {
|
|
19
|
+
if (!row.first || typeof row.first !== "object" || Array.isArray(row.first)) return;
|
|
20
|
+
const item = row.first as Record<string, unknown>;
|
|
21
|
+
if (typeof item.kind !== "string" || typeof item.level !== "string" || !["approval", "default", "storage", "recovery", "delivery", "review", "budget", "offline"].includes(item.kind) || !["act", "watch"].includes(item.level)) return;
|
|
22
|
+
if (item.peer !== undefined && !id(item.peer)) return;
|
|
23
|
+
if (item.id !== undefined && !id(item.id) && !(count(item.id) && item.id > 0)) return;
|
|
24
|
+
if (item.until !== undefined && !count(item.until) || item.since !== undefined && !count(item.since)) return;
|
|
25
|
+
first = { kind: item.kind as NeedItem["kind"], level: item.level as NeedItem["level"],
|
|
26
|
+
...(item.peer !== undefined ? { peer: item.peer as string } : {}), ...(item.id !== undefined ? { id: item.id as string | number } : {}),
|
|
27
|
+
...(item.until !== undefined ? { until: item.until as number } : {}), ...(item.since !== undefined ? { since: item.since as number } : {}) };
|
|
28
|
+
}
|
|
29
|
+
if ((row.act + row.watch > 0) !== !!first) return;
|
|
30
|
+
if (row.act > 0 && first?.level !== "act" || row.act === 0 && first?.level === "act") return;
|
|
31
|
+
if (row.act === 0 && ["waiting", "blocked", "review"].includes(row.state) || row.act > 0 && ["working", "idle", "sleeping"].includes(row.state)) return;
|
|
32
|
+
if (first?.level === "watch" && !["recovery", "budget", "offline"].includes(first.kind) || first?.level === "act" && ["budget", "offline"].includes(first.kind)) return;
|
|
33
|
+
if (row.state === "waiting" && !["approval", "default"].includes(first?.kind ?? "")) return;
|
|
34
|
+
if (row.state === "blocked" && !["storage", "recovery", "delivery"].includes(first?.kind ?? "")) return;
|
|
35
|
+
if (row.state === "review" && first?.kind !== "review") return;
|
|
36
|
+
return { act: row.act, watch: row.watch, state: row.state as NeedsSummary["state"], ...(first ? { first } : {}) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface PetView { summary?: NeedsSummary; unavailable: boolean; runnerUnknown?: boolean }
|
|
40
|
+
export interface PetRecoveryReader { lock(): string | undefined; runner(id: string): number | "unknown" | undefined }
|
|
41
|
+
const recoveryReader: PetRecoveryReader = { lock: recoveryLock, runner: recoveryRunner };
|
|
42
|
+
|
|
43
|
+
/** A recovery lock is read-only evidence; this function never resumes or changes an operation. */
|
|
44
|
+
export function petView(status: unknown, reader: PetRecoveryReader = recoveryReader): PetView {
|
|
45
|
+
const row = status && typeof status === "object" && !Array.isArray(status) ? status as Record<string, unknown> : undefined;
|
|
46
|
+
const base = parseNeedsSummary(row?.needs);
|
|
47
|
+
let operation: string | undefined;
|
|
48
|
+
try { operation = reader.lock(); }
|
|
49
|
+
catch { return { summary: base, unavailable: true, runnerUnknown: true }; }
|
|
50
|
+
if (!operation) return { summary: base, unavailable: !base };
|
|
51
|
+
if (!id(operation)) return { summary: base, unavailable: true, runnerUnknown: true };
|
|
52
|
+
let runner: number | "unknown" | undefined;
|
|
53
|
+
try { runner = reader.runner(operation); } catch { runner = "unknown"; }
|
|
54
|
+
const active = runner !== undefined;
|
|
55
|
+
const item: NeedItem = { kind: "recovery", level: active ? "watch" : "act", id: operation };
|
|
56
|
+
const reported = row?.recovery;
|
|
57
|
+
const included = base && reported && typeof reported === "object" && (reported as Record<string, unknown>).operationId === operation && base.watch > 0 ? 1 : 0;
|
|
58
|
+
const act = (base?.act ?? 0) + (active ? 0 : 1), watch = (base?.watch ?? 0) - included + (active ? 1 : 0);
|
|
59
|
+
const first = base?.first;
|
|
60
|
+
const precedes = !active && (!first || first.level === "watch" || !["approval", "default", "storage"].includes(first.kind));
|
|
61
|
+
const selected = precedes || first?.kind === "recovery" && first.id === operation ? item : first ?? item;
|
|
62
|
+
const state = !active && base?.state !== "waiting" ? "blocked" : base?.state ?? "sleeping";
|
|
63
|
+
return { summary: { act, watch, state, first: selected }, unavailable: !base, ...(runner === "unknown" ? { runnerUnknown: true } : {}) };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function readPet(stateDir: string, reader: PetRecoveryReader = recoveryReader, alive = processLiveness): PetView {
|
|
67
|
+
let status: unknown;
|
|
68
|
+
try {
|
|
69
|
+
const row = JSON.parse(readFileSync(join(stateDir, "status.json"), "utf8"));
|
|
70
|
+
const summary = parseNeedsSummary(row.needs);
|
|
71
|
+
const expired = summary?.first?.kind === "approval" && summary.first.until !== undefined && summary.first.until <= Date.now();
|
|
72
|
+
if (!expired && Number.isSafeInteger(row.pid) && row.pid > 0 && alive(row.pid, row.pidSignature) === "live") status = row;
|
|
73
|
+
} catch { /* Missing, malformed or unconfirmed owner data is unavailable, never an empty live hub. */ }
|
|
74
|
+
return petView(status, reader);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function renderPet(view: PetView, short = false, character: CharacterId = "stai"): string {
|
|
78
|
+
if (short) return view.summary?.act ? `ahub!${view.summary.act}` : "";
|
|
79
|
+
if (!view.summary) return "attention unavailable";
|
|
80
|
+
const value = view.summary;
|
|
81
|
+
const line = characterAttentionLine({ ...value, items: value.first ? [value.first] : [] }, character, 0, false, false);
|
|
82
|
+
return line + (view.unavailable ? " (status unavailable)" : "") + (view.runnerUnknown ? " (runner unknown)" : "");
|
|
83
|
+
}
|
package/src/cli/preview.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { jcodeCommand } from "../adapters/jcode.ts";
|
|
2
|
+
import { qwenSessionId } from "../hub/crash.ts";
|
|
1
3
|
import { readFileSync } from "node:fs";
|
|
2
4
|
import { join, resolve } from "node:path";
|
|
3
5
|
import { loadConfig } from "../hub/daemon.ts";
|
|
@@ -5,7 +7,8 @@ import { childEnv } from "../hub/child-process.ts";
|
|
|
5
7
|
import { relayModelIds } from "../models/relay.ts";
|
|
6
8
|
import { buildPiLaunch } from "../pi/launch.ts";
|
|
7
9
|
import { startModeOf } from "../hub/start-mode.ts";
|
|
8
|
-
import { buildLaunch, buildKimiLaunch, claudeObservationHooks, type Launch } from "./launch.ts";
|
|
10
|
+
import { buildLaunch, buildKimiLaunch, buildQwenLaunch, buildKimiTuiLaunch, nativeLaunchEnv, claudeObservationHooks, type Launch } from "./launch.ts";
|
|
11
|
+
import { kimiMcpPlan } from "./kimi-mcp.ts";
|
|
9
12
|
|
|
10
13
|
const REDACTED = "[redacted]";
|
|
11
14
|
const UNRESOLVED = "[unresolved]";
|
|
@@ -19,11 +22,12 @@ function nativeCommand(command: string[], trusted: string[]): string[] {
|
|
|
19
22
|
return command.map((value, i) => value === trusted[i] ? value : REDACTED);
|
|
20
23
|
}
|
|
21
24
|
/** Read-only preview. Inputs passed to shared native builders are redacted before serialization. */
|
|
22
|
-
export function launcherPreview(tool: "claude" | "codex" | "kimi" | "pi", raw: string[], cwd: string, stateDir: string, unattended: boolean) {
|
|
25
|
+
export function launcherPreview(tool: "claude" | "codex" | "kimi" | "pi" | "qwen" | "jcode", raw: string[], cwd: string, stateDir: string, unattended: boolean) {
|
|
23
26
|
const args = raw.filter(arg => !["--print-command", "--dry-run", "--json"].includes(arg));
|
|
24
27
|
const config = loadConfig(cwd);
|
|
25
28
|
const unresolved: { field: string; reason: string }[] = [];
|
|
26
29
|
let launch: Launch;
|
|
30
|
+
let kimiMetadata: { mode: "tui" | "headless"; mcp: { file: string; action: string; changed: boolean; tracked: boolean } } | undefined;
|
|
27
31
|
let envNames = Object.keys(childEnv()).concat(["AGENTHUB_STATE_DIR", "AGENTHUB_PROJECT_DIR"]);
|
|
28
32
|
const value = (flag: string) => {
|
|
29
33
|
const at = args.indexOf(flag);
|
|
@@ -58,10 +62,39 @@ export function launcherPreview(tool: "claude" | "codex" | "kimi" | "pi", raw: s
|
|
|
58
62
|
const passthrough = args.filter(arg => !["--unattended", "--safe", "--new"].includes(arg));
|
|
59
63
|
launch.args = launch.args.slice(0, launch.args.length - passthrough.length).concat(passthrough.map(publicArg));
|
|
60
64
|
unresolved.push({ field: "proxyUrl", reason: "assigned when the native adapter starts; preview does not start it" });
|
|
65
|
+
} else if (tool === "jcode") {
|
|
66
|
+
const mode = value("--mode") ?? "headless", model = value("--model"), profile = value("--provider-profile"), session = value("--session-id");
|
|
67
|
+
if (mode !== "headless") throw new Error("Jcode TUI driving is unverified");
|
|
68
|
+
for (let i = 0; i < args.length; i += 2) if (!["--model", "--mode", "--provider-profile", "--session-id"].includes(args[i]!) || !args[i + 1]) throw new Error("unknown Jcode option");
|
|
69
|
+
if (session !== undefined && !qwenSessionId(session)) throw new Error("invalid Jcode session identity");
|
|
70
|
+
const cmd = jcodeCommand(nativeCommand(config.jcode_cmd, ["jcode"]), "acp", UNRESOLVED, UNRESOLVED, model ? REDACTED : undefined, profile || config.jcode_provider_profile ? REDACTED : undefined);
|
|
71
|
+
launch = { cmd: cmd[0]!, args: cmd.slice(1) };
|
|
72
|
+
unresolved.push({ field: "ownedDaemon", reason: "private daemon/socket and source-pinned native identity verified at start; preview starts nothing" });
|
|
73
|
+
unresolved.push({ field: "hubTools", reason: "project MCP bridge required; native tool-call receipt remains unverified" });
|
|
74
|
+
} else if (tool === "qwen") {
|
|
75
|
+
const mode = value("--mode") ?? "headless", model = value("--model");
|
|
76
|
+
if (mode !== "headless") throw new Error("Qwen TUI is not offered before verification");
|
|
77
|
+
for (let i = 0; i < args.length; i += 2) if (!["--model", "--mode", "--session-id"].includes(args[i]!) || !args[i + 1]) throw new Error("unknown Qwen option");
|
|
78
|
+
const sessionId = value("--session-id");
|
|
79
|
+
if (sessionId !== undefined && !qwenSessionId(sessionId)) throw new Error("invalid Qwen session identity");
|
|
80
|
+
if (sessionId) unresolved.push({ field: "sessionId", reason: "requested through ACP session/load at start; preview does not load or expose it" });
|
|
81
|
+
launch = buildQwenLaunch(nativeCommand(config.qwen_cmd, ["qwen", "--acp"]), model ? REDACTED : undefined);
|
|
82
|
+
unresolved.push({ field: "nativeIdentity", reason: "Qwen Code 0.25.0 identity and permission modes are checked at ACP initialization; preview starts nothing" });
|
|
61
83
|
} else if (tool === "kimi") {
|
|
62
84
|
const model = value("--model");
|
|
63
|
-
|
|
64
|
-
|
|
85
|
+
const mode = value("--mode") ?? startModeOf(config.peers, "kimi");
|
|
86
|
+
if (mode !== "tui" && mode !== "headless") throw new Error("invalid Kimi mode");
|
|
87
|
+
for (let i = 0; i < args.length; i += 2) if (!["--model", "--mode"].includes(args[i]!) || !args[i + 1]) throw new Error("unknown Kimi option");
|
|
88
|
+
if (mode === "headless") {
|
|
89
|
+
launch = buildKimiLaunch(nativeCommand(config.kimi_cmd, ["kimi", "acp"]), model ? REDACTED : undefined);
|
|
90
|
+
unresolved.push({ field: "sessionId", reason: "ACP session identity is available only after native initialization" });
|
|
91
|
+
} else {
|
|
92
|
+
const native = buildKimiTuiLaunch(config.kimi_cmd, model ? REDACTED : undefined);
|
|
93
|
+
launch = { ...native, cmd: native.cmd === "kimi" ? "kimi" : REDACTED, args: native.args.map(arg => arg === "--model" || arg === REDACTED ? arg : REDACTED) };
|
|
94
|
+
envNames = Object.keys(nativeLaunchEnv("kimi", childEnv())).concat(["AGENTHUB_STATE_DIR", "AGENTHUB_PROJECT_DIR"]);
|
|
95
|
+
}
|
|
96
|
+
const { file, action, changed, tracked } = kimiMcpPlan(cwd, stateDir, join(import.meta.dir, "../../plugins/agent-hub/server.js"), mode);
|
|
97
|
+
kimiMetadata = { mode, mcp: { file, action, changed, tracked } };
|
|
65
98
|
} else {
|
|
66
99
|
const mode = value("--mode") ?? startModeOf(config.peers, "pi"); // #269: the same default `ahub pi` takes
|
|
67
100
|
const backend = value("--backend") ?? config.pi.backend;
|
|
@@ -88,5 +121,5 @@ export function launcherPreview(tool: "claude" | "codex" | "kimi" | "pi", raw: s
|
|
|
88
121
|
unresolved.push({ field: "env.AGENTHUB_INSTANCE_ID", reason: tool === "claude" ? "injected when an existing daemon instance is read at launch; preview does not read runtime state" : "injected when a verified Orca terminal launch is recorded against the daemon; preview does not record it" });
|
|
89
122
|
unresolved.push({ field: "env.AGENTHUB_LAUNCH_ID", reason: "allocated only for a verified Orca terminal launch; preview does not query Orca, allocate identity or write launch records" });
|
|
90
123
|
}
|
|
91
|
-
return { tool, cmd: launch.cmd, args: launch.args, settings: tool === "claude" && launch.args.includes("--settings") ? launch.args[launch.args.indexOf("--settings") + 1] : undefined, envNames: [...new Set(envNames)].sort(), ...(launch.warning ? { warning: launch.warning } : {}), unresolved, redaction: "Arbitrary user arguments, settings, commands and configured executable overrides are redacted. Environment values are never displayed.", needs: ["Native executable, accounts and runtime readiness are not checked."] };
|
|
124
|
+
return { tool, ...(kimiMetadata ?? {}), cmd: launch.cmd, args: launch.args, settings: tool === "claude" && launch.args.includes("--settings") ? launch.args[launch.args.indexOf("--settings") + 1] : undefined, envNames: [...new Set(envNames)].sort(), ...(launch.warning ? { warning: launch.warning } : {}), unresolved, redaction: "Arbitrary user arguments, settings, commands and configured executable overrides are redacted. Environment values are never displayed.", needs: ["Native executable, accounts and runtime readiness are not checked."] };
|
|
92
125
|
}
|
package/src/cli/status-lines.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ContextView } from "../hub/context-window.ts";
|
|
2
2
|
// What `ahub status` prints for a peer and for a model backend, kept pure so it can be checked.
|
|
3
3
|
|
|
4
|
-
export interface PeerRow { permissionMode?: string; context?: ContextView; state?: string; queued?: number; queuedImportant?: number; needsReview?: number; heldBy?: string; holdNote?: string; liveAccepted?: string[]; oldestQueuedAt?: number; attached?: boolean; toolsOnly?: string; paused?: string; servedBy?: string; requestedModel?: string }
|
|
4
|
+
export interface PeerRow { permissionMode?: string; context?: ContextView; state?: string; queued?: number; queuedImportant?: number; needsReview?: number; heldBy?: string; holdNote?: string; liveAccepted?: string[]; oldestQueuedAt?: number; attached?: boolean; toolsOnly?: string; pullOnly?: boolean; lastInboxAt?: number | null; permissionNote?: string; paused?: string; servedBy?: string; requestedModel?: string }
|
|
5
5
|
export interface BackendRow { kind?: string; alias?: string; state?: string; active?: number; requestedModel?: string; actualModel?: string; provider?: string; coolingUntil?: string; failures?: number; failingUntil?: string }
|
|
6
6
|
|
|
7
7
|
/** Aliases are already namespaced ("dgx/coding"); only an alias that is not gets its kind in front of it. */
|
package/src/cli/tail-render.ts
CHANGED
|
@@ -8,6 +8,7 @@ export function renderTailEvent(e: BusEvent): string {
|
|
|
8
8
|
}
|
|
9
9
|
if (e.t === "overflow") return ` ! ${e.peer}'s queue is full: dropped ${e.env.id} (from ${e.env.from})`;
|
|
10
10
|
if (e.t === "stale") return ` . dropped ${e.env.id} (from ${e.env.from}) for ${e.peer}: ${e.reason}`;
|
|
11
|
+
if (e.t === "off_roster") return ` . ${e.env.id} retained off roster for ${e.peers.join(", ")}`;
|
|
11
12
|
if (e.t === "quiet") return ` . ${e.env.id} (from ${e.env.from}) not delivered to ${e.peers.join(", ")}: turn-free cohort`;
|
|
12
13
|
const { env } = e;
|
|
13
14
|
const note = e.dropped === "hop" ? " [not delivered: hop limit]" : e.dropped === "fyi" ? " [fyi: record only]" : "";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { qwenSessionId } from "../hub/crash.ts";
|
|
1
2
|
import { isDatabaseBusy, isDatabaseBusyReply, RecoveryDatabaseBusy } from "../hub/recovery-error.ts";
|
|
2
3
|
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync } from "node:fs";
|
|
3
4
|
import { homedir } from "node:os";
|
|
@@ -238,6 +239,10 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
|
|
|
238
239
|
const reconnectOnly: string[] = [];
|
|
239
240
|
for (const peer of source.peers) {
|
|
240
241
|
if (peer.state === "offline") continue;
|
|
242
|
+
if (peer.id === "kimi" && peer.args?.mode === "tui") {
|
|
243
|
+
blockers.push("kimi: its pull-only terminal has no automatic restore; end it in that terminal, then make a new recovery plan (its session is not resumed by the hub)");
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
241
246
|
if (peer.id === "claude") {
|
|
242
247
|
// #206: managed means a live `ahub claude` launcher recorded for this daemon and a session record written by
|
|
243
248
|
// that launch. Anything else cannot be bound to the attached session, so its record never becomes a target.
|
|
@@ -265,14 +270,17 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
|
|
|
265
270
|
continue;
|
|
266
271
|
}
|
|
267
272
|
}
|
|
273
|
+
if (peer.id === "jcode") { blockers.push("jcode: source-pinned native load cannot prove conversation identity; end the peer and make a new recovery plan before effects"); continue; }
|
|
268
274
|
if (peer.id === "codex" || peer.id === "claude") {
|
|
269
275
|
const session = peer.id === "codex" ? peer.threadId : peer.sessionId;
|
|
270
276
|
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`);
|
|
271
277
|
else sessions[peer.id] = session;
|
|
278
|
+
} else if (peer.id === "qwen" || peer.id === "jcode") {
|
|
279
|
+
if (!qwenSessionId(peer.sessionId)) blockers.push(`${peer.id}: saved ACP session identity unavailable; end the peer and make a new recovery plan`);
|
|
272
280
|
} else if (peer.id === "pi" && peer.args?.mode === "tui") {
|
|
273
281
|
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");
|
|
274
282
|
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 } : {}) };
|
|
275
|
-
} else if (peer.id !== "kimi" && peer.id !== "local" && peer.id !== "pi") blockers.push(`${peer.id}: no automatic recovery adapter`);
|
|
283
|
+
} else if (peer.id !== "kimi" && peer.id !== "qwen" && peer.id !== "local" && peer.id !== "pi") blockers.push(`${peer.id}: no automatic recovery adapter`);
|
|
276
284
|
}
|
|
277
285
|
const terminals = await inspectTerminals(project.root, sessions, {
|
|
278
286
|
...terminalOptions(run), stateDir: project.stateDir, instanceId: source.instanceId,
|
|
@@ -449,6 +457,16 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
|
|
|
449
457
|
// A probe that did not answer (killed, a null exit code maps to 1) says nothing about the target: retry.
|
|
450
458
|
if (!Number.isInteger(read)) throw new Error(`the staged target's control protocol could not be read (probe exit ${protocol.code}); staged package retained, runtimes unchanged; resume again once it can run`);
|
|
451
459
|
if (read !== PROTOCOL) throw new FinalRefusal("target protocol requires a newer coordinator; staged package retained, runtimes unchanged");
|
|
460
|
+
if (op.plan.projects.some(p => p.source.peers.some(peer => peer.id === "qwen" && peer.state !== "offline"))) {
|
|
461
|
+
const catalog = join(target.root, "src/hub/peer-catalog.ts");
|
|
462
|
+
const peers = existsSync(catalog) ? /export const OPTIONAL_PEERS\s*=\s*\[([^\]]*)\]/.exec(readFileSync(catalog, "utf8"))?.[1] : undefined;
|
|
463
|
+
if (!peers || !/["']qwen["']/.test(peers)) throw new FinalRefusal("target does not support Qwen; end Qwen and make a new plan, or choose a supporting target; runtimes unchanged");
|
|
464
|
+
}
|
|
465
|
+
if (op.plan.projects.some(p => p.source.peers.some(peer => peer.id === "jcode" && peer.state !== "offline"))) {
|
|
466
|
+
const catalog = join(target.root, "src/hub/peer-catalog.ts");
|
|
467
|
+
const peers = existsSync(catalog) ? /export const OPTIONAL_PEERS\s*=\s*\[([^\]]*)\]/.exec(readFileSync(catalog, "utf8"))?.[1] : undefined;
|
|
468
|
+
if (!peers || !/["']jcode["']/.test(peers)) throw new FinalRefusal("target does not support Jcode; end it and make a new plan, or choose a supporting target; runtimes unchanged");
|
|
469
|
+
}
|
|
452
470
|
// Reconnect-only (#206) and fresh sessions (#215) need a target hub that reads recovery-waivers.json.
|
|
453
471
|
if (op.plan.projects.some((p) => p.reconnectOnly?.length || p.freshStart?.length) && !targetReadsWaivers({ targetRoot: target.root })) {
|
|
454
472
|
throw new FinalRefusal(`target ${op.plan.version} predates recovery waivers, so its hub could never accept a reconnect-only Claude or a fresh Codex start; staged package retained, runtimes unchanged; choose a newer target once this operation is cancelled or ended`);
|
|
@@ -512,14 +530,25 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
|
|
|
512
530
|
const live = await inspectRecovery(planned.project);
|
|
513
531
|
if (live.instanceId !== progress.instanceId) throw new Error("target daemon changed before peer restore");
|
|
514
532
|
if (group === "native") {
|
|
515
|
-
for (const peer of planned.source.peers.filter((p) => ["kimi", "local", "pi"].includes(p.id) && p.state !== "offline" && !(p.id === "pi" && p.args?.mode === "tui"))) {
|
|
533
|
+
for (const peer of planned.source.peers.filter((p) => ["kimi", "qwen", "jcode", "local", "pi"].includes(p.id) && p.state !== "offline" && !(p.id === "pi" && p.args?.mode === "tui"))) {
|
|
516
534
|
const current = live.peers.find((p) => p.id === peer.id);
|
|
517
535
|
if (current && current.state !== "offline") continue;
|
|
518
536
|
const args = [peer.id];
|
|
519
|
-
|
|
537
|
+
if (peer.id === "kimi" && peer.args?.mode === "tui") throw new Error("Kimi's pull-only terminal cannot be restored as a headless worker; end it in that terminal and make a new recovery plan");
|
|
538
|
+
for (const key of ["mode", "backend", "model", "route"]) if (!(peer.id === "kimi" && key === "mode") && typeof peer.args?.[key] === "string") args.push(`--${key}`, peer.args[key]!);
|
|
539
|
+
if (peer.id === "kimi") {
|
|
540
|
+
// ponytail: a staged target's fixed catalog is the capability signal; replace this source check with
|
|
541
|
+
// an explicit recovery capability once targets publish one. Older targets have only headless Kimi.
|
|
542
|
+
const modeFile = join(op.targetRoot!, "src/hub/start-mode.ts");
|
|
543
|
+
const modes = existsSync(modeFile) ? /export const START_MODE_PEERS\s*=\s*\[([^\]]*)\]/.exec(readFileSync(modeFile, "utf8"))?.[1] : undefined;
|
|
544
|
+
if (modes && /["']kimi["']/.test(modes)) args.push("--mode", "headless");
|
|
545
|
+
}
|
|
520
546
|
// #269: `ahub pi` without a mode now takes Pi's start mode (tui by default). This Pi ran headless (a terminal
|
|
521
547
|
// one is filtered out above), so a snapshot that recorded no mode still restores it headless.
|
|
522
548
|
if (peer.id === "pi" && typeof peer.args?.mode !== "string") args.push("--mode", "headless");
|
|
549
|
+
if (peer.id === "qwen" && peer.sessionId) args.push("--session-id", peer.sessionId);
|
|
550
|
+
if (peer.id === "jcode" && peer.sessionId) args.push("--session-id", peer.sessionId);
|
|
551
|
+
if (peer.id === "jcode" && peer.args?.providerProfile) args.push("--provider-profile", peer.args.providerProfile);
|
|
523
552
|
if (peer.id === "pi") {
|
|
524
553
|
const sessionFile = peer.sessionFile ?? peer.args?.sessionFile;
|
|
525
554
|
if (typeof sessionFile === "string") args.push("--session-file", sessionFile);
|