@staix/agent-hub 0.22.0 → 0.23.1
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 +19 -0
- package/README.md +2 -2
- package/docs/agent-notes/adapters.md +2 -1
- package/docs/agent-notes/budget.md +1 -1
- package/docs/agent-notes/bus.md +4 -1
- package/docs/agent-notes/daemon.md +3 -2
- package/docs/agent-notes/local-worker.md +1 -1
- package/docs/agent-notes/models.md +2 -1
- package/docs/agent-notes/tasks.md +1 -0
- package/docs/agent-notes/tests.md +1 -1
- package/docs/dogfood.md +7 -1
- package/docs/memory.md +1 -1
- package/docs/operations.md +154 -13
- package/docs/profiles.md +28 -3
- package/docs/quickstart.md +11 -3
- package/docs/security.md +11 -5
- package/docs/specs/2026-09-19-agent-hub-design.md +66 -4
- package/docs/verification/2026-10-30-0.23.0.md +70 -0
- package/docs/verification/2026-10-30-adapter-operational-settings.md +261 -0
- package/docs/verification/2026-10-30-model-operational-settings.md +211 -0
- package/docs/verification/2026-10-30-operational-settings-closure.md +1217 -0
- package/docs/verification/2026-10-30-ui-console-operational-settings.md +1845 -0
- package/docs/verification/2026-10-31-0.23.1.md +74 -0
- package/docs/verification/2026-10-31-claude-implementer-2.1.314.md +67 -0
- package/docs/verification/2026-10-31-codex-0166-reviewer-isolation.md +52 -0
- package/docs/verification/2026-10-31-codex-0166-reviewer-tool-call.md +49 -0
- package/docs/verification/2026-10-31-kimi-241-reviewer-compat.md +63 -0
- package/docs/verification/2026-10-31-qwen-0264-acp.md +57 -0
- package/docs/verification/2026-10-31-qwen-0264-dual-output.md +52 -0
- package/docs/verification/claude-shell-wrapper-native.py +1 -1
- package/docs/verified.json +15 -15
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/ROLE-NOTICE.md +1 -1
- package/plugins/agent-hub/server.js +749 -241
- package/src/adapters/acp.ts +48 -2
- package/src/adapters/claude-channel.ts +6 -4
- package/src/adapters/codex-appserver.ts +19 -0
- package/src/adapters/compat.ts +11 -11
- package/src/adapters/jcode.ts +1 -1
- package/src/adapters/local-worker.ts +3 -3
- package/src/adapters/native-capabilities.ts +50 -0
- package/src/adapters/registry-types.ts +3 -10
- package/src/cli/capabilities-output.ts +30 -0
- package/src/cli/console-state.ts +8 -5
- package/src/cli/console.ts +27 -4
- package/src/cli/help.ts +2 -1
- package/src/cli/identity.ts +1 -0
- package/src/cli/main.ts +8 -0
- package/src/hub/board.ts +8 -2
- package/src/hub/bus.ts +100 -8
- package/src/hub/capabilities.ts +215 -0
- package/src/hub/config-apply.ts +6 -3
- package/src/hub/config-trust.ts +1 -0
- package/src/hub/control-client.ts +41 -8
- package/src/hub/daemon.ts +376 -20
- package/src/hub/deferred-hub.ts +2 -2
- package/src/hub/delivery-hints.ts +2 -2
- package/src/hub/delivery-journal.ts +2 -4
- package/src/hub/envelope.ts +12 -2
- package/src/hub/events.ts +2 -0
- package/src/hub/hub-tools.ts +16 -1
- package/src/hub/inference.ts +10 -5
- package/src/hub/invocations.ts +316 -0
- package/src/hub/operational-settings.ts +102 -1
- package/src/hub/peers.ts +3 -0
- package/src/hub/restart.ts +2 -2
- package/src/hub/room-invocations.ts +145 -0
- package/src/hub/settings.ts +1 -1
- package/src/hub/shipped-packs.ts +5 -2
- package/src/hub/shipped-roles.ts +13 -0
- package/src/hub/tasks.ts +14 -0
- package/src/memory/capture.ts +2 -2
- package/src/models/relay.ts +20 -9
- package/src/rooms/application.ts +67 -2
- package/src/rooms/boundary.ts +43 -11
- package/src/rooms/service.ts +40 -2
- package/templates/checks/analysis.mjs +59 -0
- package/templates/checks/fixtures/analysis-expected.json +1 -0
- package/templates/checks/fixtures/analysis-input.json +1 -0
- package/templates/checks/fixtures/analysis.mjs +7 -0
- package/templates/checks/fixtures/ops.json +4 -0
- package/templates/checks/fixtures/writing-rules.json +4 -0
- package/templates/checks/fixtures/writing.json +5 -0
- package/templates/checks/ops.mjs +40 -0
- package/templates/checks/writing.mjs +40 -0
- package/templates/packs/analysis.pack.json +14 -0
- package/templates/packs/ops.pack.json +14 -0
- package/templates/packs/writing-pack.pack.json +14 -0
- package/templates/roles/NOTICE.md +1 -1
- package/templates/roles/ops/meeting-actions-owner.md +26 -0
- package/templates/roles/ops/project-manager.md +26 -0
- package/templates/roles/writing/editor.md +26 -0
- package/templates/roles/writing/style-checker.md +26 -0
- package/templates/roles/writing/terminology-checker.md +26 -0
- package/templates/roles/writing/translator.md +26 -0
|
@@ -107,10 +107,82 @@ export const RECOVERY_TERMINAL_IDLE_WAIT_SETTING = recoveryNumber({
|
|
|
107
107
|
min: 30, max: 600, defaultValue: 120, unit: "seconds", inventory: "src/cli/upgrade-runtime.ts:waitForIdle",
|
|
108
108
|
});
|
|
109
109
|
|
|
110
|
+
const adapterNumber = <const T extends Omit<OperationalNumberSetting, "group" | "store" | "risk" | "applies" | "type">>(def: T) => ({ group: "Start", store: "config", risk: "raises", applies: "next use", type: "integer", ...def }) as const satisfies OperationalNumberSetting;
|
|
111
|
+
export const ACP_TOOL_CEILING_SETTING = adapterNumber({
|
|
112
|
+
key: "adapter_timing.acp_tool_ceiling_min", path: ["adapter_timing", "acp_tool_ceiling_min"], label: "Next ACP launch: open-tool ceiling (minutes, at least the watchdog)",
|
|
113
|
+
min: 1, max: 30, defaultValue: 30, unit: "minutes", inventory: "src/adapters/acp.ts:TOOL_CEILING_MS",
|
|
114
|
+
});
|
|
115
|
+
export const ACP_ACTIVITY_BATCH_SETTING = adapterNumber({
|
|
116
|
+
key: "adapter_timing.acp_activity_batch_ms", path: ["adapter_timing", "acp_activity_batch_ms"], label: "Next ACP launch: activity display batch delay (milliseconds)",
|
|
117
|
+
min: 10, max: 50, defaultValue: 50, unit: "milliseconds", inventory: "src/adapters/acp.ts:setTimeout",
|
|
118
|
+
});
|
|
119
|
+
export const JCODE_STEER_TIMEOUT_SETTING = adapterNumber({
|
|
120
|
+
key: "adapter_timing.jcode_steer_timeout_s", path: ["adapter_timing", "jcode_steer_timeout_s"], label: "Next Jcode launch: total native steer deadline (seconds)",
|
|
121
|
+
min: 5, max: 60, defaultValue: 5, unit: "seconds", inventory: "src/adapters/jcode.ts:STEER_TIMEOUT_MS",
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
/** #489 H6: relay policy is captured at construction; inference consumers are daemon-start bound. */
|
|
125
|
+
const modelNumber = <const T extends Omit<OperationalNumberSetting, "group" | "store" | "risk" | "type">>(def: T) => ({ group: "Routing", store: "config", risk: "raises", type: "integer", ...def }) as const satisfies OperationalNumberSetting;
|
|
126
|
+
export const MODEL_COOLDOWN_FAILURES_SETTING = modelNumber({
|
|
127
|
+
key: "model_relay.cooldown_failures", path: ["model_relay", "cooldown_failures"], label: "Model relay: consecutive transport failures before cooldown",
|
|
128
|
+
min: 1, max: 100, defaultValue: 3, applies: "restart", restartReason: "Model relays and their health state persist until daemon shutdown", unit: "failures", inventory: "src/models/relay.ts:COOLDOWN_FAILURES",
|
|
129
|
+
});
|
|
130
|
+
export const MODEL_COOLDOWN_SETTING = modelNumber({
|
|
131
|
+
key: "model_relay.cooldown_s", path: ["model_relay", "cooldown_s"], label: "Model relay: initial cooldown and recent-failure window (seconds)",
|
|
132
|
+
min: 1, max: 300, defaultValue: 30, applies: "restart", restartReason: "Model relays and their health state persist until daemon shutdown", unit: "seconds", inventory: "src/models/relay.ts:COOLDOWN_MS",
|
|
133
|
+
});
|
|
134
|
+
export const MODEL_COOLDOWN_CAP_SETTING = modelNumber({
|
|
135
|
+
key: "model_relay.cooldown_cap_s", path: ["model_relay", "cooldown_cap_s"], label: "Model relay: maximum cooldown (seconds)",
|
|
136
|
+
min: 300, max: 3600, defaultValue: 300, applies: "restart", restartReason: "Model relays and their health state persist until daemon shutdown", unit: "seconds", inventory: "src/models/relay.ts:COOLDOWN_CAP_MS",
|
|
137
|
+
});
|
|
138
|
+
export const MODEL_TERMINAL_HISTORY_SETTING = modelNumber({
|
|
139
|
+
key: "model_relay.terminal_history_limit", path: ["model_relay", "terminal_history_limit"], label: "Model relay: retained completed request records",
|
|
140
|
+
min: 1, max: 1000, defaultValue: 1000, applies: "restart", restartReason: "Model relays and their health state persist until daemon shutdown", unit: "records", inventory: "src/models/relay.ts:JOURNAL_LIMIT",
|
|
141
|
+
});
|
|
142
|
+
const inferenceRestart = "The inference consumer captures its configuration at daemon startup";
|
|
143
|
+
export const INFERENCE_BACKOFF_SETTING = modelNumber({
|
|
144
|
+
key: "inference.backoff_min", path: ["inference", "backoff_min"], label: "Inference: cooldown after a failed optional call (minutes)",
|
|
145
|
+
min: 1, max: 60, defaultValue: 5, applies: "restart", restartReason: inferenceRestart, unit: "minutes", inventory: "src/hub/inference.ts:BACKOFF_MS",
|
|
146
|
+
});
|
|
147
|
+
export const INFERENCE_DIGEST_ITEMS_SETTING = modelNumber({
|
|
148
|
+
key: "inference.digest_min_items", path: ["inference", "digest_min_items"], label: "Inference: condense status chatter above this item count",
|
|
149
|
+
min: 1, max: 100_000, defaultValue: 5, applies: "restart", restartReason: inferenceRestart, unit: "items", inventory: "src/hub/inference.ts:digest_min_items",
|
|
150
|
+
});
|
|
151
|
+
export const INFERENCE_DIGEST_CHARS_SETTING = modelNumber({
|
|
152
|
+
key: "inference.digest_min_chars", path: ["inference", "digest_min_chars"], label: "Inference: condense status chatter above this character count",
|
|
153
|
+
min: 1, max: 10_000_000, defaultValue: 8000, applies: "restart", restartReason: inferenceRestart, unit: "characters", inventory: "src/hub/inference.ts:digest_min_chars",
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
/** #489 H7: accepted presentation values are captured once by the next console launch. */
|
|
157
|
+
const consoleNumber = <const T extends Omit<OperationalNumberSetting, "group" | "store" | "risk" | "applies" | "type">>(def: T) => ({ group: "Start", store: "config", risk: "raises", applies: "next use", type: "integer", ...def }) as const satisfies OperationalNumberSetting;
|
|
158
|
+
export const CONSOLE_POLL_SETTING = consoleNumber({
|
|
159
|
+
key: "console.poll_ms", path: ["console", "poll_ms"], label: "Next console launch: refresh interval (milliseconds)",
|
|
160
|
+
min: 2000, max: 60_000, defaultValue: 4000, unit: "milliseconds", inventory: "src/cli/console.ts:options.pollMs ?? 4000",
|
|
161
|
+
});
|
|
162
|
+
export const CONSOLE_NOTICE_SETTING = consoleNumber({
|
|
163
|
+
key: "console.notice_ms", path: ["console", "notice_ms"], label: "Next console launch: notice visibility (milliseconds)",
|
|
164
|
+
min: 10_000, max: 60_000, defaultValue: 10_000, unit: "milliseconds", inventory: "src/cli/console-state.ts:NOTICE_MS",
|
|
165
|
+
});
|
|
166
|
+
export const CONSOLE_HISTORY_SETTING = consoleNumber({
|
|
167
|
+
key: "console.history_limit", path: ["console", "history_limit"], label: "Next console launch: retained command history",
|
|
168
|
+
min: 1, max: 100, defaultValue: 100, unit: "commands", inventory: "src/cli/console-state.ts:[...s.history, input].slice",
|
|
169
|
+
});
|
|
170
|
+
export const CONSOLE_PENDING_STREAM_SETTING = consoleNumber({
|
|
171
|
+
key: "console.pending_stream_limit", path: ["console", "pending_stream_limit"], label: "Next console launch: buffered offscreen stream events",
|
|
172
|
+
min: 1, max: 1000, defaultValue: 1000, unit: "events", inventory: "src/cli/console.ts:pendingStream.length > 1000",
|
|
173
|
+
});
|
|
174
|
+
export const CONSOLE_ATTENTION_SETTING = consoleNumber({
|
|
175
|
+
key: "console.attention_limit", path: ["console", "attention_limit"], label: "Next console launch: retained message attention entries",
|
|
176
|
+
min: 1, max: 1000, defaultValue: 1000, unit: "messages", inventory: "src/cli/console.ts:[...(state.attentionMessages ?? []), { id: msg.e.env.id, peer: msg.e.env.from, important: msg.e.env.priority === important, since: msg.e.env.ts }].slice",
|
|
177
|
+
});
|
|
178
|
+
|
|
110
179
|
/** Fixed metadata; never registration from a file, request, adapter or environment variable. */
|
|
111
180
|
export const OPERATIONAL_NUMBERS: readonly OperationalNumberSetting[] = [GROOM_FYI_WINDOW_SETTING, DEMOTE_AT_SETTING, DEMOTION_HALF_LIFE_SETTING, CONTRADICTION_WINDOW_SETTING, RESCREEN_MAX_SETTING, SPLIT_MIN_SETTING, SPLIT_FAILURE_SHARE_SETTING,
|
|
112
181
|
BUS_MAX_ATTEMPTS_SETTING, BUS_DIGEST_MAX_SETTING, BUS_PULL_BUDGET_BYTES_SETTING, BUS_NOTES_KEPT_SETTING, BUS_TERMINAL_HISTORY_LIMIT_SETTING,
|
|
113
|
-
RECOVERY_RECONNECT_WAIT_SETTING, RECOVERY_UNMANAGED_RECONNECT_WAIT_SETTING, RECOVERY_CONTROL_TIMEOUT_SETTING, RECOVERY_SHUTDOWN_WAIT_SETTING, RECOVERY_TERMINAL_IDLE_WAIT_SETTING
|
|
182
|
+
RECOVERY_RECONNECT_WAIT_SETTING, RECOVERY_UNMANAGED_RECONNECT_WAIT_SETTING, RECOVERY_CONTROL_TIMEOUT_SETTING, RECOVERY_SHUTDOWN_WAIT_SETTING, RECOVERY_TERMINAL_IDLE_WAIT_SETTING,
|
|
183
|
+
ACP_TOOL_CEILING_SETTING, ACP_ACTIVITY_BATCH_SETTING, JCODE_STEER_TIMEOUT_SETTING,
|
|
184
|
+
MODEL_COOLDOWN_FAILURES_SETTING, MODEL_COOLDOWN_SETTING, MODEL_COOLDOWN_CAP_SETTING, MODEL_TERMINAL_HISTORY_SETTING, INFERENCE_BACKOFF_SETTING, INFERENCE_DIGEST_ITEMS_SETTING, INFERENCE_DIGEST_CHARS_SETTING,
|
|
185
|
+
CONSOLE_POLL_SETTING, CONSOLE_NOTICE_SETTING, CONSOLE_HISTORY_SETTING, CONSOLE_PENDING_STREAM_SETTING, CONSOLE_ATTENTION_SETTING];
|
|
114
186
|
/** The `tasks` leaves the six #489 H2 readers need; `HubConfig.tasks` has them all. */
|
|
115
187
|
export interface TaskOperationalConfig { tasks: { demote_at?: number; demotion_half_life_h?: number; contradiction_window_days?: number; rescreen_max?: number; split_min_observations?: number; split_max_failure_share?: number } }
|
|
116
188
|
/**
|
|
@@ -151,4 +223,33 @@ export const recoveryWaits = (config: () => RecoveryOperationalConfig | undefine
|
|
|
151
223
|
terminalIdleWaitMs: recoveryMs(RECOVERY_TERMINAL_IDLE_WAIT_SETTING, leaf?.terminal_idle_wait_s),
|
|
152
224
|
};
|
|
153
225
|
};
|
|
226
|
+
export interface AdapterTimingConfig { adapter_timing?: { acp_tool_ceiling_min?: number; acp_activity_batch_ms?: number; jcode_steer_timeout_s?: number } }
|
|
227
|
+
/** The host captures primitive option values for a new adapter; an accepted edit never retimes an existing launch. */
|
|
228
|
+
export const adapterOperationalReaders = (config: () => AdapterTimingConfig) => ({
|
|
229
|
+
acpToolCeilingMs: () => (config().adapter_timing?.acp_tool_ceiling_min ?? ACP_TOOL_CEILING_SETTING.defaultValue) * 60_000,
|
|
230
|
+
acpActivityBatchMs: () => config().adapter_timing?.acp_activity_batch_ms ?? ACP_ACTIVITY_BATCH_SETTING.defaultValue,
|
|
231
|
+
jcodeSteerTimeoutMs: () => (config().adapter_timing?.jcode_steer_timeout_s ?? JCODE_STEER_TIMEOUT_SETTING.defaultValue) * 1000,
|
|
232
|
+
});
|
|
154
233
|
export const operationalNumber = (key: string): OperationalNumberSetting | undefined => OPERATIONAL_NUMBERS.find(def => def.key === key);
|
|
234
|
+
|
|
235
|
+
export interface ModelOperationalConfig { model_relay?: { cooldown_failures?: number; cooldown_s?: number; cooldown_cap_s?: number; terminal_history_limit?: number } }
|
|
236
|
+
/** The host captures these primitives when it constructs a relay. No current request, cooldown or journal is rewritten by reload. */
|
|
237
|
+
export const modelOperationalReaders = (config: () => ModelOperationalConfig) => ({
|
|
238
|
+
cooldownFailures: () => config().model_relay?.cooldown_failures ?? MODEL_COOLDOWN_FAILURES_SETTING.defaultValue,
|
|
239
|
+
cooldownMs: () => (config().model_relay?.cooldown_s ?? MODEL_COOLDOWN_SETTING.defaultValue) * 1000,
|
|
240
|
+
cooldownCapMs: () => (config().model_relay?.cooldown_cap_s ?? MODEL_COOLDOWN_CAP_SETTING.defaultValue) * 1000,
|
|
241
|
+
terminalHistoryLimit: () => config().model_relay?.terminal_history_limit ?? MODEL_TERMINAL_HISTORY_SETTING.defaultValue,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
export interface ConsoleOperationalConfig { console?: { poll_ms?: number; notice_ms?: number; history_limit?: number; pending_stream_limit?: number; attention_limit?: number } }
|
|
245
|
+
/** Existing authenticated settings_get rows only: pending file values and client-supplied bounds never choose a value. */
|
|
246
|
+
export function consoleOperationalSnapshot(rows: unknown) {
|
|
247
|
+
const read = (def: OperationalNumberSetting): number => {
|
|
248
|
+
if (!Array.isArray(rows)) return def.defaultValue;
|
|
249
|
+
const matches = rows.filter(row => row && typeof row === "object" && !Array.isArray(row) && row.key === def.key);
|
|
250
|
+
if (matches.length !== 1) return def.defaultValue;
|
|
251
|
+
const value: unknown = matches[0].value;
|
|
252
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= def.min && value <= def.max ? value : def.defaultValue;
|
|
253
|
+
};
|
|
254
|
+
return Object.freeze({ pollMs: read(CONSOLE_POLL_SETTING), noticeMs: read(CONSOLE_NOTICE_SETTING), historyLimit: read(CONSOLE_HISTORY_SETTING), pendingStreamLimit: read(CONSOLE_PENDING_STREAM_SETTING), attentionLimit: read(CONSOLE_ATTENTION_SETTING) });
|
|
255
|
+
}
|
package/src/hub/peers.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Envelope, EnvelopeOpts, PeerId, PeerState } from "./envelope.ts";
|
|
|
2
2
|
import type { PermissionMode } from "./permission-mode.ts";
|
|
3
3
|
import type { StartForm } from "../adapters/registry-types.ts";
|
|
4
4
|
import type { DoingKind } from "./peer-doing.ts";
|
|
5
|
+
import type { CapabilityManifest } from "./capabilities.ts";
|
|
5
6
|
|
|
6
7
|
export interface DeliveryReceipt {
|
|
7
8
|
id: string;
|
|
@@ -66,6 +67,8 @@ export interface PeerAdapter {
|
|
|
66
67
|
readonly statusNotice?: { code: "unidentified-cancellation" | "qwen-detached" | "qwen-stalled"; message: string };
|
|
67
68
|
/** Fixed builtins with session.nativeRevert only. Native conversation effects remain in the adapter. */
|
|
68
69
|
revertTurn?(nativeTurn: string): Promise<void>;
|
|
70
|
+
/** #445 C1: what this peer's native source lists right now (a probed version only), or an empty manifest. Never throws for an unprobed version; a transport failure rejects. */
|
|
71
|
+
capabilityManifest?(): Promise<CapabilityManifest>;
|
|
69
72
|
/** Leave the roster without stopping a person's terminal. */
|
|
70
73
|
detachForRoster?(resetMode: PermissionMode): void | Promise<void>;
|
|
71
74
|
/** Return to the roster after detachForRoster. */
|
package/src/hub/restart.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import type { BusSnapshot } from "./bus.ts";
|
|
5
5
|
import type { Envelope, PeerId, PeerState } from "./envelope.ts";
|
|
6
|
-
import { MAX_HOP } from "./envelope.ts";
|
|
6
|
+
import { KINDS, MAX_HOP } from "./envelope.ts";
|
|
7
7
|
import { validateJournalSnapshot } from "./delivery-journal.ts";
|
|
8
8
|
import { atomicPrivateJSON } from "./recovery-store.ts";
|
|
9
9
|
import { RecoveryDeferredHubError, validateDeferredHub } from "./deferred-hub.ts";
|
|
@@ -76,7 +76,7 @@ export function readRestartSnapshot(stateDir: string, expected: RestartReadExpec
|
|
|
76
76
|
const isEnvelope = (value: unknown, seen = false): boolean => {
|
|
77
77
|
if (!value || typeof value !== "object") return false;
|
|
78
78
|
const e = value as Record<string, unknown>;
|
|
79
|
-
return typeof e.id === "string" && e.id.length > 0 && e.id.length <= 256 && typeof e.trace === "string" && e.trace.length > 0 && e.trace.length <= 256 && peerId(e.from) && typeof e.body === "string" && Number.isSafeInteger(e.hop) && (e.hop as number) >= 0 && (seen || (e.hop as number) <= MAX_HOP) && Number.isFinite(e.ts) &&
|
|
79
|
+
return typeof e.id === "string" && e.id.length > 0 && e.id.length <= 256 && typeof e.trace === "string" && e.trace.length > 0 && e.trace.length <= 256 && peerId(e.from) && typeof e.body === "string" && Number.isSafeInteger(e.hop) && (e.hop as number) >= 0 && (seen || (e.hop as number) <= MAX_HOP) && Number.isFinite(e.ts) && (KINDS as readonly string[]).includes(e.kind as string) && ["important", "status", "fyi"].includes(e.priority as string) && (e.to === undefined || (Array.isArray(e.to) && e.to.every(peerId)));
|
|
80
80
|
};
|
|
81
81
|
const bus = s.bus as Partial<BusSnapshot>;
|
|
82
82
|
if (bus.schemaVersion !== 1 || !bus.queues || typeof bus.queues !== "object" || !bus.prefaces || typeof bus.prefaces !== "object" || !Array.isArray(bus.seen) || !bus.attempts || typeof bus.attempts !== "object" || !Array.isArray(bus.withdrawn)) return undefined;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/** Host-owned X6 correlation. The room transport never becomes a task/tool authority. */
|
|
2
|
+
import { PreEffectToolRefusal } from "./tool-refusal.ts";
|
|
3
|
+
import { HUB, MAX_HOP, newEnvelope, type Envelope } from "./envelope.ts";
|
|
4
|
+
import type { Bus } from "./bus.ts";
|
|
5
|
+
import { argsDigest, boundedInvocationBody, isOpenOutcome, type InvocationLedger, type InvocationOutcome, type InvocationRow } from "./invocations.ts";
|
|
6
|
+
import { canonical, type ShareConfig } from "./capabilities.ts";
|
|
7
|
+
import { authorizeRoomInvocation, type RoomInvocationAuthorization } from "../rooms/boundary.ts";
|
|
8
|
+
import { decodeRoomInvocationBody, encodeRoomInvocationBody, type RoomReadInvocationRequest } from "../rooms/application.ts";
|
|
9
|
+
import type { RoomInvocationIdentity, RoomInvocationIngress } from "../rooms/service.ts";
|
|
10
|
+
|
|
11
|
+
export interface RoomInvocationPorts {
|
|
12
|
+
bus: Bus; ledger: InvocationLedger; shares: () => ShareConfig;
|
|
13
|
+
identity: (peer: string, sender?: string) => RoomInvocationIdentity | undefined;
|
|
14
|
+
target: (input: RoomInvocationIngress, request: RoomReadInvocationRequest) => Promise<void>;
|
|
15
|
+
safeResult: (body: string, current: () => boolean) => Promise<boolean>;
|
|
16
|
+
quarantine?: (row: InvocationRow) => void;
|
|
17
|
+
finish: (row: InvocationRow, outcome: InvocationOutcome, input?: { body?: string; reason?: string }) => void;
|
|
18
|
+
}
|
|
19
|
+
export class RoomInvocations {
|
|
20
|
+
constructor(private readonly p: RoomInvocationPorts) {}
|
|
21
|
+
localActor(env: Envelope): string | undefined {
|
|
22
|
+
const local = env.kind === "invocation" ? env.id : env.refs?.invocation;
|
|
23
|
+
const row = local && this.p.ledger.get(local), route = local && this.p.ledger.relay(local);
|
|
24
|
+
if (!row || !route) return undefined;
|
|
25
|
+
try {
|
|
26
|
+
const identity = this.p.identity(route.remotePeer);
|
|
27
|
+
return identity && this.authorization(env, identity) ? route.direction === "outbound" ? row.caller : row.target : undefined;
|
|
28
|
+
} catch { return undefined; }
|
|
29
|
+
}
|
|
30
|
+
authorization(env: Envelope, identity: RoomInvocationIdentity): RoomInvocationAuthorization | undefined {
|
|
31
|
+
const local = env.kind === "invocation" ? env.id : env.refs?.invocation;
|
|
32
|
+
const row = local && this.p.ledger.get(local), route = local && this.p.ledger.relay(local);
|
|
33
|
+
if (!row || !route || row.class !== "read" || env.from !== HUB || env.to?.length !== 1 || env.to[0] !== route.remotePeer
|
|
34
|
+
|| route.room !== identity.room || route.member !== identity.member || route.epoch !== identity.epoch || !identity.current()) return undefined;
|
|
35
|
+
if (env.kind === "invocation" && (route.direction !== "outbound" || !isOpenOutcome(row.outcome) || env.id !== route.request)) return undefined;
|
|
36
|
+
if (env.kind === "invocation_result" && (route.direction !== "inbound" || env.id !== route.result || route.state !== "result_queued")) return undefined;
|
|
37
|
+
if (env.kind !== "invocation" && env.kind !== "invocation_result") return undefined;
|
|
38
|
+
if (env.trace !== row.trace || env.hop !== route.hop + (env.kind === "invocation_result" ? 1 : 0)) return undefined;
|
|
39
|
+
if (env.kind === "invocation" && roomInvocationHash({ id: env.id, trace: env.trace, hop: env.hop, from: route.caller, to: route.target,
|
|
40
|
+
kind: env.kind, priority: "status", body: env.body, ts: env.ts }) !== route.hash) return undefined;
|
|
41
|
+
const payload = decodeRoomInvocationBody(env.kind, env.body);
|
|
42
|
+
if (payload.invocation !== route.request || payload.capability !== route.capability || payload.digest !== route.digest) return undefined;
|
|
43
|
+
if (env.kind === "invocation_result" && (payload as import("../rooms/application.ts").RoomReadInvocationResult).outcome !== row.outcome) return undefined;
|
|
44
|
+
if (env.kind === "invocation" && argsDigest((payload as RoomReadInvocationRequest).args) !== row.argsSha256) return undefined;
|
|
45
|
+
const current = () => {
|
|
46
|
+
const live = this.p.ledger.relay(row.id), candidate = this.p.ledger.get(row.id);
|
|
47
|
+
const pin = this.p.shares()[route.remotePeer]?.[route.capability];
|
|
48
|
+
return identity.current() && !!candidate && live?.room === identity.room && live.member === identity.member && live.epoch === identity.epoch
|
|
49
|
+
&& (env.kind === "invocation" ? isOpenOutcome(candidate.outcome) && pin?.digest === row.digest && pin.class === "read" : live.state === "result_queued" && live.result === env.id);
|
|
50
|
+
};
|
|
51
|
+
return authorizeRoomInvocation(env, route.direction === "outbound" ? row.caller : row.target, payload, current);
|
|
52
|
+
}
|
|
53
|
+
/** Called after target execution settles, before the ordinary caller-offline early return. */
|
|
54
|
+
result(row: InvocationRow, outcome: InvocationOutcome, input: { body?: string; reason?: string }): boolean {
|
|
55
|
+
const route = this.p.ledger.relay(row.id);
|
|
56
|
+
if (!route || route.direction !== "inbound") return false;
|
|
57
|
+
const result = newEnvelope(HUB, "", { to: [route.remotePeer], kind: "invocation_result", priority: "status", refs: { invocation: row.id } });
|
|
58
|
+
result.trace = row.trace; result.hop = route.hop + 1;
|
|
59
|
+
result.body = encodeRoomInvocationBody("invocation_result", { invocation: route.request, capability: row.capability, digest: row.digest, class: "read",
|
|
60
|
+
outcome: outcome as Exclude<InvocationOutcome, "queued" | "delivered">, body: outcome === "withheld" ? "" : boundedInvocationBody(input.body ?? input.reason ?? "no result") });
|
|
61
|
+
const identity = this.p.identity(route.remotePeer, row.target);
|
|
62
|
+
if (!identity || identity.room !== route.room || identity.member !== route.member || identity.epoch !== route.epoch || !identity.current()) {
|
|
63
|
+
this.p.ledger.updateRelay(row.id, "needs_review"); this.p.bus.holdInvocation(row.id, "remote invocation result route changed", row.target); return true;
|
|
64
|
+
}
|
|
65
|
+
this.p.ledger.updateRelay(row.id, "result_queued", result.id);
|
|
66
|
+
try { this.p.bus.publish(result); }
|
|
67
|
+
catch { this.p.ledger.updateRelay(row.id, "needs_review"); this.p.bus.holdInvocation(row.id, "remote invocation result publication is uncertain", row.target); }
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
async receive(input: RoomInvocationIngress): Promise<"admitted" | "refused"> {
|
|
71
|
+
const env = input.envelope;
|
|
72
|
+
if (env.kind === "invocation") {
|
|
73
|
+
const request = decodeRoomInvocationBody("invocation", env.body);
|
|
74
|
+
if (env.hop >= MAX_HOP || !input.current()) return "refused";
|
|
75
|
+
const prior = this.p.ledger.relayByRemote(input.room, input.member, request.invocation, "inbound");
|
|
76
|
+
if (prior) {
|
|
77
|
+
if (prior.hash !== input.hash || prior.epoch !== input.epoch || prior.remotePeer !== input.remotePeer || prior.caller !== env.from || prior.target !== env.to || prior.capability !== request.capability || prior.digest !== request.digest) return "refused";
|
|
78
|
+
if (!input.claim()) throw new Error("room: invocation claim unavailable");
|
|
79
|
+
return "admitted";
|
|
80
|
+
}
|
|
81
|
+
try { await this.p.target(input, request); return "admitted"; }
|
|
82
|
+
catch (error) {
|
|
83
|
+
// Only a proven pre-effect refusal earns a signed NACK; storage/post-claim failures retain uncertainty.
|
|
84
|
+
if (error instanceof PreEffectToolRefusal) return "refused";
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (env.kind !== "invocation_result") return "refused";
|
|
89
|
+
const result = decodeRoomInvocationBody("invocation_result", env.body), route = this.p.ledger.relay(result.invocation), row = this.p.ledger.get(result.invocation);
|
|
90
|
+
if (!route || !row || route.direction !== "outbound" || route.room !== input.room || route.member !== input.member || route.epoch !== input.epoch
|
|
91
|
+
|| route.remotePeer !== input.remotePeer || env.from !== route.target || env.to !== route.caller || env.trace !== row.trace || env.hop !== route.hop + 1
|
|
92
|
+
|| result.capability !== row.capability || result.digest !== row.digest || !input.current()) return "refused";
|
|
93
|
+
if (!isOpenOutcome(row.outcome)) { if (!input.claim()) throw new Error("room: invocation claim unavailable"); return "admitted"; }
|
|
94
|
+
const pin = this.p.shares()[route.remotePeer]?.[route.capability];
|
|
95
|
+
const current = () => input.current() && this.p.shares()[route.remotePeer]?.[route.capability]?.digest === row.digest
|
|
96
|
+
&& this.p.shares()[route.remotePeer]?.[route.capability]?.class === "read";
|
|
97
|
+
let safe = pin?.digest === row.digest && pin.class === "read" && await this.p.safeResult(result.body, current);
|
|
98
|
+
if (!input.current()) throw new Error("room: invocation trust changed");
|
|
99
|
+
safe &&= current();
|
|
100
|
+
if (!input.claim()) throw new Error("room: invocation claim unavailable");
|
|
101
|
+
const uncertain = result.outcome === "needs_review" || result.outcome === "timeout";
|
|
102
|
+
if (uncertain) this.p.bus.holdInvocation(row.id, "target hub reports an uncertain invocation", row.target);
|
|
103
|
+
this.p.ledger.updateRelay(row.id, route.state, env.id);
|
|
104
|
+
const outcome = uncertain ? "needs_review" : safe ? result.outcome : result.outcome === "refused" ? "refused" : "withheld";
|
|
105
|
+
const visible = safe ? result.outcome === "completed" ? { body: result.body } : { reason: boundedInvocationBody(result.body) }
|
|
106
|
+
: uncertain ? { reason: "the target reports an uncertain outcome; remote details were withheld" }
|
|
107
|
+
: result.outcome === "refused" ? { reason: "the target declined; the reason was withheld" } : {};
|
|
108
|
+
this.p.finish(row, outcome, visible);
|
|
109
|
+
this.p.ledger.updateRelay(row.id, uncertain ? "needs_review" : "completed", env.id);
|
|
110
|
+
return "admitted";
|
|
111
|
+
}
|
|
112
|
+
receipt(id: string, state: "completed" | "failed_safe" | "needs_review"): void {
|
|
113
|
+
const request = this.p.ledger.relay(id), result = this.p.ledger.relayForResult(id);
|
|
114
|
+
if (request?.direction === "outbound") {
|
|
115
|
+
if (state === "completed") { this.p.ledger.markDelivered(id); this.p.ledger.updateRelay(id, "admitted"); }
|
|
116
|
+
else {
|
|
117
|
+
this.p.ledger.updateRelay(id, state === "failed_safe" ? "completed" : "needs_review"); const row = this.p.ledger.get(id);
|
|
118
|
+
if (row) this.p.finish(row, state === "failed_safe" ? "refused" : "needs_review", { reason: state === "failed_safe" ? "the target hub refused read admission" : "remote invocation delivery is uncertain; inspect it by hand" });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (result) this.p.ledger.updateRelay(result.local, state === "completed" ? "completed" : "needs_review");
|
|
122
|
+
}
|
|
123
|
+
/** Startup only: never resend an outcome whose publication a crash could have interrupted. */
|
|
124
|
+
reconcile(): void {
|
|
125
|
+
const pending = this.p.bus.queueList(undefined, true);
|
|
126
|
+
for (const route of this.p.ledger.pendingRelays()) {
|
|
127
|
+
if (route.state === "needs_review") continue;
|
|
128
|
+
const row = this.p.ledger.get(route.local);
|
|
129
|
+
if (!row) continue;
|
|
130
|
+
const request = pending.find(delivery => delivery.originals.some(env => env.id === row.id));
|
|
131
|
+
if (isOpenOutcome(row.outcome) && (!request || request.state !== "queued")) {
|
|
132
|
+
this.p.bus.holdInvocation(row.id, "remote invocation handoff is unconfirmed after restart", row.target);
|
|
133
|
+
this.p.ledger.updateRelay(row.id, "needs_review");
|
|
134
|
+
if (this.p.quarantine) this.p.quarantine(row); else this.p.ledger.settle(row.id, "needs_review");
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const result = route.result && pending.find(delivery => delivery.originals.some(env => env.id === route.result));
|
|
138
|
+
if (route.direction === "inbound" && !isOpenOutcome(row.outcome) && (route.state !== "result_queued" || !result || result.state !== "queued")) {
|
|
139
|
+
this.p.ledger.updateRelay(row.id, "needs_review");
|
|
140
|
+
if (!route.result) this.p.bus.holdInvocation(row.id, "remote result publication was interrupted", row.target);
|
|
141
|
+
} else if (route.direction === "outbound" && !isOpenOutcome(row.outcome)) this.p.ledger.updateRelay(row.id, "needs_review");
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
export const roomInvocationHash = (value: unknown): string => argsDigest(canonical(value));
|
package/src/hub/settings.ts
CHANGED
|
@@ -87,7 +87,7 @@ export const SETTINGS: readonly SettingDef[] = [
|
|
|
87
87
|
* Never editable from settings, whatever the session: these choose what the hub executes or where secrets and task
|
|
88
88
|
* text go. A key equal to an entry, or below it, is refused by name.
|
|
89
89
|
*/
|
|
90
|
-
export const NEVER_EDITABLE = ["kimi_cmd", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "codex_bin", "pi.cmd", "checks", "mlx", "omniroute", "memory.worker_url", "local.read_allow", "local.bash_network", "local.network_allow", "bench.enabled", "terminal", "schedules", "folder_triggers", "corpus", "rooms", "instances", "adapters", "adapter_modules", "local.doc_read", "local.web_fetch"] as const;
|
|
90
|
+
export const NEVER_EDITABLE = ["invocation", "kimi_cmd", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "codex_bin", "pi.cmd", "checks", "mlx", "omniroute", "memory.worker_url", "local.read_allow", "local.bash_network", "local.network_allow", "bench.enabled", "terminal", "schedules", "folder_triggers", "corpus", "rooms", "instances", "adapters", "adapter_modules", "local.doc_read", "local.web_fetch"] as const;
|
|
91
91
|
|
|
92
92
|
/**
|
|
93
93
|
* The one key `ahub learn accept` writes (#514b): the active profile's review checklist, which replaces as a unit. It is NOT a row of `SETTINGS` and `settingDef` does NOT resolve it, so
|
package/src/hub/shipped-packs.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import example from "../../templates/packs/example.pack.json" with { type: "text" };
|
|
2
|
+
import writing from "../../templates/packs/writing-pack.pack.json" with { type: "text" };
|
|
3
|
+
import analysis from "../../templates/packs/analysis.pack.json" with { type: "text" };
|
|
4
|
+
import ops from "../../templates/packs/ops.pack.json" with { type: "text" };
|
|
2
5
|
|
|
3
6
|
/**
|
|
4
7
|
* The packs the hub ships (#513a), by file name without `.pack.json`. Imported as text so the bundle carries them, as `shipped-roles.ts` carries the role catalog.
|
|
5
|
-
*
|
|
8
|
+
* The format example and the writing, analysis and ops domain packs are advisory data; checks never execute from this registry.
|
|
6
9
|
*/
|
|
7
10
|
// TypeScript types a `.json` import as its parsed shape even with `type: "text"`; Bun hands over the file text, which `parsePack` parses and bounds itself.
|
|
8
|
-
export const SHIPPED_PACKS: Readonly<Record<string, string>> = { example: example as unknown as string };
|
|
11
|
+
export const SHIPPED_PACKS: Readonly<Record<string, string>> = { example: example as unknown as string, "writing-pack": writing as unknown as string, analysis: analysis as unknown as string, ops: ops as unknown as string };
|
package/src/hub/shipped-roles.ts
CHANGED
|
@@ -10,6 +10,13 @@ import role8 from "../../templates/roles/research/analyst.md" with { type: "text
|
|
|
10
10
|
import role9 from "../../templates/roles/research/statistician.md" with { type: "text" };
|
|
11
11
|
import role10 from "../../templates/roles/research/fact-checker.md" with { type: "text" };
|
|
12
12
|
|
|
13
|
+
import role11 from "../../templates/roles/writing/editor.md" with { type: "text" };
|
|
14
|
+
import role12 from "../../templates/roles/writing/translator.md" with { type: "text" };
|
|
15
|
+
import role13 from "../../templates/roles/writing/style-checker.md" with { type: "text" };
|
|
16
|
+
import role14 from "../../templates/roles/writing/terminology-checker.md" with { type: "text" };
|
|
17
|
+
import role15 from "../../templates/roles/ops/project-manager.md" with { type: "text" };
|
|
18
|
+
import role16 from "../../templates/roles/ops/meeting-actions-owner.md" with { type: "text" };
|
|
19
|
+
|
|
13
20
|
export const SHIPPED_ROLES: Readonly<Record<string, string>> = {
|
|
14
21
|
"software-architect": role0,
|
|
15
22
|
"backend-engineer": role1,
|
|
@@ -22,4 +29,10 @@ export const SHIPPED_ROLES: Readonly<Record<string, string>> = {
|
|
|
22
29
|
"analyst": role8,
|
|
23
30
|
"statistician": role9,
|
|
24
31
|
"fact-checker": role10,
|
|
32
|
+
"editor": role11,
|
|
33
|
+
"translator": role12,
|
|
34
|
+
"style-checker": role13,
|
|
35
|
+
"terminology-checker": role14,
|
|
36
|
+
"project-manager": role15,
|
|
37
|
+
"meeting-actions-owner": role16,
|
|
25
38
|
};
|
package/src/hub/tasks.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { InvocationRow } from "./invocations.ts";
|
|
1
2
|
import type { ContextAdmission } from "./context-window.ts";
|
|
2
3
|
import { queueBackpressure, queueBackpressurePolicy, type QueueBackpressureCode, type QueueBackpressurePolicy, type QueueBackpressureStatus, type QueuePressure } from "./queue-backpressure.ts";
|
|
3
4
|
import { canonicalMemorySymbolNode, normalizeMemorySymbolInput, MEMORY_SYMBOL_LIMIT, type MemorySymbolNodes } from "./memory-symbols.ts";
|
|
@@ -262,6 +263,7 @@ QUIET_EVENTS.add("ready held"); // #655: the hub's note that a ready task waits
|
|
|
262
263
|
QUIET_EVENTS.add("plan observed"); // Host observation does not move the task or replace completion intent.
|
|
263
264
|
QUIET_EVENTS.add("planner changed"); // Planning authority does not move the execution owner or revoke its handoff.
|
|
264
265
|
QUIET_EVENTS.add("claim verdict"); // #901: a person's bookkeeping on a plan claim moves nothing, so it must not turn a running check into "check finished late".
|
|
266
|
+
QUIET_EVENTS.add("invocation");
|
|
265
267
|
QUIET_EVENTS.add("shell branch bound"); // #880: the hub's host-observed branch binding (#845) is bookkeeping; it neither moves nor settles the task.
|
|
266
268
|
/** The records of the `done (checking)` entry a passed check now completes: what the owner delivered then (#509a). */
|
|
267
269
|
const latestPendingDeliverables = (task: Task): DeliverableRecord[] | undefined => {
|
|
@@ -609,6 +611,17 @@ export class Tasks {
|
|
|
609
611
|
});
|
|
610
612
|
}
|
|
611
613
|
|
|
614
|
+
/** Host-only audit: no tool accepts this record and no task state or notification changes. */
|
|
615
|
+
recordInvocation(id: number, row: InvocationRow): void {
|
|
616
|
+
const task = this.d.board.get(id);
|
|
617
|
+
if (!task) throw new Error("invocation audit task is unavailable");
|
|
618
|
+
if (row.task !== id) throw new Error("invocation audit task mismatch");
|
|
619
|
+
if (row.outcome === "queued" && (this.isPii(task) || this.d.privateTurn?.(row.caller) || isClosedTaskState(task.state) || task.state === "backlog" || task.owner !== row.caller && task.reviewer !== row.caller)) throw new Error("invocation audit task scope changed");
|
|
620
|
+
if (task.history.some(entry => entry.invocation?.id === row.id && entry.invocation.outcome === row.outcome)) return;
|
|
621
|
+
const { id: invocationId, caller, target, capability, digest, class: sideEffect, approval, argsSha256, outcome } = row;
|
|
622
|
+
this.d.board.update(id, HUB, "invocation", {}, undefined, { invocation: { id: invocationId, caller, target, capability, digest, class: sideEffect, approval, argsSha256, outcome } });
|
|
623
|
+
}
|
|
624
|
+
|
|
612
625
|
recordOwnerTurn(peer: PeerId, bindings: readonly { id: number; acceptance: number }[], changed: boolean | undefined, effect: boolean): void {
|
|
613
626
|
requireLocalTaskPeer(peer);
|
|
614
627
|
this.noWriteTurns.retain(this.d.board.list("in_progress").filter(task => ["implement", "bulk_edit"].includes(task.class)).map(task => task.id));
|
|
@@ -865,6 +878,7 @@ export class Tasks {
|
|
|
865
878
|
...(h.reviewFindings ? { reviewFindings: !h.withheld && this.findingTextAllowed(task, h.reviewFindings.items) ? h.reviewFindings
|
|
866
879
|
: { ...(h.reviewFindings.head ? { head: h.reviewFindings.head } : {}), tally: findingTally(h.reviewFindings.items), withheld: true } } : {}),
|
|
867
880
|
...(h.supersededBy === undefined ? {} : { supersededBy: screenHistoryText(h.supersededBy, "supersession", h.withheld) }),
|
|
881
|
+
...(h.invocation ? { invocation: h.invocation } : {}),
|
|
868
882
|
...(h.profile === undefined ? {} : { profile: screenHistoryText(h.profile, "profile") }),
|
|
869
883
|
...(h.owner === undefined ? {} : { owner: h.owner }),
|
|
870
884
|
...(h.planner === undefined ? {} : { planner: h.planner }),
|
package/src/memory/capture.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
2
2
|
import { defaultCapturePeer } from "../adapters/definitions/native-identity-defaults.ts";
|
|
3
|
-
import { BOUNDED_TASK_TOOLS } from "../hub/hub-tools.ts";
|
|
3
|
+
import { BOUNDED_TASK_TOOLS, CAPABILITY_TOOLS } from "../hub/hub-tools.ts";
|
|
4
4
|
import { MEMORY_READ_TOOLS } from "../hub/read-tools.ts";
|
|
5
5
|
import { isDenied } from "../local/deny.ts";
|
|
6
6
|
import type { MemoryClient } from "./client.ts";
|
|
@@ -38,7 +38,7 @@ export class Capture {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
observe(call: { tool: string; args: string; output: string; id: string; paths: string[]; sourceId?: string }): void {
|
|
41
|
-
if (!this.sessionId || ((MEMORY_READ_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || BOUNDED_TASK_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, ""))) || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_memory_correct") || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_source_read" || this.opts.skip?.includes(call.tool)) return;
|
|
41
|
+
if (!this.sessionId || ((MEMORY_READ_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || BOUNDED_TASK_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || CAPABILITY_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, ""))) || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_memory_correct") || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_source_read" || this.opts.skip?.includes(call.tool)) return;
|
|
42
42
|
// Anything that names a denylisted path is kept out of memory entirely, input and output alike.
|
|
43
43
|
if (call.paths.some((p) => isDenied(p, this.opts.deny) || p.split(/\s+/).some((word) => isDenied(word, this.opts.deny)))) return;
|
|
44
44
|
// ponytail: legacy callers without a host sourceId dedupe native ids within a summary interval; all live LocalPeer calls supply invocation identity. Require that port for other hosts.
|
package/src/models/relay.ts
CHANGED
|
@@ -167,6 +167,11 @@ export interface ModelRelayOptions {
|
|
|
167
167
|
efficientWaitMs?: number;
|
|
168
168
|
/** A backend alias's cooldown starts or ends. */
|
|
169
169
|
onCooldown?: (event: RelayCooldownEvent) => void;
|
|
170
|
+
/** Host-validated policy captured when this relay is constructed. */
|
|
171
|
+
cooldownFailures?: number;
|
|
172
|
+
cooldownMs?: number;
|
|
173
|
+
cooldownCapMs?: number;
|
|
174
|
+
terminalHistoryLimit?: number;
|
|
170
175
|
/** Cooldown clock; tests inject one. */
|
|
171
176
|
now?: () => number;
|
|
172
177
|
/** How long a dispatch waits for a busy MLX slot before it gives up and falls back (default 120 s); tests shorten it. */
|
|
@@ -207,8 +212,9 @@ export interface ModelRelay {
|
|
|
207
212
|
|
|
208
213
|
export interface RelayCooldownEvent { alias: string; event: "start" | "end"; failures: number; ms?: number }
|
|
209
214
|
|
|
210
|
-
|
|
211
|
-
const COOLDOWN_FAILURES = 3, COOLDOWN_MS = 30_000, COOLDOWN_CAP_MS = 300_000;
|
|
215
|
+
/** Legacy defaults, independently pinned by the operational-settings tests. */
|
|
216
|
+
export const COOLDOWN_FAILURES = 3, COOLDOWN_MS = 30_000, COOLDOWN_CAP_MS = 300_000;
|
|
217
|
+
export const RELAY_TERMINAL_HISTORY_LIMIT = 1000;
|
|
212
218
|
/** The longest one upstream request may take. */
|
|
213
219
|
const REQUEST_DEADLINE_MS = 180_000;
|
|
214
220
|
// ponytail: a moved attempt (a load move or an enforced pin, both only optimizations) gets 15 s to its response headers
|
|
@@ -222,7 +228,12 @@ const MOVE_FIRST_BYTE_MS = 15_000;
|
|
|
222
228
|
*/
|
|
223
229
|
export class BackendCooldowns {
|
|
224
230
|
private readonly entries = new Map<string, { failures: number; until?: number; failedAt?: number; countedAt?: number }>();
|
|
225
|
-
|
|
231
|
+
private readonly policy: { failures: number; cooldownMs: number; capMs: number };
|
|
232
|
+
constructor(
|
|
233
|
+
private readonly now: () => number = Date.now,
|
|
234
|
+
private readonly notify: (event: RelayCooldownEvent) => void = () => {},
|
|
235
|
+
policy: { failures: number; cooldownMs: number; capMs: number } = { failures: COOLDOWN_FAILURES, cooldownMs: COOLDOWN_MS, capMs: COOLDOWN_CAP_MS },
|
|
236
|
+
) { this.policy = { ...policy }; }
|
|
226
237
|
|
|
227
238
|
/** The alias's cooldown while it lasts. Its end is recorded when it is first seen to have passed, or at an answer. */
|
|
228
239
|
cooling(alias: string): { until: number; failures: number } | undefined {
|
|
@@ -244,11 +255,11 @@ export class BackendCooldowns {
|
|
|
244
255
|
const entry = this.entry(alias);
|
|
245
256
|
entry.failedAt = this.now();
|
|
246
257
|
if (cooling || !streak) return;
|
|
247
|
-
if (entry.countedAt !== undefined && this.now() - entry.countedAt > 2 *
|
|
258
|
+
if (entry.countedAt !== undefined && this.now() - entry.countedAt > 2 * this.policy.capMs) entry.failures = 0;
|
|
248
259
|
entry.countedAt = this.now();
|
|
249
260
|
entry.failures++;
|
|
250
|
-
if (entry.failures <
|
|
251
|
-
const ms = Math.min(
|
|
261
|
+
if (entry.failures < this.policy.failures) return;
|
|
262
|
+
const ms = Math.min(this.policy.capMs, this.policy.cooldownMs * 2 ** (entry.failures - this.policy.failures));
|
|
252
263
|
entry.until = this.now() + ms;
|
|
253
264
|
this.notify({ alias, event: "start", failures: entry.failures, ms });
|
|
254
265
|
}
|
|
@@ -265,7 +276,7 @@ export class BackendCooldowns {
|
|
|
265
276
|
/** Until when the alias's last dispatch counts as failed for a load move, while it does. */
|
|
266
277
|
failing(alias: string): number | undefined {
|
|
267
278
|
const at = this.entries.get(alias)?.failedAt;
|
|
268
|
-
return at !== undefined && this.now() < at +
|
|
279
|
+
return at !== undefined && this.now() < at + this.policy.cooldownMs ? at + this.policy.cooldownMs : undefined;
|
|
269
280
|
}
|
|
270
281
|
|
|
271
282
|
private entry(alias: string) {
|
|
@@ -430,7 +441,7 @@ export async function startModelRelay(options: ModelRelayOptions): Promise<Model
|
|
|
430
441
|
const models = relayModelIds(options);
|
|
431
442
|
const efficientWaitMs = options.efficientWaitMs;
|
|
432
443
|
const moveFirstByteMs = options.moveFirstByteMs ?? MOVE_FIRST_BYTE_MS;
|
|
433
|
-
const cooldowns = new BackendCooldowns(options.now, (event) => { try { options.onCooldown?.(event); } catch { /* observation cannot fail routing */ } });
|
|
444
|
+
const cooldowns = new BackendCooldowns(options.now, (event) => { try { options.onCooldown?.(event); } catch { /* observation cannot fail routing */ } }, { failures: options.cooldownFailures ?? COOLDOWN_FAILURES, cooldownMs: options.cooldownMs ?? COOLDOWN_MS, capMs: options.cooldownCapMs ?? COOLDOWN_CAP_MS });
|
|
434
445
|
const autoRoute = options.enableHubAuto ? new AutoRouteSelector({ ...options, dgxMaxInputTokens }, defaultBackend, mlxAlias, estimateInputTokens) : undefined;
|
|
435
446
|
let mlx: MlxHandle | undefined;
|
|
436
447
|
let mlxStarting: Promise<MlxHandle> | undefined;
|
|
@@ -438,7 +449,7 @@ export async function startModelRelay(options: ModelRelayOptions): Promise<Model
|
|
|
438
449
|
const activeRequests = new Set<ActiveRequest>();
|
|
439
450
|
const activeByAlias = new Map<string, number>();
|
|
440
451
|
const journal: RelayRequestRecord[] = [];
|
|
441
|
-
const JOURNAL_LIMIT =
|
|
452
|
+
const JOURNAL_LIMIT = options.terminalHistoryLimit ?? RELAY_TERMINAL_HISTORY_LIMIT;
|
|
442
453
|
|
|
443
454
|
// The record object is the generation fence: every update goes through this entry's own closure,
|
|
444
455
|
// so interleaved requests for the same alias never write into each other's evidence.
|
package/src/rooms/application.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { canonicalBytes, ROOM_WIRE_LIMITS } from "./wire.ts";
|
|
|
6
6
|
export const ROOM_APPLICATION_MAX_BYTES = ROOM_WIRE_LIMITS.plaintext;
|
|
7
7
|
export interface RoomChatEnvelope {
|
|
8
8
|
id: string; trace: string; hop: number; from: string; to: string;
|
|
9
|
-
kind: "chat" | "status"; priority: Priority; body: string; ts: number;
|
|
9
|
+
kind: "chat" | "status" | "invocation" | "invocation_result"; priority: Priority; body: string; ts: number;
|
|
10
10
|
}
|
|
11
11
|
export type RoomReceiptReason = "admitted" | "inbox_admitted" | "inbox_full" | "inbox_oversize" | "inbox_expired" | "inbox_age_unavailable" | "not_exposed" | "boundary_refused" | "invalid_message" | "rate_limited" | "hop" | "person_inbox_unsupported";
|
|
12
12
|
export type RoomMessage =
|
|
@@ -64,10 +64,75 @@ function text(value: unknown): string {
|
|
|
64
64
|
if (encoder.encode(value).length > ROOM_APPLICATION_MAX_BYTES) fail("limit");
|
|
65
65
|
return value;
|
|
66
66
|
}
|
|
67
|
+
export type RoomInvocationKind = "invocation" | "invocation_result";
|
|
68
|
+
export interface RoomReadInvocationRequest {
|
|
69
|
+
invocation: string; capability: string; digest: string; class: "read"; depth: 1;
|
|
70
|
+
args: Record<string, string | number | boolean>;
|
|
71
|
+
}
|
|
72
|
+
export interface RoomReadInvocationResult {
|
|
73
|
+
invocation: string; capability: string; digest: string; class: "read";
|
|
74
|
+
outcome: "completed" | "refused" | "failed" | "expired" | "timeout" | "needs_review" | "withheld";
|
|
75
|
+
body: string;
|
|
76
|
+
}
|
|
77
|
+
export type RoomInvocationPayload = RoomReadInvocationRequest | RoomReadInvocationResult;
|
|
78
|
+
/** Wire contract bounds; the target still validates the capability's actual schema. */
|
|
79
|
+
const INVOCATION_ARGS_MAX = 4096, INVOCATION_STRING_MAX = 2000, INVOCATION_RESULT_MAX = 8192;
|
|
80
|
+
const CAPABILITY_ID = /^(command|skill|mcp_tool):[A-Za-z0-9_.:-]{1,64}$/;
|
|
81
|
+
const CAPABILITY_DIGEST = /^sha256:[0-9a-f]{64}$/;
|
|
82
|
+
export function roomInvocationArgs(value: unknown): Record<string, string | number | boolean> {
|
|
83
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) fail();
|
|
84
|
+
const keys = Object.keys(value).sort();
|
|
85
|
+
if (keys.length > 16) fail("limit");
|
|
86
|
+
const raw = object(value, keys), args = Object.create(null) as Record<string, string | number | boolean>;
|
|
87
|
+
for (const key of keys) {
|
|
88
|
+
if (/^[A-Za-z0-9_.:-]{1,64}$/.exec(key)?.[0] !== key) fail();
|
|
89
|
+
const arg = raw[key];
|
|
90
|
+
if (typeof arg === "string") { if (!arg.isWellFormed() || arg.length > INVOCATION_STRING_MAX) fail("limit"); }
|
|
91
|
+
else if (typeof arg !== "boolean" && (typeof arg !== "number" || !Number.isFinite(arg))) fail();
|
|
92
|
+
Object.defineProperty(args, key, { value: arg, enumerable: true });
|
|
93
|
+
}
|
|
94
|
+
if (JSON.stringify(args).length > INVOCATION_ARGS_MAX) fail("limit");
|
|
95
|
+
return Object.fromEntries(Object.entries(args));
|
|
96
|
+
}
|
|
97
|
+
function invocationPayload(kind: RoomInvocationKind, value: unknown): RoomInvocationPayload {
|
|
98
|
+
const row = object(value, kind === "invocation" ? ["invocation", "capability", "digest", "class", "depth", "args"] : ["invocation", "capability", "digest", "class", "outcome", "body"]);
|
|
99
|
+
if (row.class !== "read" || typeof row.capability !== "string" || CAPABILITY_ID.exec(row.capability)?.[0] !== row.capability
|
|
100
|
+
|| typeof row.digest !== "string" || CAPABILITY_DIGEST.exec(row.digest)?.[0] !== row.digest) fail();
|
|
101
|
+
const common = { invocation: uuid(row.invocation), capability: row.capability, digest: row.digest, class: "read" as const };
|
|
102
|
+
if (kind === "invocation") {
|
|
103
|
+
if (row.depth !== 1) fail();
|
|
104
|
+
return { ...common, depth: 1, args: roomInvocationArgs(row.args) };
|
|
105
|
+
}
|
|
106
|
+
if (typeof row.outcome !== "string" || !["completed", "refused", "failed", "expired", "timeout", "needs_review", "withheld"].includes(row.outcome)) fail();
|
|
107
|
+
const body = text(row.body);
|
|
108
|
+
if (encoder.encode(body).length > INVOCATION_RESULT_MAX) fail("limit");
|
|
109
|
+
if (row.outcome === "withheld" && body !== "") fail();
|
|
110
|
+
return { ...common, outcome: row.outcome as RoomReadInvocationResult["outcome"], body };
|
|
111
|
+
}
|
|
112
|
+
/** Closed canonical JSON inside the encrypted envelope: no refs, approvals or extra authority fields. */
|
|
113
|
+
export function encodeRoomInvocationBody(kind: RoomInvocationKind, value: RoomInvocationPayload): string {
|
|
114
|
+
try { return decoder.decode(canonicalBytes(invocationPayload(kind, value))); }
|
|
115
|
+
catch (error) { return applicationError(error); }
|
|
116
|
+
}
|
|
117
|
+
export function decodeRoomInvocationBody(kind: "invocation", body: string): RoomReadInvocationRequest;
|
|
118
|
+
export function decodeRoomInvocationBody(kind: "invocation_result", body: string): RoomReadInvocationResult;
|
|
119
|
+
export function decodeRoomInvocationBody(kind: RoomInvocationKind, body: string): RoomInvocationPayload;
|
|
120
|
+
export function decodeRoomInvocationBody(kind: RoomInvocationKind, body: string): RoomInvocationPayload {
|
|
121
|
+
try {
|
|
122
|
+
const parsed = invocationPayload(kind, JSON.parse(text(body)));
|
|
123
|
+
if (decoder.decode(canonicalBytes(parsed)) !== body) fail();
|
|
124
|
+
return parsed;
|
|
125
|
+
} catch (error) { return applicationError(error); }
|
|
126
|
+
}
|
|
127
|
+
|
|
67
128
|
function parseEnvelope(value: unknown): RoomChatEnvelope {
|
|
68
129
|
const row = object(value, ["id", "trace", "hop", "from", "to", "kind", "priority", "body", "ts"]);
|
|
69
|
-
if (row.kind !== "chat" && row.kind !== "status") fail();
|
|
130
|
+
if (row.kind !== "chat" && row.kind !== "status" && row.kind !== "invocation" && row.kind !== "invocation_result") fail();
|
|
70
131
|
if (row.priority !== "important" && row.priority !== "status" && row.priority !== "fyi") fail();
|
|
132
|
+
if (row.kind === "invocation" || row.kind === "invocation_result") {
|
|
133
|
+
const payload = decodeRoomInvocationBody(row.kind, text(row.body));
|
|
134
|
+
if (row.from === "user" || row.to === "user" || row.priority !== "status" || row.kind === "invocation" && payload.invocation !== row.id) fail();
|
|
135
|
+
}
|
|
71
136
|
return { id: uuid(row.id), trace: uuid(row.trace), hop: integer(row.hop, 0, MAX_HOP), from: localPeer(row.from), to: localPeer(row.to), kind: row.kind, priority: row.priority, body: text(row.body), ts: integer(row.ts, 0) };
|
|
72
137
|
}
|
|
73
138
|
function frameDigest(value: unknown): string {
|