@staix/agent-hub 0.23.1 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/README.md +32 -5
- package/docs/agent-notes/adapters.md +5 -2
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +16 -2
- package/docs/agent-notes/tasks.md +9 -2
- package/docs/agent-notes/tests.md +3 -0
- package/docs/dogfood.md +16 -2
- package/docs/memory.md +1 -1
- package/docs/operations.md +198 -5
- package/docs/profiles.md +2 -0
- package/docs/quickstart.md +19 -4
- package/docs/security.md +225 -8
- package/docs/specs/2026-09-19-agent-hub-design.md +54 -1
- package/docs/specs/2026-09-19-multi-project-design.md +12 -1
- package/docs/specs/2026-11-01-research-campaigns-design.md +351 -0
- package/docs/verification/2026-10-31-0.24.0.md +63 -0
- package/docs/verification/2026-10-31-claude-implementer-2.1.316.md +65 -0
- package/docs/verification/2026-11-01-0.25.0.md +91 -0
- package/docs/verification/claude-shell-wrapper-native.py +1 -1
- package/docs/verified.json +31 -15
- package/package.json +4 -3
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +5120 -4710
- package/src/adapters/claude-channel.ts +29 -6
- package/src/adapters/compat.ts +3 -3
- package/src/adapters/definitions/native-cli-claude-setup.ts +4 -0
- package/src/adapters/definitions/native-cli-doctor.ts +30 -0
- package/src/adapters/jcode.ts +3 -0
- package/src/adapters/registry.ts +1 -1
- package/src/cli/acon-register.ts +29 -0
- package/src/cli/autonomy-homes.ts +33 -0
- package/src/cli/autonomy-offline.ts +75 -0
- package/src/cli/autonomy-projects.ts +103 -0
- package/src/cli/autonomy.ts +44 -0
- package/src/cli/claude-homes.ts +123 -0
- package/src/cli/claude-profile-hook.ts +130 -0
- package/src/cli/handoff-args.ts +27 -0
- package/src/cli/handoff-output.ts +25 -0
- package/src/cli/help.ts +8 -4
- package/src/cli/identity.ts +8 -1
- package/src/cli/main.ts +174 -33
- package/src/cli/output.ts +8 -5
- package/src/cli/schedule-output.ts +8 -4
- package/src/cli/setup-homes.ts +46 -0
- package/src/cli/setup.ts +1 -1
- package/src/cli/study-output.ts +71 -0
- package/src/cli/study.ts +58 -0
- package/src/cli/task-output.ts +1 -0
- package/src/hub/action-class.ts +203 -14
- package/src/hub/action-enforce.ts +8 -6
- package/src/hub/action-policy.ts +95 -9
- package/src/hub/autonomy-consent.ts +173 -0
- package/src/hub/autonomy-defaults.ts +58 -0
- package/src/hub/autonomy.ts +232 -0
- package/src/hub/board.ts +871 -2
- package/src/hub/channel-project.ts +61 -0
- package/src/hub/config-apply.ts +3 -2
- package/src/hub/confirmation-matrix.ts +202 -0
- package/src/hub/control-client.ts +10 -1
- package/src/hub/daemon.ts +546 -27
- package/src/hub/envelope.ts +2 -0
- package/src/hub/events.ts +4 -1
- package/src/hub/execution-budget.ts +12 -0
- package/src/hub/handoff.ts +126 -0
- package/src/hub/hub-tools.ts +12 -2
- package/src/hub/manager.ts +64 -57
- package/src/hub/no-writes.ts +1 -1
- package/src/hub/path-policy.ts +12 -8
- package/src/hub/project.ts +14 -5
- package/src/hub/provenance.ts +30 -0
- package/src/hub/research-campaign.ts +265 -0
- package/src/hub/research-experiments.ts +445 -0
- package/src/hub/restart.ts +26 -0
- package/src/hub/schedules.ts +210 -9
- package/src/hub/session-identity.ts +49 -0
- package/src/hub/settings.ts +105 -8
- package/src/hub/task-repository.ts +11 -6
- package/src/hub/tasks.ts +894 -22
- package/src/hub/ui.ts +15 -6
- package/src/memory/capture.ts +2 -2
- package/src/ui/index.html +5 -3
- package/src/ui/office-space.ts +118 -0
- package/src/ui/plugin.ts +293 -0
- package/templates/AGENTS.block.md +1 -0
- package/templates/presets/autonomous.json +161 -0
- package/templates/presets/confirmation-matrix.json +943 -0
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +4 -4
- package/templates/profiles/writing.json +1 -1
- package/templates/roles/research/analyst.md +1 -0
- package/templates/roles/research/fact-checker.md +1 -0
- package/templates/roles/research/research-lead.md +1 -0
- package/templates/roles/research/researcher.md +1 -0
- package/templates/roles/research/statistician.md +1 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Which project and state directory the Claude channel plugin joins (#604 PR B, agent-hub#1030 item 10). It applies the same decision the CLI makes at startup: `projectContext` for an
|
|
2
|
+
// ordinary process, plus the one exception the CLI has for a declared extra instance, whose cwd is its own managed worktree and whose state directory is the main project's by design.
|
|
3
|
+
// Without this the plugin trusted an inherited AGENTHUB_STATE_DIR as is, so a Claude process started in `<repo>/.worktrees/<name>` that inherited the main checkout's variables joined the
|
|
4
|
+
// main repository's hub.
|
|
5
|
+
//
|
|
6
|
+
// Limit, stated plainly: this stops accidental and inherited-environment joins. It is not a defence against a same-user process that sets the variables and the launch evidence itself.
|
|
7
|
+
import { definitionOf } from "../adapters/registry.ts";
|
|
8
|
+
import { readInstanceConfig } from "./instance-config.ts";
|
|
9
|
+
import { effectiveInstanceRoles } from "./instances.ts";
|
|
10
|
+
import { resolveInstanceExecutionCwd } from "./instance-execution.ts";
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { canonicalPath, inheritedStateDir, projectContext, projectRoot, realPath } from "./project.ts";
|
|
14
|
+
|
|
15
|
+
export interface ChannelProject {
|
|
16
|
+
root: string; stateDir: string;
|
|
17
|
+
/** True when the state directory came from the inherited environment, proven. */
|
|
18
|
+
inherited: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Set when this process has no project of its own and an inherited state directory that records another project (agent-hub#1036): it joins that hub as a distinct session, never as the
|
|
21
|
+
* peer id it names. `label` is the origin part of the id the hub mints (`<peer>~<label>`), or empty to let the hub choose a short one.
|
|
22
|
+
*/
|
|
23
|
+
session?: { label: string };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `cwd` is the plugin process's working directory, `env` its environment, `policyRoles(root)` the configured roles of a project (the same merge the plugin and the CLI use).
|
|
28
|
+
* Returns `undefined` only when there is nothing to join at all: no project of its own and no inherited state directory. Everything else joins something (agent-hub#1036: nothing that
|
|
29
|
+
* works today is refused): the proven project, the cwd's own project, or, when the process cannot prove it belongs to the hub its environment names, that hub as a distinct session.
|
|
30
|
+
*/
|
|
31
|
+
export function channelProject(cwd: string, env: NodeJS.ProcessEnv, policyRoles: (root: string) => Record<string, string[]>): ChannelProject | undefined {
|
|
32
|
+
const override = env.AGENTHUB_STATE_DIR?.trim(), declared = env.AGENTHUB_PROJECT_DIR?.trim();
|
|
33
|
+
// A tools-mode helper (`AGENTHUB_MODE=tools`) is started by the hub or by an agent the hub started, with the state dir and project dir set for exactly that purpose: the Claude implementer child and
|
|
34
|
+
// an external-module instance run in their own managed worktree and must reach the owning hub, as they always did. The daemon checks their launch id itself at the hello. The root check below is
|
|
35
|
+
// for a channel session, a person's `claude` that may merely have inherited a pair (review thread on #1037; agent-hub#1036 AC4).
|
|
36
|
+
if (env.AGENTHUB_MODE === "tools" && override && declared) return { root: canonicalPath(declared), stateDir: canonicalPath(override), inherited: true };
|
|
37
|
+
// The origin label is left empty on purpose: the hub mints a short id, so no directory name reaches a header another agent reads.
|
|
38
|
+
const asSession = (): ChannelProject | undefined => override
|
|
39
|
+
? { root: declared ? canonicalPath(declared) : "", stateDir: canonicalPath(override), inherited: false, session: { label: "" } } : undefined;
|
|
40
|
+
let own: ReturnType<typeof projectContext>;
|
|
41
|
+
try { own = projectContext(cwd, {}); } catch { return asSession(); }
|
|
42
|
+
const peer = env.AGENTHUB_PEER_ID;
|
|
43
|
+
if (peer && declared && !definitionOf(peer)) {
|
|
44
|
+
// A declared extra instance: its workspace is the exact managed worktree the project declares for it, and its hub is the project that declares it.
|
|
45
|
+
try {
|
|
46
|
+
const root = realPath(declared), config = readInstanceConfig(root);
|
|
47
|
+
if (config.instances && Object.hasOwn(config.instances, peer)) {
|
|
48
|
+
const workspace = resolveInstanceExecutionCwd(root, peer, config.instances, effectiveInstanceRoles(peer, config.instances, policyRoles(root), config.catalog), config.catalog);
|
|
49
|
+
if (realPath(projectRoot(cwd)) !== realPath(workspace)) return asSession();
|
|
50
|
+
const stateDir = inheritedStateDir(root, env);
|
|
51
|
+
return stateDir ? { root, stateDir, inherited: true } : asSession();
|
|
52
|
+
}
|
|
53
|
+
} catch { return asSession(); }
|
|
54
|
+
}
|
|
55
|
+
const inherited = inheritedStateDir(own.root, env);
|
|
56
|
+
if (inherited) return { root: own.root, stateDir: inherited, inherited: true };
|
|
57
|
+
// No project of its own (no .agenthub/config.json up to the git boundary) and an inherited state directory that is not proven to belong here: it joins that hub as a distinct session.
|
|
58
|
+
// With a project of its own (a linked worktree, say) it keeps that project's own hub, as before.
|
|
59
|
+
if (override && !existsSync(join(own.root, ".agenthub", "config.json"))) return asSession();
|
|
60
|
+
return { root: own.root, stateDir: own.stateDir, inherited: false };
|
|
61
|
+
}
|
package/src/hub/config-apply.ts
CHANGED
|
@@ -23,7 +23,7 @@ import { projectionConfig } from "./text-projection.ts";
|
|
|
23
23
|
import { validateInstances, type InstancesConfig } from "./instances.ts";
|
|
24
24
|
import { peerRosterConfig, staleInstanceKey } from "./roster.ts";
|
|
25
25
|
import { CHARACTER_IDS } from "../pet/characters.ts";
|
|
26
|
-
import { checkSettingValue, settingDef } from "./settings.ts";
|
|
26
|
+
import { autonomyOwnership, checkSettingValue, settingDef } from "./settings.ts";
|
|
27
27
|
import { petPalette } from "../pet/config.ts";
|
|
28
28
|
|
|
29
29
|
export type ApplyClass = "live" | "next use" | "restart";
|
|
@@ -176,9 +176,10 @@ export const validatePetConfig: Check = (v, k) => {
|
|
|
176
176
|
shapes.set(validatePetConfig, { fields: petFields });
|
|
177
177
|
const nativeValidators = { bool, text, strings, argv, number, words, block };
|
|
178
178
|
const schema: Record<string, Check> = {
|
|
179
|
+
_autonomy: v => { autonomyOwnership(v); },
|
|
179
180
|
...Object.assign({}, ...ADAPTERS.map(adapter => adapter.configuration?.schema?.(nativeValidators) ?? {})),
|
|
180
181
|
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,
|
|
181
|
-
action_policy: block({ enabled: bool, always_ask: (v, k) => { actionPolicyConfig({ always_ask: v }); }, budgets: map(number(0, 1000, true), ACTION_CLASSES) }),
|
|
182
|
+
action_policy: block({ enabled: bool, floor: bool, always_ask: (v, k) => { actionPolicyConfig({ always_ask: v }); }, budgets: map(number(0, 1000, true), ACTION_CLASSES) }),
|
|
182
183
|
permission_modes: v => { permissionDefaults(v); }, protect_instruction_files: bool,
|
|
183
184
|
adapter_modules: v => { adapterModuleDeclarations(v); },
|
|
184
185
|
adapters: v => { createProjectAdapterCatalog(v); }, instances: v => { validateInstances(v, createProjectAdapterCatalog()); }, pet: validatePetConfig,
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import matrixJson from "../../templates/presets/confirmation-matrix.json";
|
|
2
|
+
import { ADAPTERS } from "../adapters/registry.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* #999 T0: the model-free inventory of every place a person is asked to confirm during a standard task, per agent kind and
|
|
6
|
+
* environment (`templates/presets/confirmation-matrix.json`). This module only reads and checks that data. It changes no
|
|
7
|
+
* behaviour: nothing in the daemon, the settings registry or an adapter consults it yet. T1's preset and `ahub autonomy status`
|
|
8
|
+
* are its first readers. A new peer kind adds one row set to the JSON, never a branch here (this file names no peer).
|
|
9
|
+
*/
|
|
10
|
+
export const MATRIX_ENVIRONMENTS = ["terminal", "orca", "headless"] as const;
|
|
11
|
+
export type MatrixEnvironment = typeof MATRIX_ENVIRONMENTS[number];
|
|
12
|
+
export const MATRIX_POINTS = ["tool-approval", "classed-action", "hub-tools", "session-start", "tui-open", "mode-confirmation", "approval-expiry", "unattended", "settings-raise", "task-flow", "merge", "release"] as const;
|
|
13
|
+
/** The two points every peer kind needs a row for in every environment it can run in. */
|
|
14
|
+
export const REQUIRED_POINTS = ["tool-approval", "classed-action"] as const;
|
|
15
|
+
export const HUB_SEES = ["yes", "partial", "no", "unknown", "not-applicable"] as const;
|
|
16
|
+
export const ASKED_BY = ["hub-console", "native-tui", "native-and-hub", "none", "unknown"] as const;
|
|
17
|
+
export const KEY_KINDS = ["setting", "file", "launch-flag", "command", "native"] as const;
|
|
18
|
+
export const HUB_MODES = ["mapped", "unmanaged", "unmanaged-in-tui"] as const;
|
|
19
|
+
const FORMS = ["tui", "headless"] as const;
|
|
20
|
+
/** Rows that belong to the hub itself, not to one agent kind. */
|
|
21
|
+
export const HUB_ROW_KIND = "hub";
|
|
22
|
+
|
|
23
|
+
export interface MatrixEvidence { file: string; needle: string }
|
|
24
|
+
export interface ConfirmationRow {
|
|
25
|
+
id: string;
|
|
26
|
+
kinds: string[];
|
|
27
|
+
environments: MatrixEnvironment[];
|
|
28
|
+
form: "tui" | "headless" | "any";
|
|
29
|
+
point: typeof MATRIX_POINTS[number];
|
|
30
|
+
asks: string;
|
|
31
|
+
controlledBy: string[];
|
|
32
|
+
hubSeesBeforeRun: typeof HUB_SEES[number];
|
|
33
|
+
askedBy: typeof ASKED_BY[number];
|
|
34
|
+
note?: string;
|
|
35
|
+
/** What was not established, one entry each. Required, and only allowed, when the note says `unknown` or hubSeesBeforeRun or askedBy is `unknown`. */
|
|
36
|
+
unknowns?: string[];
|
|
37
|
+
evidence: MatrixEvidence[];
|
|
38
|
+
}
|
|
39
|
+
export interface MatrixKey {
|
|
40
|
+
kind: typeof KEY_KINDS[number];
|
|
41
|
+
setting: string | null;
|
|
42
|
+
appliesTo?: string[];
|
|
43
|
+
where: string;
|
|
44
|
+
applies: string;
|
|
45
|
+
default: string;
|
|
46
|
+
setBy: string;
|
|
47
|
+
doc: string;
|
|
48
|
+
}
|
|
49
|
+
export interface MatrixKind {
|
|
50
|
+
forms: string[];
|
|
51
|
+
/** A form the kind has only as a declared instance or an explicit start, never as its default start forms. */
|
|
52
|
+
extraForms: string[];
|
|
53
|
+
conductorStartable: boolean;
|
|
54
|
+
pullOnlyForms: string[];
|
|
55
|
+
hubMode: typeof HUB_MODES[number];
|
|
56
|
+
formNote?: string;
|
|
57
|
+
}
|
|
58
|
+
export interface ConfirmationMatrix {
|
|
59
|
+
schema: 1;
|
|
60
|
+
issue: string;
|
|
61
|
+
phase: string;
|
|
62
|
+
about: string;
|
|
63
|
+
environments: Record<MatrixEnvironment, { detect: string; opens: string }>;
|
|
64
|
+
kinds: Record<string, MatrixKind>;
|
|
65
|
+
keys: Record<string, MatrixKey>;
|
|
66
|
+
unavailable: { kind: string; environment: MatrixEnvironment; reason: string }[];
|
|
67
|
+
rows: ConfirmationRow[];
|
|
68
|
+
}
|
|
69
|
+
/** What the registry says about one kind, the facts the matrix's `kinds` block must equal. */
|
|
70
|
+
export interface CatalogKind { kind: string; forms: string[]; extraForms: string[]; conductorStartable: boolean; pullOnlyForms: string[]; hubMode: typeof HUB_MODES[number] }
|
|
71
|
+
|
|
72
|
+
export const confirmationMatrix = (): ConfirmationMatrix => matrixJson as unknown as ConfirmationMatrix;
|
|
73
|
+
|
|
74
|
+
/** The catalog's kinds as the registry declares them now. A form a kind only has as a declared instance or an explicit start is `extraForms`. */
|
|
75
|
+
export function catalogKinds(): CatalogKind[] {
|
|
76
|
+
return ADAPTERS.map((definition): CatalogKind => ({
|
|
77
|
+
kind: definition.kind,
|
|
78
|
+
forms: [...definition.start.forms],
|
|
79
|
+
extraForms: [...(definition.headlessVariant ? ["headless"] : []), ...(definition.start.createTui ? ["tui"] : [])].filter(form => !definition.start.forms.includes(form as "tui" | "headless")),
|
|
80
|
+
conductorStartable: definition.start.conductorStartable,
|
|
81
|
+
pullOnlyForms: [...(definition.attach?.pullOnlyForms ?? [])],
|
|
82
|
+
hubMode: definition.permission.unmanaged ? "unmanaged" : definition.permission.unmanagedForms?.includes("tui") ? "unmanaged-in-tui" : "mapped",
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const same = (left: readonly string[], right: readonly string[]): boolean => left.length === right.length && [...left].sort().join("\0") === [...right].sort().join("\0");
|
|
87
|
+
const isObject = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
|
|
88
|
+
const oneOf = <T extends string>(list: readonly T[], value: unknown): value is T => typeof value === "string" && (list as readonly string[]).includes(value);
|
|
89
|
+
|
|
90
|
+
/** Every kind and environment the rows cover, or name: a pair with neither the two required points nor an `unavailable` entry is a gap. */
|
|
91
|
+
export function missingCoverage(matrix: ConfirmationMatrix, kinds: readonly string[]): string[] {
|
|
92
|
+
const missing: string[] = [];
|
|
93
|
+
for (const kind of kinds) {
|
|
94
|
+
if (!matrix.kinds[kind]) missing.push(`${kind}: no entry in kinds`);
|
|
95
|
+
for (const environment of MATRIX_ENVIRONMENTS) {
|
|
96
|
+
if (matrix.unavailable.some(entry => entry.kind === kind && entry.environment === environment)) continue;
|
|
97
|
+
for (const point of REQUIRED_POINTS) {
|
|
98
|
+
if (!matrix.rows.some(row => row.kinds.includes(kind) && row.environments.includes(environment) && row.point === point)) missing.push(`${kind}/${environment}: no ${point} row`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return missing;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The rows that apply to `kind` in `environment`, in file order. Hub rows (`kind: hub`) answer for the hub itself. */
|
|
106
|
+
export function rowsFor(matrix: ConfirmationMatrix, kind: string, environment: MatrixEnvironment): ConfirmationRow[] {
|
|
107
|
+
return matrix.rows.filter(row => row.kinds.includes(kind) && row.environments.includes(environment));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Structural problems of the matrix against the registry's catalog, empty when sound. Pure: no file or process access.
|
|
112
|
+
* `evidence` needles are checked by `evidenceProblems` (it reads files).
|
|
113
|
+
*/
|
|
114
|
+
export function validateConfirmationMatrix(value: unknown, catalog: readonly CatalogKind[] = catalogKinds()): string[] {
|
|
115
|
+
if (!isObject(value)) return ["the matrix must be an object"];
|
|
116
|
+
const problems: string[] = [];
|
|
117
|
+
const matrix = value as unknown as ConfirmationMatrix;
|
|
118
|
+
if (matrix.schema !== 1) problems.push("schema must be 1");
|
|
119
|
+
for (const field of ["issue", "phase", "about"] as const) if (typeof matrix[field] !== "string" || !matrix[field]) problems.push(`${field} must be a non-empty string`);
|
|
120
|
+
if (!isObject(matrix.environments) || !same(Object.keys(matrix.environments), MATRIX_ENVIRONMENTS)) problems.push(`environments must be exactly ${MATRIX_ENVIRONMENTS.join(", ")}`);
|
|
121
|
+
else for (const environment of MATRIX_ENVIRONMENTS) { const entry = matrix.environments[environment]; if (!isObject(entry) || typeof entry.detect !== "string" || typeof entry.opens !== "string" || !entry.detect || !entry.opens) problems.push(`environments.${environment} needs detect and opens text`); }
|
|
122
|
+
if (!isObject(matrix.kinds) || !isObject(matrix.keys) || !Array.isArray(matrix.rows) || !Array.isArray(matrix.unavailable)) return [...problems, "kinds, keys, unavailable and rows must be present"];
|
|
123
|
+
|
|
124
|
+
// kinds: one entry per catalog kind, equal to what the registry declares now.
|
|
125
|
+
const known = new Set(catalog.map(entry => entry.kind));
|
|
126
|
+
for (const entry of catalog) {
|
|
127
|
+
const kind = matrix.kinds[entry.kind];
|
|
128
|
+
if (!kind) { problems.push(`kinds.${entry.kind}: missing (the adapter catalog has this kind)`); continue; }
|
|
129
|
+
if (!same(kind.forms ?? [], entry.forms)) problems.push(`kinds.${entry.kind}.forms: ${JSON.stringify(kind.forms)} but the catalog declares ${JSON.stringify(entry.forms)}`);
|
|
130
|
+
if (!same(kind.extraForms ?? [], entry.extraForms)) problems.push(`kinds.${entry.kind}.extraForms: ${JSON.stringify(kind.extraForms ?? [])} but the catalog gives ${JSON.stringify(entry.extraForms)}`);
|
|
131
|
+
if (kind.conductorStartable !== entry.conductorStartable) problems.push(`kinds.${entry.kind}.conductorStartable differs from the catalog`);
|
|
132
|
+
if (!same(kind.pullOnlyForms ?? [], entry.pullOnlyForms)) problems.push(`kinds.${entry.kind}.pullOnlyForms differs from the catalog`);
|
|
133
|
+
if (kind.hubMode !== entry.hubMode) problems.push(`kinds.${entry.kind}.hubMode is ${String(kind.hubMode)} but the catalog gives ${entry.hubMode}`);
|
|
134
|
+
}
|
|
135
|
+
for (const name of Object.keys(matrix.kinds)) if (!known.has(name)) problems.push(`kinds.${name}: not a kind of the adapter catalog`);
|
|
136
|
+
|
|
137
|
+
// keys
|
|
138
|
+
for (const [name, key] of Object.entries(matrix.keys)) {
|
|
139
|
+
if (!isObject(key)) { problems.push(`keys.${name} must be an object`); continue; }
|
|
140
|
+
if (!oneOf(KEY_KINDS, key.kind)) problems.push(`keys.${name}.kind must be one of ${KEY_KINDS.join(", ")}`);
|
|
141
|
+
if (key.kind === "setting" ? typeof key.setting !== "string" || !key.setting : key.setting !== null) problems.push(`keys.${name}.setting must be a registered setting name for a setting and null otherwise`);
|
|
142
|
+
for (const field of ["where", "applies", "default", "setBy", "doc"] as const) if (typeof key[field] !== "string" || !key[field]) problems.push(`keys.${name}.${field} must be non-empty text`);
|
|
143
|
+
for (const kind of key.appliesTo ?? []) if (!known.has(kind)) problems.push(`keys.${name}.appliesTo names ${kind}, which is not a catalog kind`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// rows
|
|
147
|
+
const seen = new Set<string>();
|
|
148
|
+
for (const [index, row] of matrix.rows.entries()) {
|
|
149
|
+
const at = isObject(row) && typeof row.id === "string" ? `rows[${row.id}]` : `rows[${index}]`;
|
|
150
|
+
if (!isObject(row)) { problems.push(`${at} must be an object`); continue; }
|
|
151
|
+
if (typeof row.id !== "string" || !/^[a-z][a-z0-9.-]*$/.test(row.id)) problems.push(`${at}: id must be lowercase words joined by dots`);
|
|
152
|
+
else if (seen.has(row.id)) problems.push(`${at}: duplicate id`);
|
|
153
|
+
else seen.add(row.id);
|
|
154
|
+
if (!Array.isArray(row.kinds) || !row.kinds.length) problems.push(`${at}: kinds must be a non-empty list`);
|
|
155
|
+
for (const kind of Array.isArray(row.kinds) ? row.kinds : []) {
|
|
156
|
+
if (kind !== HUB_ROW_KIND && !known.has(kind)) { problems.push(`${at}: ${String(kind)} is not a catalog kind`); continue; }
|
|
157
|
+
const facts = matrix.kinds[kind];
|
|
158
|
+
if (kind !== HUB_ROW_KIND && row.form !== "any" && facts && !facts.forms.includes(row.form) && !(facts.extraForms ?? []).includes(row.form)) problems.push(`${at}: ${kind} has no ${String(row.form)} form`);
|
|
159
|
+
}
|
|
160
|
+
if (!Array.isArray(row.environments) || !row.environments.length || row.environments.some(environment => !oneOf(MATRIX_ENVIRONMENTS, environment))) problems.push(`${at}: environments must be a non-empty subset of ${MATRIX_ENVIRONMENTS.join(", ")}`);
|
|
161
|
+
if (row.form !== "any" && !oneOf(FORMS, row.form)) problems.push(`${at}: form must be tui, headless or any`);
|
|
162
|
+
if (!oneOf(MATRIX_POINTS, row.point)) problems.push(`${at}: point must be one of ${MATRIX_POINTS.join(", ")}`);
|
|
163
|
+
if (typeof row.asks !== "string" || !row.asks) problems.push(`${at}: asks must say what is asked`);
|
|
164
|
+
if (!Array.isArray(row.controlledBy)) problems.push(`${at}: controlledBy must be a list`);
|
|
165
|
+
for (const key of Array.isArray(row.controlledBy) ? row.controlledBy : []) if (!Object.hasOwn(matrix.keys, key)) problems.push(`${at}: controlledBy names ${String(key)}, which is not in keys`);
|
|
166
|
+
if (!oneOf(HUB_SEES, row.hubSeesBeforeRun)) problems.push(`${at}: hubSeesBeforeRun must be one of ${HUB_SEES.join(", ")}`);
|
|
167
|
+
if (!oneOf(ASKED_BY, row.askedBy)) problems.push(`${at}: askedBy must be one of ${ASKED_BY.join(", ")}`);
|
|
168
|
+
// A fact that was not established is the word unknown in a structured field, never prose alone: a note cannot override a concrete value.
|
|
169
|
+
const saysUnknown = row.hubSeesBeforeRun === "unknown" || row.askedBy === "unknown" || /\bunknown\b/.test(row.note ?? "");
|
|
170
|
+
const listed = Array.isArray(row.unknowns) ? row.unknowns : undefined;
|
|
171
|
+
if (row.unknowns !== undefined && (!listed || !listed.length || listed.some(item => typeof item !== "string" || !item.trim()))) problems.push(`${at}: unknowns must be a non-empty list of text`);
|
|
172
|
+
if (saysUnknown && !listed?.length) problems.push(`${at}: an unknown value needs a structured unknowns list that says what is unknown`);
|
|
173
|
+
if (!saysUnknown && row.unknowns !== undefined) problems.push(`${at}: unknowns is listed but neither the note nor a field says unknown`);
|
|
174
|
+
if ((row.hubSeesBeforeRun === "unknown") !== (row.askedBy === "unknown") && row.askedBy !== "none" && row.hubSeesBeforeRun !== "no") problems.push(`${at}: hubSeesBeforeRun and askedBy are unknown together`);
|
|
175
|
+
// A native TUI prompt exists only in a TUI form: a row that covers headless, or any form, cannot name one as the asker.
|
|
176
|
+
if ((row.askedBy === "native-tui" || row.askedBy === "native-and-hub") && (row.form !== "tui" || (Array.isArray(row.environments) && row.environments.includes("headless")))) problems.push(`${at}: askedBy ${row.askedBy} needs form tui and no headless environment; split the row or mark the asker unknown`);
|
|
177
|
+
// A prompt that costs a person's turn must name a key that controls it. A row whose asker is unknown names none: that is the gap it records.
|
|
178
|
+
if (row.askedBy !== "none" && row.askedBy !== "unknown" && Array.isArray(row.controlledBy) && !row.controlledBy.length) problems.push(`${at}: a row that asks needs at least one controlling key`);
|
|
179
|
+
if (!Array.isArray(row.evidence) || !row.evidence.length) problems.push(`${at}: evidence must name at least one file and needle`);
|
|
180
|
+
for (const item of Array.isArray(row.evidence) ? row.evidence : []) if (!isObject(item) || typeof item.file !== "string" || typeof item.needle !== "string" || !item.file || item.needle.length < 8 || item.file.startsWith("/") || item.file.includes("..")) problems.push(`${at}: an evidence item needs a project-relative file and a needle of at least 8 characters`);
|
|
181
|
+
}
|
|
182
|
+
for (const [index, entry] of matrix.unavailable.entries()) {
|
|
183
|
+
const at = `unavailable[${index}]`;
|
|
184
|
+
if (!isObject(entry) || !known.has(entry.kind) || !oneOf(MATRIX_ENVIRONMENTS, entry.environment) || typeof entry.reason !== "string" || !entry.reason) { problems.push(`${at} needs a catalog kind, an environment and a reason`); continue; }
|
|
185
|
+
if (matrix.rows.some(row => row.kinds.includes(entry.kind) && row.environments.includes(entry.environment) && (REQUIRED_POINTS as readonly string[]).includes(row.point))) problems.push(`${at}: ${entry.kind} has rows in ${entry.environment}, so it is not unavailable there`);
|
|
186
|
+
}
|
|
187
|
+
problems.push(...missingCoverage(matrix, catalog.map(entry => entry.kind)));
|
|
188
|
+
return problems;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Evidence items whose file or needle no longer exists. `read` returns a file's text, or undefined when there is none. */
|
|
192
|
+
export function evidenceProblems(matrix: ConfirmationMatrix, read: (file: string) => string | undefined): string[] {
|
|
193
|
+
const problems: string[] = [];
|
|
194
|
+
const cache = new Map<string, string | undefined>();
|
|
195
|
+
for (const row of matrix.rows) for (const { file, needle } of row.evidence) {
|
|
196
|
+
if (!cache.has(file)) cache.set(file, read(file));
|
|
197
|
+
const text = cache.get(file);
|
|
198
|
+
if (text === undefined) problems.push(`rows[${row.id}]: evidence file ${file} does not exist`);
|
|
199
|
+
else if (!text.includes(needle)) problems.push(`rows[${row.id}]: ${file} no longer contains "${needle}"`);
|
|
200
|
+
}
|
|
201
|
+
return problems;
|
|
202
|
+
}
|
|
@@ -9,7 +9,7 @@ export function stateDirFor(cwd: string): string {
|
|
|
9
9
|
|
|
10
10
|
/** 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. */
|
|
11
11
|
export const PROTOCOL = 16;
|
|
12
|
-
export const DAEMON_FEATURES = ["conductor_recovery", "encrypted_rooms", "external_adapter_instances", "external_adapter_modules", "configured_acp", "native_hook_launch", "task_cancel", "invocation", "recovery_deferred_hub", "peer_instances", "role_stages", "task_planners", "release_trains", "reviewer_pool", "native_tui_launch", "terminal_owner", "cli_audit_delegated"] as const;
|
|
12
|
+
export const DAEMON_FEATURES = ["project_autonomy", "conductor_recovery", "encrypted_rooms", "external_adapter_instances", "external_adapter_modules", "configured_acp", "native_hook_launch", "task_cancel", "invocation", "recovery_deferred_hub", "peer_instances", "role_stages", "task_planners", "release_trains", "reviewer_pool", "native_tui_launch", "terminal_owner", "cli_audit_delegated", "action_policy_floor", "calendar_schedules", "research_records", "research_dispatch", "research_dropped_offers", "research_experiments", "research_comparisons", "task_handoff", "session_identity"] as const;
|
|
13
13
|
/** Protocols a current coordinator may authenticate while upgrading a running source. */
|
|
14
14
|
export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, 15, PROTOCOL] as const;
|
|
15
15
|
|
|
@@ -42,6 +42,12 @@ export interface Hello {
|
|
|
42
42
|
launchId?: string;
|
|
43
43
|
/** Optional canonical native execution cwd; ownership requires independent host proof. */
|
|
44
44
|
cwd?: string;
|
|
45
|
+
/**
|
|
46
|
+
* agent-hub#1036: the plugin has no project of its own and its inherited state directory belongs to another project. It asks to join as a distinct session; the value is the origin label
|
|
47
|
+
* (possibly empty). The daemon mints `<peer>~<label>` for it and never binds the peer id it names. A daemon without the `session_identity` feature ignores the field, so a client checks
|
|
48
|
+
* `features` in `status.json` before sending it.
|
|
49
|
+
*/
|
|
50
|
+
session?: string;
|
|
45
51
|
/** Reclaim after replacement, never an intentional initial takeover. */
|
|
46
52
|
standby?: boolean;
|
|
47
53
|
projectId?: string;
|
|
@@ -97,6 +103,8 @@ export function controlRequestTimeout(msg: Record<string, unknown>, requested =
|
|
|
97
103
|
/** One authenticated control connection. `request` correlates replies by `rid`; everything else goes to `onPush`. */
|
|
98
104
|
export class ControlClient {
|
|
99
105
|
features: readonly string[] = [];
|
|
106
|
+
/** The id the hub minted for a distinct session (agent-hub#1036); absent for every ordinary connection. */
|
|
107
|
+
sessionId?: string;
|
|
100
108
|
private welcomeInstanceId?: string;
|
|
101
109
|
/** Identity from the authenticated welcome, never the unauthenticated descriptor alone. */
|
|
102
110
|
get instanceId(): string | undefined { return this.welcomeInstanceId; }
|
|
@@ -170,6 +178,7 @@ export class ControlClient {
|
|
|
170
178
|
}
|
|
171
179
|
client.features = Object.freeze(Array.isArray(reply.features) ? reply.features.filter((feature: unknown): feature is string => typeof feature === "string") : []);
|
|
172
180
|
client.welcomeInstanceId = typeof reply.instanceId === "string" && reply.instanceId ? reply.instanceId : undefined;
|
|
181
|
+
client.sessionId = typeof reply.session === "string" && reply.session.length <= 64 ? reply.session : undefined;
|
|
173
182
|
clearTimeout(timer);
|
|
174
183
|
resolve(client);
|
|
175
184
|
});
|