@staix/agent-hub 0.12.43 → 0.12.44
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 +16 -3
- package/README.md +3 -3
- package/docs/adding-an-ai-tool.md +140 -0
- package/docs/agent-notes/README.md +5 -0
- package/docs/agent-notes/adapters.md +8 -3
- package/docs/agent-notes/budget.md +2 -2
- package/docs/agent-notes/daemon.md +9 -5
- package/docs/agent-notes/tasks.md +6 -2
- package/docs/agent-notes/tests.md +3 -1
- package/docs/operations.md +64 -5
- package/docs/quickstart.md +6 -2
- package/docs/security.md +5 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +160 -4
- package/docs/verification/2026-10-21-0.12.44.md +54 -0
- package/docs/verification/2026-10-21-grok-config-acp-t0.md +30 -0
- package/docs/verified.json +34 -19
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +743 -229
- package/src/adapters/acp-owned-group.ts +124 -0
- package/src/adapters/acp.ts +92 -25
- package/src/adapters/agy/definition.ts +1 -3
- package/src/adapters/agy/entry.ts +4 -1
- package/src/adapters/channel-peer.ts +143 -0
- package/src/adapters/claude-channel.ts +7 -7
- package/src/adapters/codex-appserver.ts +16 -2
- package/src/adapters/configured-acp.ts +124 -0
- package/src/adapters/definitions/builtin-metadata.ts +37 -33
- package/src/adapters/definitions/native-channel-state.ts +41 -0
- package/src/adapters/definitions/native-cli-entry-kimi.ts +3 -1
- package/src/adapters/definitions/native-cli-preview-qwen.ts +1 -1
- package/src/adapters/definitions/native-identity-defaults.ts +14 -0
- package/src/adapters/definitions/qwen.ts +3 -8
- package/src/adapters/definitions/reviewer-variants.ts +3 -7
- package/src/adapters/jcode.ts +4 -0
- package/src/adapters/local-worker.ts +4 -0
- package/src/adapters/pi.ts +4 -0
- package/src/adapters/registry-types.ts +18 -16
- package/src/adapters/registry.ts +68 -6
- package/src/adapters/reviewer-print.ts +4 -0
- package/src/cli/agy-mcp.ts +3 -2
- package/src/cli/configured-acp-entry.ts +58 -0
- package/src/cli/console-state.ts +9 -4
- package/src/cli/facts-hook.ts +2 -1
- package/src/cli/help.ts +6 -0
- package/src/cli/identity.ts +7 -6
- package/src/cli/launch.ts +5 -4
- package/src/cli/main.ts +78 -27
- package/src/cli/memory.ts +13 -10
- package/src/cli/output.ts +1 -0
- package/src/cli/preview.ts +9 -0
- package/src/cli/recovery-guidance.ts +1 -1
- package/src/cli/terminal-recovery.ts +16 -13
- package/src/cli/upgrade-restore-state.ts +3 -2
- package/src/cli/upgrade-runtime.ts +21 -6
- package/src/cli/upgrade.ts +3 -2
- package/src/hub/board.ts +9 -6
- package/src/hub/budget.ts +9 -2
- package/src/hub/bus.ts +6 -2
- package/src/hub/claude-state.ts +2 -37
- package/src/hub/conductor.ts +8 -6
- package/src/hub/config-apply.ts +26 -14
- package/src/hub/config-reload.ts +39 -11
- package/src/hub/config-trust.ts +1 -1
- package/src/hub/context-window.ts +6 -0
- package/src/hub/control-client.ts +1 -1
- package/src/hub/crash.ts +8 -6
- package/src/hub/daemon.ts +341 -313
- package/src/hub/hub-tools.ts +36 -0
- package/src/hub/instance-config.ts +20 -11
- package/src/hub/instance-execution.ts +4 -4
- package/src/hub/instances.ts +45 -21
- package/src/hub/lifecycle-schema.ts +5 -3
- package/src/hub/memory-profile.ts +16 -3
- package/src/hub/no-writes.ts +50 -0
- package/src/hub/permission-mode.ts +9 -7
- package/src/hub/roster.ts +7 -4
- package/src/hub/routing.ts +21 -20
- package/src/hub/settings.ts +50 -16
- package/src/hub/snapshots.ts +26 -2
- package/src/hub/start-mode.ts +10 -8
- package/src/hub/task-sweep.ts +8 -2
- package/src/hub/tasks.ts +85 -17
- package/src/memory/capture.ts +2 -1
- package/src/ui/index.html +5 -0
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +1 -1
- package/templates/profiles/writing.json +1 -1
package/src/hub/board.ts
CHANGED
|
@@ -15,9 +15,9 @@ export type TaskState = "backlog" | "proposed" | "in_progress" | "in_review" | "
|
|
|
15
15
|
export const isClosedTaskState = (state: string): boolean => state === "approved" || state === "cancelled";
|
|
16
16
|
export interface TaskOffer { envelopeId: string; owner: PeerId; assignmentFlowRevision: number; pullOnly?: boolean }
|
|
17
17
|
export interface TaskCancellation { reason: string; supersededBy?: string; flowRevision: number; withheld?: true }
|
|
18
|
-
export const TASK_STALL_CODES = ["tests", "spec", "roster", "pull-only", "not-attached", "excluded", "owner-admission", "offline", "paused", "failing", "pii", "policy", "cancelled", "unavailable"] as const;
|
|
18
|
+
export const TASK_STALL_CODES = ["tests", "spec", "roster", "pull-only", "not-attached", "excluded", "owner-admission", "offline", "paused", "failing", "pii", "policy", "cancelled", "unavailable", "no-writes"] as const;
|
|
19
19
|
export type TaskStallCode = (typeof TASK_STALL_CODES)[number];
|
|
20
|
-
export interface TaskStall { task: number; signature: string; replanRevision: number; cause: { kind: "reservation" | "no-eligible-peer" | "cancelled-dependency"; peer?: string; dependency?: number; code: TaskStallCode; refusals?: { peer: string; code: TaskStallCode }[] }; at: number }
|
|
20
|
+
export interface TaskStall { task: number; signature: string; replanRevision: number; cause: { kind: "reservation" | "no-eligible-peer" | "cancelled-dependency" | "no-writes"; peer?: string; dependency?: number; code: TaskStallCode; turns?: number; acceptance?: number; refusals?: { peer: string; code: TaskStallCode }[] }; at: number }
|
|
21
21
|
|
|
22
22
|
export interface TaskRefs {
|
|
23
23
|
repo?: string;
|
|
@@ -55,6 +55,8 @@ export interface HistoryEntry {
|
|
|
55
55
|
sweep?: SweepRecord;
|
|
56
56
|
/** The PII screen did not clear this entry's note (#198): only the console reads it. */
|
|
57
57
|
withheld?: true;
|
|
58
|
+
/** Host evidence at done, never inferred from the owner's summary. */
|
|
59
|
+
noWrites?: true;
|
|
58
60
|
/** Host-recorded expected cost and exact review head, never narrative. */
|
|
59
61
|
reviewCost?: ReviewCost;
|
|
60
62
|
/** #223 R4: on a verdict, whether the reviewer's context was new for it; set by the hub, never by the reviewer. */
|
|
@@ -287,10 +289,11 @@ export class Board {
|
|
|
287
289
|
const value = JSON.parse(row.payload) as TaskStall, cause = value.cause;
|
|
288
290
|
const peer = (id: unknown) => typeof id === "string" && /^[a-z][a-z0-9_-]{0,63}$/i.test(id);
|
|
289
291
|
if (Object.keys(value).some(key => !["task", "signature", "replanRevision", "cause", "at"].includes(key)) || !Number.isSafeInteger(value.task) || value.task < 1 || !Number.isSafeInteger(value.replanRevision) || value.replanRevision < 0 || !Number.isFinite(value.at)
|
|
290
|
-
|| !cause || !["reservation", "no-eligible-peer", "cancelled-dependency"].includes(cause.kind) || !TASK_STALL_CODES.includes(cause.code)
|
|
292
|
+
|| !cause || !["reservation", "no-eligible-peer", "cancelled-dependency", "no-writes"].includes(cause.kind) || !TASK_STALL_CODES.includes(cause.code)
|
|
293
|
+
|| cause.kind === "no-writes" && (cause.code !== "no-writes" || !peer(cause.peer) || !Number.isSafeInteger(cause.turns) || cause.turns! < 1 || !Number.isSafeInteger(cause.acceptance) || cause.acceptance! < 0)
|
|
291
294
|
|| cause.peer !== undefined && !peer(cause.peer) || cause.dependency !== undefined && (!Number.isSafeInteger(cause.dependency) || cause.dependency < 1)
|
|
292
295
|
|| cause.refusals !== undefined && (!Array.isArray(cause.refusals) || cause.refusals.length > 64 || cause.refusals.some(row => Object.keys(row).some(key => !["peer", "code"].includes(key)) || !peer(row.peer) || !TASK_STALL_CODES.includes(row.code)))
|
|
293
|
-
|| Object.keys(cause).some(key => !["kind", "peer", "dependency", "code", "refusals"].includes(key)) || value.signature !== JSON.stringify(cause)) return [];
|
|
296
|
+
|| Object.keys(cause).some(key => !["kind", "peer", "dependency", "code", "refusals", ...(cause.kind === "no-writes" ? ["turns", "acceptance"] : [])].includes(key)) || value.signature !== JSON.stringify(cause)) return [];
|
|
294
297
|
return [value];
|
|
295
298
|
} catch { return []; }
|
|
296
299
|
});
|
|
@@ -304,7 +307,7 @@ export class Board {
|
|
|
304
307
|
|
|
305
308
|
/** The only way a task changes. Validates the move, records who did what, returns the new row. */
|
|
306
309
|
/** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
|
|
307
|
-
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep" | "reason" | "from" | "capacity" | "withheld" | "reviewCost" | "reviewContext" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number): Task {
|
|
310
|
+
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled">>, note?: string, extra: Pick<HistoryEntry, "profile" | "sweep" | "reason" | "from" | "capacity" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number): Task {
|
|
308
311
|
const task = this.get(id);
|
|
309
312
|
if (!task) throw new Error(`no task #${id}`);
|
|
310
313
|
if (expectedFlowRevision !== undefined && (!Number.isSafeInteger(expectedFlowRevision) || expectedFlowRevision < 0 || task.history.length !== expectedFlowRevision)) throw new Error("task flow revision changed");
|
|
@@ -320,7 +323,7 @@ export class Board {
|
|
|
320
323
|
}
|
|
321
324
|
if (patch.state === "backlog" && taskHasStarted(task) && !untouchedReturn) throw new Error(`task #${id}: started work cannot return to backlog`);
|
|
322
325
|
const reviewMetadata = reviewerHistory(extra);
|
|
323
|
-
const history = [...task.history, { at: Date.now(), by, event, ...reviewMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.capacity === true ? { capacity: true as const } : {}), ...(extra.withheld && note ? { withheld: true as const } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}) }];
|
|
326
|
+
const history = [...task.history, { at: Date.now(), by, event, ...reviewMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.capacity === true ? { capacity: true as const } : {}), ...(extra.withheld && note ? { withheld: true as const } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}) }];
|
|
324
327
|
this.taskWrite(() => this.db
|
|
325
328
|
.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ? WHERE id = ?")
|
|
326
329
|
.run(next.state, next.owner, next.reviewer, next.reserved ?? null, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(next.signals), JSON.stringify(history), next.sdlc ? JSON.stringify(next.sdlc) : null, next.role ?? null, next.cancelled ? JSON.stringify(next.cancelled) : null, Date.now(), id));
|
package/src/hub/budget.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { definitionOf } from "../adapters/registry.ts";
|
|
1
|
+
import { definitionOf, type AdapterDefinition } from "../adapters/registry.ts";
|
|
2
2
|
import { Database } from "bun:sqlite";
|
|
3
3
|
import type { PeerId } from "./envelope.ts";
|
|
4
4
|
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
@@ -13,6 +13,10 @@ export interface UsageWindow {
|
|
|
13
13
|
windowMins?: number;
|
|
14
14
|
source: string;
|
|
15
15
|
}
|
|
16
|
+
/** No declared source is unknown, not a synthetic empty provider reading. */
|
|
17
|
+
export function readNativeQuota(definition: AdapterDefinition | undefined, value: unknown): UsageWindow[] | undefined {
|
|
18
|
+
return definition?.budget?.reader?.read(value);
|
|
19
|
+
}
|
|
16
20
|
interface Reading extends UsageWindow {
|
|
17
21
|
at: number;
|
|
18
22
|
}
|
|
@@ -46,6 +50,8 @@ export interface BudgetConfig {
|
|
|
46
50
|
export const DEFAULT_BUDGET: BudgetConfig = { gate: 0.9, stale_min: 30, poll_min: 10, checkpoint_timeout_s: 90, kimi_tokens_5h: 0, wait_max_min: 0 };
|
|
47
51
|
|
|
48
52
|
export interface BudgetHooks {
|
|
53
|
+
/** Startup-bound project resolver; undefined keeps builtin-only compatibility. */
|
|
54
|
+
definitionOf?(peer: PeerId): AdapterDefinition | undefined;
|
|
49
55
|
/** Stable/idempotent for this Budget lifetime. All lifecycle hooks receive this canonical account id. */
|
|
50
56
|
account?(peer: PeerId): PeerId;
|
|
51
57
|
pause(peer: PeerId): void;
|
|
@@ -202,7 +208,8 @@ export class Budget {
|
|
|
202
208
|
// Explicit quota exemption or absent budget metadata exempts registered kinds; external ids keep accounting.
|
|
203
209
|
const reporter = peer;
|
|
204
210
|
peer = this.account(peer);
|
|
205
|
-
const
|
|
211
|
+
const resolve = this.hooks.definitionOf ?? definitionOf;
|
|
212
|
+
const adapter = resolve(reporter) ?? resolve(peer);
|
|
206
213
|
if (reporter === "user" || reporter === "hub" || peer === "user" || peer === "hub" || adapter && (!adapter.budget || adapter.budget.quotaExempt === true) || this.closed) return;
|
|
207
214
|
// An override ignores soft readings, not a provider that has actually refused another turn.
|
|
208
215
|
if (hard && this.ignoreUntil.delete(peer)) {
|
package/src/hub/bus.ts
CHANGED
|
@@ -322,7 +322,7 @@ export class Bus {
|
|
|
322
322
|
try {
|
|
323
323
|
this.deferredHub.delete(env.id);
|
|
324
324
|
this.publishNow(env);
|
|
325
|
-
} catch
|
|
325
|
+
} catch {
|
|
326
326
|
// Only publication state is transactional. A live peer's preface, pauses, attempts and
|
|
327
327
|
// recovery mode are independent state, not something a failed checkpoint may reload.
|
|
328
328
|
this.deferredHub.clear(); for (const [id, pending] of beforeDeferred) this.deferredHub.set(id, pending);
|
|
@@ -673,7 +673,11 @@ export class Bus {
|
|
|
673
673
|
this.prefaces.set(id, !now ? { ...env, body: uniqueMemoryLines(env.body) } : now.id === env.id ? { ...now, body: uniqueMemoryLines(now.body) } : { ...env, body: keepNotes(mergePrefaces(env.body, now.body), NOTES_KEPT) });
|
|
674
674
|
}
|
|
675
675
|
|
|
676
|
-
/**
|
|
676
|
+
/**
|
|
677
|
+
* Reset only: audit every deferred recipient without publishing or releasing work. The guard below is what it
|
|
678
|
+
* checks: no attached peers, no flush or replay in progress and a readable journal. It cannot see a running
|
|
679
|
+
* daemon, so the caller (`resetRuntime`) must hold the stopped-hub proof; a bare Bus has none.
|
|
680
|
+
*/
|
|
677
681
|
discardDeferredHub(reason: string): string[] {
|
|
678
682
|
if (this.peers.size || this.flushingHub || this.replayingHub || !this.journal || this.storageError) throw new Error("deferred reset requires a stopped, readable delivery journal");
|
|
679
683
|
const pending = [...this.deferredHub];
|
package/src/hub/claude-state.ts
CHANGED
|
@@ -1,37 +1,2 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
/** The peer a Claude process writes for: its AGENTHUB_PEER_ID when well formed, otherwise the builtin claude peer. */
|
|
5
|
-
export function claudePeer(value: string | undefined): string {
|
|
6
|
-
return value !== undefined && PEER.test(value) ? value : "claude";
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
/** Per-peer Claude state file names. The builtin claude peer keeps its original names, so nothing migrates. */
|
|
10
|
-
export function claudeStateFile(base: "claude-launch" | "claude-session" | "claude-context", peer: string): string {
|
|
11
|
-
// Instance ids are always <kind>-<name>; a builtin id has no hyphen.
|
|
12
|
-
return peer.includes("-") && PEER.test(peer) ? `${base}.${peer}.json` : `${base}.json`;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/** A Claude peer's own row in terminal-recovery.json for this daemon, never a sibling instance's (row order is arbitrary). */
|
|
16
|
-
export function ownTerminalRow(records: unknown, peer: string, projectRoot: string, instanceId: string): { launchId?: unknown } | undefined {
|
|
17
|
-
if (!Array.isArray(records)) return undefined;
|
|
18
|
-
return records.find((row) => row?.peer === peer && row?.projectRoot === projectRoot && row?.instanceId === instanceId);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Whether a Claude session's transcript exists: the path the peer's own status line reported for that session, else
|
|
23
|
-
* the builtin's default location. An instance may run under its own CLAUDE_CONFIG_DIR, which the hub cannot see:
|
|
24
|
-
* without its reported path the answer is unknown, and unknown counts as persisted (it never waives session identity).
|
|
25
|
-
*/
|
|
26
|
-
export function transcriptPersisted(reported: { sessionId?: string; transcriptPath?: string }, sessionId: string, instance: boolean, fallbackPath: string, exists: (path: string) => boolean | "unknown"): boolean {
|
|
27
|
-
// Only a positive "missing" (ENOENT) is not persisted; an unreadable path is unknown and keeps the identity strict.
|
|
28
|
-
const found = (path: string) => exists(path) !== false;
|
|
29
|
-
if (reported.sessionId === sessionId && reported.transcriptPath) return found(reported.transcriptPath);
|
|
30
|
-
return instance || found(fallbackPath);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** lstat evidence: true when present, false only on ENOENT/ENOTDIR, "unknown" for any other failure (EACCES included). */
|
|
34
|
-
export function pathEvidence(path: string, lstat: (path: string) => unknown): boolean | "unknown" {
|
|
35
|
-
try { lstat(path); return true; }
|
|
36
|
-
catch (error) { const code = (error as { code?: string }).code; return code === "ENOENT" || code === "ENOTDIR" ? false : "unknown"; }
|
|
37
|
-
}
|
|
1
|
+
// Stable leaf import for released statusline/CLI callers. Native policy belongs to the adapter.
|
|
2
|
+
export { claudePeer, claudeStateFile, ownTerminalRow, transcriptPersisted, pathEvidence } from "../adapters/definitions/native-channel-state.ts";
|
package/src/hub/conductor.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { definitionOf, type KindMap } from "../adapters/registry.ts";
|
|
1
|
+
import { definitionOf, type KindMap, type ProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
2
2
|
import { Database } from "bun:sqlite";
|
|
3
3
|
import type { Task } from "./board.ts";
|
|
4
4
|
import { CONDUCTOR_TOOL_NAMES } from "./hub-tools.ts";
|
|
@@ -10,6 +10,7 @@ import type { HubEvent } from "./events.ts";
|
|
|
10
10
|
import type { SupervisionFeed } from "./supervision.ts";
|
|
11
11
|
import type { Budget } from "./budget.ts";
|
|
12
12
|
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
13
|
+
import { supportsHeadlessInstances } from "./instances.ts";
|
|
13
14
|
|
|
14
15
|
/** The production ProgressObserver sink forwards structured verdicts, never its observations/reasoning. */
|
|
15
16
|
export function conductorProgressSink(record: (event: HubEvent) => void, feed: Pick<SupervisionFeed, "milestone">): (event: HubEvent) => void {
|
|
@@ -109,14 +110,14 @@ export type ConductorStart = { peer: string; mode: "headless" } | { peer: "claud
|
|
|
109
110
|
* What a conductor's start of `peer` is (#269): the peer's own start mode, which a person sets. A `mode` argument may
|
|
110
111
|
* only repeat it. Preview commands come from the same launch planner the CLI uses, never caller-supplied shell text.
|
|
111
112
|
*/
|
|
112
|
-
export function conductorStart(peer: unknown, mode: unknown, preview: (peer: "claude" | "codex" | "pi") => string, preEffect: boolean, startMode: (peer: string) => StartMode, instances?: KindMap): ConductorStart {
|
|
113
|
+
export function conductorStart(peer: unknown, mode: unknown, preview: (peer: "claude" | "codex" | "pi") => string, preEffect: boolean, startMode: (peer: string) => StartMode, instances?: KindMap, adapterCatalog?: ProjectAdapterCatalog): ConductorStart {
|
|
113
114
|
const refuse = (text: string) => new (preEffect ? PreEffectToolRefusal : Error)(text);
|
|
114
115
|
if (mode !== undefined && mode !== "headless" && mode !== "tui") throw refuse("mode must be headless or tui");
|
|
115
|
-
const adapter = typeof peer === "string" ? definitionOf(peer, instances) : undefined;
|
|
116
|
+
const adapter = typeof peer === "string" ? (adapterCatalog?.definitionOf ?? definitionOf)(peer, instances) : undefined;
|
|
116
117
|
if (typeof peer !== "string" || !adapter?.start.conductorStartable) throw refuse("only claude, codex, kimi, pi and local, plus opt-in qwen/jcode, may be started by the conductor");
|
|
117
118
|
const selected = peer;
|
|
118
119
|
const extra = adapter.kind !== selected;
|
|
119
|
-
if (extra &&
|
|
120
|
+
if (extra && !supportsHeadlessInstances(adapter)) throw refuse(`${peer}: this native transport does not support extra headless instances`);
|
|
120
121
|
const own = startMode(selected);
|
|
121
122
|
if (extra && own !== "headless") throw refuse(`${peer}: extra instances require headless mode in I2`);
|
|
122
123
|
if (mode !== undefined && mode !== own) throw refuse(`${peer} starts ${own} here; a person sets a start mode (ahub settings), the conductor cannot choose one`);
|
|
@@ -156,6 +157,7 @@ export interface ConductEvent {
|
|
|
156
157
|
kind: "conduct"; actor: string; action: string; task?: number; peer?: string;
|
|
157
158
|
}
|
|
158
159
|
export interface ConductorHooks {
|
|
160
|
+
adapterCatalog?: ProjectAdapterCatalog;
|
|
159
161
|
roles(): unknown;
|
|
160
162
|
capabilities(): Record<string, unknown>;
|
|
161
163
|
status(): ConductorStatusInput;
|
|
@@ -236,8 +238,8 @@ export class Conductor {
|
|
|
236
238
|
}
|
|
237
239
|
if (tool === "hub_peer_start") {
|
|
238
240
|
const instances = this.hooks.instances?.();
|
|
239
|
-
const start = conductorStart(args.peer, args.mode, this.hooks.preview, true, this.hooks.startMode, instances);
|
|
240
|
-
if (definitionOf(start.peer, instances)?.kind !== start.peer && !this.hooks.known(start.peer)) throw new PreEffectToolRefusal(`unknown peer: ${start.peer}`);
|
|
241
|
+
const start = conductorStart(args.peer, args.mode, this.hooks.preview, true, this.hooks.startMode, instances, this.hooks.adapterCatalog);
|
|
242
|
+
if ((this.hooks.adapterCatalog?.definitionOf ?? definitionOf)(start.peer, instances)?.kind !== start.peer && !this.hooks.known(start.peer)) throw new PreEffectToolRefusal(`unknown peer: ${start.peer}`);
|
|
241
243
|
const result = await this.hooks.start(start.peer);
|
|
242
244
|
const opened = result && typeof result === "object" && typeof (result as { opened?: unknown }).opened === "string" ? { opened: (result as { opened: string }).opened } : {};
|
|
243
245
|
const already = result && typeof result === "object" && (result as { already?: unknown }).already === true ? { already: true } : {};
|
package/src/hub/config-apply.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { reviewerPoolConfig } from "./reviewer-pool-config.ts";
|
|
2
2
|
import type { HubConfig } from "./daemon.ts";
|
|
3
3
|
import { ALL_MACHINE_LOCAL, configRepositoryPresent, configTracked } from "./config-trust.ts";
|
|
4
|
-
import { ADAPTERS, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS, SUPPORTED_PEERS, PLANNER_PEERS, START_MODE_PEERS } from "../adapters/registry.ts";
|
|
4
|
+
import { createProjectAdapterCatalog, type ProjectAdapterCatalog, ADAPTERS, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS, SUPPORTED_PEERS, PLANNER_PEERS, START_MODE_PEERS } from "../adapters/registry.ts";
|
|
5
5
|
import { CLASSES } from "./board.ts";
|
|
6
6
|
import { permissionDefaults } from "./permission-mode.ts";
|
|
7
7
|
import { peerStartConfig, terminalTemplate } from "./start-mode.ts";
|
|
@@ -16,6 +16,12 @@ import { petPalette } from "../pet/config.ts";
|
|
|
16
16
|
|
|
17
17
|
export type ApplyClass = "live" | "next use" | "restart";
|
|
18
18
|
export interface ApplyRule { applies: ApplyClass; reason?: string }
|
|
19
|
+
export interface ConfigIdentityScope { catalog: ProjectAdapterCatalog; instances: InstancesConfig }
|
|
20
|
+
/** Plain captured metadata stays cloneable with ConfigSources; native callbacks are compiled outside it. */
|
|
21
|
+
export interface ConfigIdentitySnapshot { adapters?: HubConfig["adapters"]; instances: InstancesConfig }
|
|
22
|
+
export function configIdentityScope(snapshot: ConfigIdentitySnapshot): ConfigIdentityScope {
|
|
23
|
+
return { catalog: createProjectAdapterCatalog(snapshot.adapters), instances: snapshot.instances };
|
|
24
|
+
}
|
|
19
25
|
export interface ConfigChange extends ApplyRule { key: string; before: unknown; after: unknown }
|
|
20
26
|
export interface ConfigLayer { name: "config.json" | "config.local.json"; raw: Record<string, unknown> }
|
|
21
27
|
export interface ReloadGuard { key: string; path: readonly string[]; risk: "safe" | "raises"; floor?: string | boolean | number; store: "runtime" | "config" | "routing" }
|
|
@@ -128,7 +134,7 @@ const schema: Record<string, Check> = {
|
|
|
128
134
|
...Object.assign({}, ...ADAPTERS.map(adapter => adapter.configuration?.schema?.(nativeValidators) ?? {})),
|
|
129
135
|
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,
|
|
130
136
|
permission_modes: v => { permissionDefaults(v); }, protect_instruction_files: bool,
|
|
131
|
-
instances: v => { validateInstances(v); }, pet: validatePetConfig,
|
|
137
|
+
adapters: v => { createProjectAdapterCatalog(v); }, instances: v => { validateInstances(v, createProjectAdapterCatalog()); }, pet: validatePetConfig,
|
|
132
138
|
batch_max: number(1, 100_000, true), batch_ms: number(0, 2_147_483_647), queue_cap: number(1, 1_000_000, true),
|
|
133
139
|
memory: block({ enabled: bool, worker_url: text, inject_tokens: number(0, 1_000_000, true), brief_items: number(0, 10_000, true), native: bool,
|
|
134
140
|
short_ttl_days: number(1, 3650, true), sync_interval_min: number(0, 1440, true), suggest_edges: bool }),
|
|
@@ -145,7 +151,7 @@ const schema: Record<string, Check> = {
|
|
|
145
151
|
mlx: block({ enabled: bool, provider: words(["legacy", "ollama"]), host: text, runtimeDir: text, modelPath: text, bin: text, port: number(1, 65535, true), model: text, sourceModel: text,
|
|
146
152
|
contextWindow: number(1), maxInputTokens: number(1), maxTokens: number(1), maxConcurrency: number(1, 1024, true) }),
|
|
147
153
|
approvals: block({ timeout_s: timerSeconds, notify: bool }), tasks: block({ release_after_min: number(0), wip_limits: map(number(0, 32, true)), escalate_after: number(1, 10, true) }),
|
|
148
|
-
task_sweep: (v, k) => { block({ enabled: bool, auto_reassign: bool, interval_s: timerSeconds, unaccepted_min: timerMinutes, idle_min: timerMinutes, review_min: timerMinutes, ready_min: timerMinutes, ladder_min: timerMinutes })(v, k); taskSweepConfig(v); },
|
|
154
|
+
task_sweep: (v, k) => { block({ enabled: bool, auto_reassign: bool, no_writes_turns: number(0, Number.MAX_SAFE_INTEGER, true), interval_s: timerSeconds, unaccepted_min: timerMinutes, idle_min: timerMinutes, review_min: timerMinutes, ready_min: timerMinutes, ladder_min: timerMinutes })(v, k); taskSweepConfig(v); },
|
|
149
155
|
checks: (v, k) => { for (const [key, value] of Object.entries(object(v, k))) {
|
|
150
156
|
if (value === undefined) continue;
|
|
151
157
|
if (key === "timeout_s") timerSeconds(value, `${k}.${key}`);
|
|
@@ -160,12 +166,15 @@ const schema: Record<string, Check> = {
|
|
|
160
166
|
capabilities: map(strings), coordination: words(["advisory", "turn-free"]), experiments: block({ stale_notices: words(["drop", "deliver"]) }),
|
|
161
167
|
};
|
|
162
168
|
export const SCHEMA_KEYS: readonly string[] = Object.freeze(Object.keys(schema));
|
|
163
|
-
export function validateRawConfig(raw: unknown, declarations?: InstancesConfig): asserts raw is Record<string, unknown> {
|
|
169
|
+
export function validateRawConfig(raw: unknown, declarations?: InstancesConfig, context?: ProjectAdapterCatalog): asserts raw is Record<string, unknown> {
|
|
164
170
|
const value = object(raw, "config");
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
block({ ...schema,
|
|
168
|
-
|
|
171
|
+
const catalog = value.adapters !== undefined ? createProjectAdapterCatalog(value.adapters) : context ?? createProjectAdapterCatalog();
|
|
172
|
+
const instances = validateInstances(value.instances ?? declarations, catalog);
|
|
173
|
+
block({ ...schema,
|
|
174
|
+
instances: v => { validateInstances(v, catalog); },
|
|
175
|
+
permission_modes: v => { permissionDefaults(v, instances, catalog); },
|
|
176
|
+
roster: v => { peerRosterConfig(v, instances, catalog); },
|
|
177
|
+
peers: v => { peerStartConfig(v, instances, catalog); },
|
|
169
178
|
})(raw, "config");
|
|
170
179
|
}
|
|
171
180
|
export function validateEffectiveConfig(value: unknown): asserts value is HubConfig {
|
|
@@ -178,6 +187,7 @@ export function validateEffectiveConfig(value: unknown): asserts value is HubCon
|
|
|
178
187
|
}
|
|
179
188
|
|
|
180
189
|
const restart: Record<string, string> = {
|
|
190
|
+
adapters: "Configured adapter identity, commands and capability policy are fixed at daemon startup",
|
|
181
191
|
instances: "Native instance identity, worktree, ports and account bindings are fixed at daemon startup",
|
|
182
192
|
...Object.assign({}, ...ADAPTERS.map(adapter => adapter.configuration?.restartReasons ?? {})),
|
|
183
193
|
memory: "Open memory consumers, selected profile, client and sync timer must be quiesced together",
|
|
@@ -190,21 +200,21 @@ const restart: Record<string, string> = {
|
|
|
190
200
|
const live = new Set(["permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "research", "snapshots", "tasks.wip_limits", "tasks.escalate_after", "approvals.notify", "pet"]);
|
|
191
201
|
const nextMemory = new Set(["memory.inject_tokens", "memory.brief_items", "memory.suggest_edges"]);
|
|
192
202
|
/** Closed top-level schema; leaf overrides retain the existing session/runtime boundaries. */
|
|
193
|
-
export function configApply(key: string): ApplyRule {
|
|
203
|
+
export function configApply(key: string, scopes: readonly ConfigIdentityScope[] = []): ApplyRule {
|
|
194
204
|
const top = key.split(".")[0]!;
|
|
195
205
|
if (!(Object.hasOwn(schema, top) || top === "permission" || top === "routing")) return fail(key, "unknown configuration key");
|
|
196
206
|
if (top !== "routing" && top !== "permission" && key.includes(".")) {
|
|
197
207
|
const special: Record<string, readonly string[]> = {
|
|
198
|
-
task_sweep: ["enabled", "auto_reassign", "interval_s", "unaccepted_min", "idle_min", "review_min", "ready_min", "ladder_min"],
|
|
208
|
+
task_sweep: ["enabled", "auto_reassign", "no_writes_turns", "interval_s", "unaccepted_min", "idle_min", "review_min", "ready_min", "ladder_min"],
|
|
199
209
|
checks: ["timeout_s", "runner", ...CLASSES], terminal: ["open"],
|
|
200
210
|
};
|
|
201
211
|
let check = schema[top];
|
|
202
212
|
for (const [index, part] of key.split(".").slice(1).entries()) {
|
|
203
213
|
const shape = check && shapes.get(check);
|
|
204
214
|
if (shape?.fields) { if (!Object.hasOwn(shape.fields, part)) return fail(key, "unknown configuration key"); check = shape.fields[part]; }
|
|
205
|
-
else if (shape?.item) { if (shape.keys && !shape.keys.includes(part)) return fail(key, "unknown configuration key"); check = shape.item; }
|
|
215
|
+
else if (shape?.item) { if (shape.keys && !shape.keys.includes(part) && !(top === "roster" && scopes.some(scope => scope.catalog.definitionOf(part, scope.instances)))) return fail(key, "unknown configuration key"); check = shape.item; }
|
|
206
216
|
else if (index === 0 && (special[top]?.includes(part) || top === "checks" && /^[a-z][a-z0-9_]{0,47}$/.test(part))) break;
|
|
207
|
-
else if (["permission_modes", "catalog_roles", "profile_overrides", "text_projection", "peers", "instances"].includes(top)) break;
|
|
217
|
+
else if (["permission_modes", "catalog_roles", "profile_overrides", "text_projection", "peers", "instances", "adapters"].includes(top)) break;
|
|
208
218
|
else return fail(key, "unknown configuration key");
|
|
209
219
|
}
|
|
210
220
|
}
|
|
@@ -219,13 +229,15 @@ export function configApply(key: string): ApplyRule {
|
|
|
219
229
|
const canonical = (v: unknown): unknown => Array.isArray(v) ? v.map(canonical) : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map(k => [k, canonical((v as Record<string, unknown>)[k])])) : v;
|
|
220
230
|
const same = (a: unknown, b: unknown): boolean => JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
|
|
221
231
|
const at = (raw: unknown, path: readonly string[]): unknown => path.reduce<unknown>((v, k) => v && typeof v === "object" && Object.hasOwn(v, k) ? (v as Record<string, unknown>)[k] : undefined, raw);
|
|
222
|
-
export function configChanges(running: HubConfig, next: HubConfig): ConfigChange[] {
|
|
232
|
+
export function configChanges(running: HubConfig, next: HubConfig, scopes: readonly ConfigIdentityScope[]): ConfigChange[] {
|
|
223
233
|
const changes: ConfigChange[] = [];
|
|
234
|
+
// Scopes are captured/validated by the host. Their union classifies removed/added keys only; it cannot
|
|
235
|
+
// authorize a declaration or turn a restart-retained hybrid into a fresh startup configuration.
|
|
224
236
|
const walk = (before: unknown, after: unknown, key: string) => {
|
|
225
237
|
if (same(before, after)) return;
|
|
226
238
|
if (before && after && typeof before === "object" && typeof after === "object" && !Array.isArray(before) && !Array.isArray(after)) {
|
|
227
239
|
for (const child of new Set([...Object.keys(before), ...Object.keys(after)])) walk(at(before, [child]), at(after, [child]), key ? `${key}.${child}` : child);
|
|
228
|
-
} else changes.push({ key, before, after, ...configApply(key) });
|
|
240
|
+
} else changes.push({ key, before, after, ...configApply(key, scopes) });
|
|
229
241
|
};
|
|
230
242
|
const clean = (v: HubConfig) => Object.fromEntries(Object.entries(v).filter(([key]) => !["ignored", "retired", "permission_default_sources"].includes(key)));
|
|
231
243
|
walk(clean(running), clean(next), ""); return changes.sort((a, b) => a.key.localeCompare(b.key));
|
package/src/hub/config-reload.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { ADAPTERS } from "../adapters/registry.ts";
|
|
1
|
+
import { ADAPTERS, createProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import { validateRawConfig, type ConfigLayer } from "./config-apply.ts";
|
|
5
|
+
import { validateRawConfig, type ConfigLayer, type ConfigIdentitySnapshot } from "./config-apply.ts";
|
|
6
6
|
import { parseOverlay, parseRouting, type Routing } from "./routing.ts";
|
|
7
7
|
import { CLASSES } from "./board.ts";
|
|
8
|
+
import { readInstanceConfig } from "./instance-config.ts";
|
|
8
9
|
import { validateInstances } from "./instances.ts";
|
|
9
10
|
|
|
10
11
|
export const CONFIG_SOURCE_FILES = ["config.json", "config.local.json", "routing.toml", "routing.local.toml"] as const;
|
|
@@ -16,6 +17,8 @@ export interface ConfigSources {
|
|
|
16
17
|
trustLayers: { name: ConfigSourceName; raw: Record<string, unknown> }[];
|
|
17
18
|
routing: Routing;
|
|
18
19
|
fingerprint: string;
|
|
20
|
+
/** Captured trusted declarations, never a peer-name prefix or a request value. */
|
|
21
|
+
identity: ConfigIdentitySnapshot;
|
|
19
22
|
}
|
|
20
23
|
const MAX_CONFIG_BYTES = 1024 * 1024;
|
|
21
24
|
|
|
@@ -83,16 +86,36 @@ function validateRoutingRaw(value: unknown): void {
|
|
|
83
86
|
}
|
|
84
87
|
|
|
85
88
|
/** Strict reloads never use routing's last-good fallback. Startup can retain its existing fallback explicitly. */
|
|
86
|
-
export function parseConfigSources(texts: ConfigSourceTexts, fallbackRouting?: Routing): ConfigSources {
|
|
89
|
+
export function parseConfigSources(texts: ConfigSourceTexts, fallbackRouting?: Routing, cwd?: string): ConfigSources {
|
|
87
90
|
const layers: ConfigLayer[] = [], trustLayers: ConfigSources["trustLayers"] = [];
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
+
const jsonLayers: ConfigLayer[] = [];
|
|
92
|
+
for (const name of ["config.json", "config.local.json"] as const) {
|
|
93
|
+
const text = texts[name];
|
|
94
|
+
if (text === null) continue;
|
|
95
|
+
try {
|
|
96
|
+
const value: unknown = JSON.parse(text);
|
|
97
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error();
|
|
98
|
+
jsonLayers.push({ name, raw: value as Record<string, unknown> });
|
|
99
|
+
} catch {
|
|
100
|
+
if (!fallbackRouting) throw new Error(`.agenthub/${name}: invalid JSON object`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const localRaw = jsonLayers.find(layer => layer.name === "config.local.json")?.raw ?? {};
|
|
91
104
|
let declared;
|
|
92
|
-
|
|
93
|
-
|
|
105
|
+
let identityAdapters: ConfigIdentitySnapshot["adapters"];
|
|
106
|
+
let catalog = createProjectAdapterCatalog();
|
|
107
|
+
try {
|
|
108
|
+
if (cwd) {
|
|
109
|
+
const accepted = readInstanceConfig(cwd, jsonLayers);
|
|
110
|
+
catalog = accepted.catalog; declared = accepted.instances; identityAdapters = accepted.adapters;
|
|
111
|
+
} else {
|
|
112
|
+
catalog = createProjectAdapterCatalog(localRaw?.adapters);
|
|
113
|
+
identityAdapters = localRaw?.adapters as ConfigIdentitySnapshot["adapters"];
|
|
114
|
+
declared = validateInstances(localRaw?.instances, catalog);
|
|
115
|
+
}
|
|
116
|
+
} catch (error) {
|
|
94
117
|
if (!fallbackRouting) throw new Error(`.agenthub/config.local.json: ${(error as Error).message}`);
|
|
95
|
-
declared = validateInstances(undefined);
|
|
118
|
+
declared = validateInstances(undefined, catalog);
|
|
96
119
|
}
|
|
97
120
|
for (const name of CONFIG_SOURCE_FILES) {
|
|
98
121
|
const text = texts[name];
|
|
@@ -108,7 +131,12 @@ export function parseConfigSources(texts: ConfigSourceTexts, fallbackRouting?: R
|
|
|
108
131
|
}
|
|
109
132
|
if (name === "config.json" || name === "config.local.json") {
|
|
110
133
|
if (!fallbackRouting) {
|
|
111
|
-
try {
|
|
134
|
+
try {
|
|
135
|
+
const structural = { ...raw };
|
|
136
|
+
// Declaration provenance was checked before compilation; ignored source data is never re-admitted.
|
|
137
|
+
delete structural.adapters; delete structural.instances;
|
|
138
|
+
validateRawConfig(structural, declared, catalog);
|
|
139
|
+
}
|
|
112
140
|
catch (error) { throw new Error(`.agenthub/${name}: ${(error as Error).message}`); }
|
|
113
141
|
}
|
|
114
142
|
layers.push({ name, raw });
|
|
@@ -125,5 +153,5 @@ export function parseConfigSources(texts: ConfigSourceTexts, fallbackRouting?: R
|
|
|
125
153
|
try { routing = parseRouting(base, overlay); }
|
|
126
154
|
catch { throw new Error(".agenthub/routing.toml / routing.local.toml: invalid effective routing"); }
|
|
127
155
|
}
|
|
128
|
-
return { texts, layers, trustLayers, routing, fingerprint: configFingerprint(texts) };
|
|
156
|
+
return { texts, layers, trustLayers, routing, fingerprint: configFingerprint(texts), identity: { ...(identityAdapters ? { adapters: structuredClone(identityAdapters) } : {}), instances: declared ?? validateInstances(undefined, catalog) } };
|
|
129
157
|
}
|
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", "instances"] as const;
|
|
32
|
+
export const ALL_MACHINE_LOCAL = [...MACHINE_LOCAL, "protect_instruction_files", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "adapters", "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 {
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { nonnegative, positive } from "../adapters/definitions/native-context.ts";
|
|
2
|
+
import type { AdapterDefinition } from "../adapters/registry-types.ts";
|
|
2
3
|
export { claudeContext, codexContext, acpContext } from "../adapters/definitions/native-context.ts";
|
|
3
4
|
|
|
5
|
+
/** The host retains session/time/privacy fences; the declared reader owns normalization. */
|
|
6
|
+
export function readNativeContext(definition: AdapterDefinition | undefined, value: unknown, sessionId: string, measuredAt: number): ContextReading | undefined {
|
|
7
|
+
return definition?.budget?.context?.read(value, sessionId, measuredAt);
|
|
8
|
+
}
|
|
9
|
+
|
|
4
10
|
/** Native active-context occupancy, separate from accumulated billable usage (#185). */
|
|
5
11
|
export interface ContextReading {
|
|
6
12
|
source: "claude_statusline" | "codex_token_usage" | "acp_usage_update";
|
|
@@ -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 = ["native_hook_launch", "task_cancel", "recovery_deferred_hub", "peer_instances", "release_trains", "reviewer_pool", "native_tui_launch", "terminal_owner", "cli_audit_delegated"] as const;
|
|
11
|
+
export const DAEMON_FEATURES = ["configured_acp", "native_hook_launch", "task_cancel", "recovery_deferred_hub", "peer_instances", "release_trains", "reviewer_pool", "native_tui_launch", "terminal_owner", "cli_audit_delegated"] 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
|
|
package/src/hub/crash.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { definitionOf, type KindMap } from "../adapters/registry.ts";
|
|
1
|
+
import { definitionOf, type ProjectAdapterCatalog, 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,19 @@ 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[], instances?: KindMap): { peer: string; resume?: Record<string, string>; how: string; fresh?: Record<string, string>; tui?: true }[] {
|
|
59
|
+
export function crashPlan(records: SessionRecord[], instances?: KindMap, catalog?: ProjectAdapterCatalog): { 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, instances);
|
|
64
|
-
const extra = adapter !== undefined &&
|
|
63
|
+
const adapter = (catalog?.definitionOf ?? definitionOf)(peer, instances);
|
|
64
|
+
const extra = adapter !== undefined && adapter.kind !== peer;
|
|
65
65
|
const command = extra ? `ahub ${adapter.cli.command} --as ${peer}` : `ahub ${peer}`;
|
|
66
66
|
if (adapter?.session.resumeById) {
|
|
67
|
-
const
|
|
67
|
+
const rawId = meta[adapter.session.idField];
|
|
68
|
+
const sessionId = adapter.session.crashIdentity ? adapter.session.crashIdentity(meta).sessionId
|
|
69
|
+
: adapter.transport === "acp" && typeof rawId === "string" && rawId.length > 0 && Buffer.byteLength(rawId) <= 4096 && !/[\p{Cc}\p{Cf}]/u.test(rawId) ? rawId : undefined;
|
|
68
70
|
if (!sessionId) return { peer, how: `${peer}: saved ACP session identity unavailable; start it deliberately with ${command}` };
|
|
69
|
-
return { peer, resume: { sessionId, ...(model ? { model } : {}) }, how: `${peer}: the recorded conversation can be loaded again (ACP session/load)` };
|
|
71
|
+
return { peer, resume: { sessionId, ...(model && definitionOf(adapter.kind) ? { model } : {}) }, how: `${peer}: the recorded conversation can be loaded again (ACP session/load)` };
|
|
70
72
|
}
|
|
71
73
|
const native = adapter?.session.crashPlan?.(meta);
|
|
72
74
|
if (!native) return { peer, how: `${peer}: reconnects by itself if its client is still running` };
|