@staix/agent-hub 0.22.0 → 0.23.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 +12 -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/operations.md +147 -6
- package/docs/profiles.md +28 -3
- package/docs/quickstart.md +5 -1
- package/docs/security.md +7 -1
- 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/verified.json +15 -15
- package/package.json +1 -1
- 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 +559 -115
- 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/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
package/src/adapters/acp.ts
CHANGED
|
@@ -12,6 +12,8 @@ import { BasePeer } from "../hub/peers.ts";
|
|
|
12
12
|
import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
|
|
13
13
|
import { AcpOwnedGroup } from "./acp-owned-group.ts";
|
|
14
14
|
import type { StartForm } from "./registry-types.ts";
|
|
15
|
+
import type { CapabilityManifest } from "../hub/capabilities.ts";
|
|
16
|
+
import { acpManifest } from "./native-capabilities.ts";
|
|
15
17
|
|
|
16
18
|
export interface PermissionOption {
|
|
17
19
|
optionId: string;
|
|
@@ -121,6 +123,8 @@ export interface AcpOptions {
|
|
|
121
123
|
watchdogMs?: number;
|
|
122
124
|
/** #542 D4: how long an open tool call keeps a silent turn alive; defaults to 30 minutes, never below the watchdog. */
|
|
123
125
|
toolCeilingMs?: number;
|
|
126
|
+
/** Activity display coalescing; captured by the host at the next adapter start. */
|
|
127
|
+
activityBatchMs?: number;
|
|
124
128
|
/** Bound for a journal-less watchdog cancellation to report its prompt; defaults to the 30 s ACP handshake cap. */
|
|
125
129
|
cancelWaitMs?: number;
|
|
126
130
|
/** ACP stdio MCP servers for the session (the hub's task tools). */
|
|
@@ -156,8 +160,11 @@ export interface AcpOptions {
|
|
|
156
160
|
|
|
157
161
|
const HANDSHAKE_MS = 30_000;
|
|
158
162
|
/** Tool calls whose arguments are remembered until their permission request arrives. */
|
|
163
|
+
/** Sessions whose command list may wait for the bind (see `earlyCommands`). */
|
|
164
|
+
const EARLY_COMMANDS_MAX = 8;
|
|
159
165
|
const TOOL_INPUT_CAP = 64;
|
|
160
|
-
const TOOL_CEILING_MS = 30 * 60_000;
|
|
166
|
+
export const TOOL_CEILING_MS = 30 * 60_000;
|
|
167
|
+
export const ACTIVITY_BATCH_MS = 50;
|
|
161
168
|
|
|
162
169
|
export interface ManagedAcpLaunch {
|
|
163
170
|
readonly generation: string;
|
|
@@ -232,6 +239,17 @@ export class AcpPeer extends BasePeer {
|
|
|
232
239
|
private modeUnknown = false;
|
|
233
240
|
private kimiAgent = false;
|
|
234
241
|
private qwenAgent = false;
|
|
242
|
+
/** #445 C1: the agent identity from `initialize`, and the latest `available_commands_update` of the CURRENT session (an update that arrives before the session id is bound waits in `earlyCommands`). */
|
|
243
|
+
private agentIdentity: { name: unknown; version: unknown } | undefined;
|
|
244
|
+
private commands: { sessionId: string; update: unknown; at: number } | undefined;
|
|
245
|
+
private binding = false;
|
|
246
|
+
/**
|
|
247
|
+
* Lists that arrive while the session is being bound, by the session they name. `readline` hands every line of one stdout chunk to `onLine` before `start()` resumes
|
|
248
|
+
* from `await session/new`, so on a loaded machine the reply, the bound session's list and another session's list can all arrive while `binding` is still true. A
|
|
249
|
+
* single slot let the last of them win; keyed by session, a list for another session can neither replace nor stand in for the bound one. Bounded: an agent that names
|
|
250
|
+
* more sessions than this only loses the oldest.
|
|
251
|
+
*/
|
|
252
|
+
private earlyCommands = new Map<string, { sessionId: string; update: unknown; at: number }>();
|
|
235
253
|
private availableModes = new Set<string>();
|
|
236
254
|
private proc: ChildProcessWithoutNullStreams | undefined;
|
|
237
255
|
private ownedGroup: { proc: ChildProcessWithoutNullStreams; proof: AcpOwnedGroup } | undefined;
|
|
@@ -284,7 +302,7 @@ export class AcpPeer extends BasePeer {
|
|
|
284
302
|
if (this.activityPending?.kind !== item.kind || (this.activityPending.text.length + item.text.length > 4096)) this.flushActivity();
|
|
285
303
|
if (this.activityPending) this.activityPending.text += item.text;
|
|
286
304
|
else this.activityPending = { kind: item.kind, text: item.text };
|
|
287
|
-
this.activityTimer ??= setTimeout(() => this.flushActivity(),
|
|
305
|
+
this.activityTimer ??= setTimeout(() => this.flushActivity(), this.opts.activityBatchMs ?? ACTIVITY_BATCH_MS);
|
|
288
306
|
this.activityTimer.unref?.();
|
|
289
307
|
} else { this.flushActivity(); this.emitActivity(item.kind, item.text); }
|
|
290
308
|
}
|
|
@@ -329,6 +347,7 @@ export class AcpPeer extends BasePeer {
|
|
|
329
347
|
managedLaunches.delete(this);
|
|
330
348
|
if (this.opts.requireOwnedGroup && this.proc) throw new Error("Configured ACP still has a process; stop it before starting another");
|
|
331
349
|
this.stopping = false;
|
|
350
|
+
this.binding = true; this.earlyCommands.clear(); // #445 C1: a new process starts a new session (the bind below drops the old list)
|
|
332
351
|
const launchedId = this.id;
|
|
333
352
|
const launchedCwd = this.opts.cwd;
|
|
334
353
|
const [bin, ...args] = this.opts.cmd;
|
|
@@ -350,6 +369,7 @@ export class AcpPeer extends BasePeer {
|
|
|
350
369
|
// The configured peer id and command are labels, not an agent-specific mode contract.
|
|
351
370
|
// Installed Kimi Code CLI's ACP initialize advertises this exact name.
|
|
352
371
|
this.kimiAgent = this.nativePolicy === undefined && init?.agentInfo?.name === "Kimi Code CLI";
|
|
372
|
+
this.agentIdentity = { name: init?.agentInfo?.name, version: init?.agentInfo?.version };
|
|
353
373
|
const expected = this.opts.expectedAgent, version = init?.agentInfo?.version;
|
|
354
374
|
if (expected && (init?.agentInfo?.name !== expected.name || !(typeof expected.version === "function" ? expected.version(version) : version === expected.version))) throw new Error("native ACP identity is not verified for this production adapter");
|
|
355
375
|
const qwen = qwenNativeIdentity(this.id, this.opts.qwen025, init, this.opts.kind);
|
|
@@ -379,6 +399,8 @@ export class AcpPeer extends BasePeer {
|
|
|
379
399
|
throw new Error("Configured ACP did not provide a valid native session identity");
|
|
380
400
|
}
|
|
381
401
|
this.sessionId = session.sessionId;
|
|
402
|
+
this.binding = false;
|
|
403
|
+
this.adoptEarlyCommands(session.sessionId);
|
|
382
404
|
this.onDoing?.("reset");
|
|
383
405
|
const modes: unknown = session.modes?.availableModes;
|
|
384
406
|
this.availableModes = new Set(Array.isArray(modes) ? modes.flatMap((mode) => typeof mode?.id === "string" ? [mode.id] : []) : []);
|
|
@@ -459,6 +481,19 @@ export class AcpPeer extends BasePeer {
|
|
|
459
481
|
this.permissionMode = mode;
|
|
460
482
|
}
|
|
461
483
|
|
|
484
|
+
/** The bind replaces the old process's list with the one that arrived for THIS session while it was being bound, if any; lists for other sessions are dropped with the rest. */
|
|
485
|
+
private adoptEarlyCommands(sessionId: string): void {
|
|
486
|
+
this.commands = this.earlyCommands.get(sessionId);
|
|
487
|
+
this.earlyCommands.clear();
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** #445 C1: the capabilities of the current session's last command list, on a probed Kimi or Qwen version only. Offline or no list yet is empty. */
|
|
491
|
+
async capabilityManifest(): Promise<CapabilityManifest> {
|
|
492
|
+
const held = this.commands;
|
|
493
|
+
if (this.state === "offline" || !held || held.sessionId !== this.sessionId) return { capabilities: [], observedAt: Date.now() };
|
|
494
|
+
return acpManifest(this.agentIdentity?.name, this.agentIdentity?.version, held.update, held.at);
|
|
495
|
+
}
|
|
496
|
+
|
|
462
497
|
async stop(reason?: string): Promise<void> {
|
|
463
498
|
managedLaunches.delete(this);
|
|
464
499
|
if (this.activityOpen) this.endActivity("stopped", true);
|
|
@@ -693,6 +728,17 @@ export class AcpPeer extends BasePeer {
|
|
|
693
728
|
if (this.state === "busy" && !this.cancelWait) { this.lastSignAt = Date.now(); this.touch(); }
|
|
694
729
|
|
|
695
730
|
if (msg.method === "session/update") {
|
|
731
|
+
// #445 C1: the command list is the session's, sent right after `session/new` and again when skills change, whatever the turn state. Only the current session's counts.
|
|
732
|
+
if (msg.params?.update?.sessionUpdate === "available_commands_update" && typeof msg.params.sessionId === "string") {
|
|
733
|
+
const held = { sessionId: msg.params.sessionId, update: msg.params.update, at: Date.now() };
|
|
734
|
+
if (this.binding) {
|
|
735
|
+
this.earlyCommands.delete(held.sessionId); // the newest list of a session replaces its earlier one, and moves it to the newest end
|
|
736
|
+
this.earlyCommands.set(held.sessionId, held);
|
|
737
|
+
while (this.earlyCommands.size > EARLY_COMMANDS_MAX) this.earlyCommands.delete(this.earlyCommands.keys().next().value as string);
|
|
738
|
+
}
|
|
739
|
+
else if (held.sessionId === this.sessionId) this.commands = held;
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
696
742
|
if (msg.params?.sessionId && msg.params.sessionId !== this.sessionId) return;
|
|
697
743
|
const u = msg.params?.update;
|
|
698
744
|
// Occupancy is the session's, not the turn's: 2.1.1's source text emits usage_update after the prompt
|
|
@@ -12,7 +12,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
12
12
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
13
13
|
import { readFileSync } from "node:fs";
|
|
14
14
|
import { join } from "node:path";
|
|
15
|
-
import { ControlClient, stateDirFor } from "../hub/control-client.ts";
|
|
15
|
+
import { ControlClient, INVOCATION_CONTROL_TIMEOUT_MS, stateDirFor } from "../hub/control-client.ts";
|
|
16
16
|
import { VERSION } from "../version.ts";
|
|
17
17
|
import { IMPLEMENTER_HUB_TOOL_NAMES, IMPLEMENTER_PERMISSION_TOOL, ACTIONABLE_MESSAGE_INSTRUCTION, projectConductorTools, projectToolList, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, roleContract, TASK_TOOL_NAMES, TASK_TOOLS, profileTaskTools } from "../hub/hub-tools.ts";
|
|
18
18
|
import { frame, replyParent, sanitize, HUB_MESSAGE_INSTRUCTION, type Envelope } from "../hub/envelope.ts";
|
|
@@ -391,7 +391,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
391
391
|
return list;
|
|
392
392
|
});
|
|
393
393
|
|
|
394
|
-
async function callTool(req: { params: { name: string; arguments?: Record<string, unknown> } }) {
|
|
394
|
+
async function callTool(req: { params: { name: string; arguments?: Record<string, unknown> } }, signal?: AbortSignal) {
|
|
395
395
|
const { name, arguments: args } = req.params;
|
|
396
396
|
if (unidentified) return { ...text(UNIDENTIFIED), isError: true };
|
|
397
397
|
if (permissionTool && !IMPLEMENTER_HUB_TOOL_NAMES.includes(name)) return { ...text("tool unavailable to this owned implementer child"), isError: true };
|
|
@@ -458,14 +458,16 @@ async function callTool(req: { params: { name: string; arguments?: Record<string
|
|
|
458
458
|
}
|
|
459
459
|
if (TASK_TOOL_NAMES.has(name) || CONDUCTOR_TOOL_NAMES.has(name)) {
|
|
460
460
|
if (!hub) return text(offline());
|
|
461
|
-
|
|
461
|
+
// #445 C2: a daemon that predates invocations would answer with a generic unknown-operation error; say what is missing instead.
|
|
462
|
+
if (name === "hub_invoke" && !hub.features.includes("invocation")) return { ...text("error: this hub does not support invocations; restart it with the current agent-hub release (ahub restart)"), isError: true };
|
|
463
|
+
const res = await hub.request({ t: "task", op: name, args: args ?? {} }, name === "hub_invoke" ? INVOCATION_CONTROL_TIMEOUT_MS : undefined, name === "hub_invoke" ? signal : undefined);
|
|
462
464
|
return { ...text(res.ok ? res.text : `error: ${res.error}`), ...(res.ok ? {} : { isError: true }) };
|
|
463
465
|
}
|
|
464
466
|
return text(`unknown tool ${name}`);
|
|
465
467
|
}
|
|
466
468
|
|
|
467
469
|
// Inbox results still show fresh counts, without telling a held or refused reader to retry in a loop.
|
|
468
|
-
server.setRequestHandler(CallToolRequestSchema, async (req) => withPendingHint(await callTool(req), req.params.name !== "hub_inbox" && req.params.name !== "hub_source_read"));
|
|
470
|
+
server.setRequestHandler(CallToolRequestSchema, async (req, extra) => withPendingHint(await callTool(req, extra.signal), req.params.name !== "hub_inbox" && req.params.name !== "hub_source_read"));
|
|
469
471
|
|
|
470
472
|
await server.connect(new StdioServerTransport());
|
|
471
473
|
// The host (Claude Code, Codex, Kimi) talks over stdin; once it is gone nothing can use this server, and a leftover would keep reconnecting.
|
|
@@ -6,6 +6,8 @@ import { BasePeer, type FrontendProxyPort } from "../hub/peers.ts";
|
|
|
6
6
|
import { codexContext, type ContextReading } from "../hub/context-window.ts";
|
|
7
7
|
import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
|
|
8
8
|
import type { ContextReader } from "./registry-types.ts";
|
|
9
|
+
import type { CapabilityManifest } from "../hub/capabilities.ts";
|
|
10
|
+
import { codexSkillsManifest, NATIVE_PINS } from "./native-capabilities.ts";
|
|
9
11
|
import { ACTION_BUDGET_OPTION, type ActionGateVerdict, type PermissionRequest } from "./acp.ts";
|
|
10
12
|
|
|
11
13
|
const UNIDENTIFIED_CANCELLATION_NOTICE = "Codex cancelled turn has no verified native id; reattach the native session before further hub delivery";
|
|
@@ -775,6 +777,23 @@ export class CodexPeer extends BasePeer {
|
|
|
775
777
|
}
|
|
776
778
|
revertTurn(nativeTurn: string): Promise<void> { return this.revert(nativeTurn); }
|
|
777
779
|
|
|
780
|
+
/**
|
|
781
|
+
* #445 C1: `skills/list` through the attached connection with a hub id (the answer never reaches the TUI). Only the probed version lists (C0 proved 0.166.0;
|
|
782
|
+
* the pin is older, so a pinned install lists nothing until a receipt covers it). Offline, unattached or unanswered rejects: the caller shows no list, never a stale one.
|
|
783
|
+
*/
|
|
784
|
+
capabilityManifest(): Promise<CapabilityManifest> {
|
|
785
|
+
if (this.version !== NATIVE_PINS.codex.version) return Promise.resolve({ capabilities: [], observedAt: Date.now() });
|
|
786
|
+
const link = this.link;
|
|
787
|
+
if (this.state === "offline" || !link || link.up.readyState !== WebSocket.OPEN) return Promise.reject(new Error(`${this.id} has no attached app-server`));
|
|
788
|
+
const id = this.nextId--;
|
|
789
|
+
return new Promise<CapabilityManifest>((resolve, reject) => {
|
|
790
|
+
const timer = setTimeout(() => this.pending.delete(id) && reject(new Error("skills/list got no answer")), 10_000);
|
|
791
|
+
timer.unref?.();
|
|
792
|
+
this.pending.set(id, { resolve: (result) => (clearTimeout(timer), resolve(codexSkillsManifest(this.version, result, Date.now()))), reject: (e) => (clearTimeout(timer), reject(e)) });
|
|
793
|
+
link.up.send(JSON.stringify({ method: "skills/list", id, params: { cwds: [this.opts.cwd], forceReload: false } }));
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
|
|
778
797
|
/** account/rateLimits/read through the TUI's connection, with a hub id so the answer never reaches the TUI. */
|
|
779
798
|
private readUsage(): void {
|
|
780
799
|
const link = this.link;
|
package/src/adapters/jcode.ts
CHANGED
|
@@ -81,7 +81,7 @@ export const jcodeVersionSupported = (version: unknown): boolean => assessVersio
|
|
|
81
81
|
class SteerRefused extends Error {}
|
|
82
82
|
/** A native steer that was sent but not acknowledged: it may have gone in, so it is never requeued as if it had not. */
|
|
83
83
|
class SteerUncertain extends Error {}
|
|
84
|
-
const STEER_TIMEOUT_MS = 5000;
|
|
84
|
+
export const STEER_TIMEOUT_MS = 5000;
|
|
85
85
|
|
|
86
86
|
/**
|
|
87
87
|
* #572 AC1: a second client on the hub's own private server socket queues a soft interrupt into the running session
|
|
@@ -4,7 +4,7 @@ import type { RouteLabelEvent, RouteTurnOutcome } from "../models/route/labels.t
|
|
|
4
4
|
import type { StaySwitchPolicy } from "../models/route/stage.ts";
|
|
5
5
|
import type { ToolObservation } from "../models/route/signals.ts";
|
|
6
6
|
import { randomUUID } from "node:crypto";
|
|
7
|
-
import { MAX_HOP, renderDigest, replyAudience, replyParent, STANDING_INSTRUCTION, USER, type Envelope, type EnvelopeOpts, type PeerId } from "../hub/envelope.ts";
|
|
7
|
+
import { MAX_HOP, remoteEnvelope, renderDigest, replyAudience, replyParent, STANDING_INSTRUCTION, USER, type Envelope, type EnvelopeOpts, type PeerId } from "../hub/envelope.ts";
|
|
8
8
|
import { CONDUCTOR_TOOL_NAMES, CONDUCTOR_TOOLS, TASK_TOOL_NAMES, TASK_TOOLS, profileTaskTools } from "../hub/hub-tools.ts";
|
|
9
9
|
import { BasePeer } from "../hub/peers.ts";
|
|
10
10
|
import type { PublishResult } from "../hub/bus.ts";
|
|
@@ -169,7 +169,7 @@ export class LocalPeer extends BasePeer {
|
|
|
169
169
|
const progress = { sideEffects: 0, last: "" };
|
|
170
170
|
const requestedPolicy = this.opts.turnPolicy?.(envs);
|
|
171
171
|
const policy = envs.some(env => env.private) ? { ...requestedPolicy, pii: true } : requestedPolicy;
|
|
172
|
-
const remoteTurn = envs.some(
|
|
172
|
+
const remoteTurn = envs.some(remoteEnvelope);
|
|
173
173
|
const preserveHistory = !policy?.pii && !remoteTurn;
|
|
174
174
|
const labelTurn = this.beginRouteTurn(turn, !!policy?.pii || envs.some(env => env.private), policy?.task);
|
|
175
175
|
let completed = false;
|
|
@@ -244,7 +244,7 @@ export class LocalPeer extends BasePeer {
|
|
|
244
244
|
const { maxSteps = 30 } = this.opts;
|
|
245
245
|
const turnSignal = this.abort!.signal;
|
|
246
246
|
// Remote text/tool output is never memory evidence, even beside ordinary local work in the same turn.
|
|
247
|
-
const remoteTurn = envs.some(
|
|
247
|
+
const remoteTurn = envs.some(remoteEnvelope);
|
|
248
248
|
const capture = policy?.pii || remoteTurn ? undefined : this.opts.capture;
|
|
249
249
|
// Unknown gateway state is never permission to send PII.
|
|
250
250
|
if (policy?.pii && !(await this.opts.omni.onCampus())) {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// #445 C1: what the hub can read of a native peer's own skills and commands, and the only versions it was probed on (C0 receipt, 2026-10-30).
|
|
2
|
+
// Vendor identities and listing shapes live here, in the adapter layer, never in the core rules of `src/hub/capabilities.ts` (the #451 registry guard).
|
|
3
|
+
import { buildManifest, type Candidate, type CapabilityManifest, type FlatSchema } from "../hub/capabilities.ts";
|
|
4
|
+
|
|
5
|
+
const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
|
|
6
|
+
|
|
7
|
+
/** The only native versions whose listings were probed (C0 receipt, 2026-10-30). Any other version lists nothing until a new receipt covers it. */
|
|
8
|
+
export const NATIVE_PINS = Object.freeze({
|
|
9
|
+
kimi: { agent: "Kimi Code CLI", version: "2.3.2" },
|
|
10
|
+
qwen: { agent: "qwen-code", version: "0.26.1" },
|
|
11
|
+
codex: { agent: "codex", version: "0.166.0" },
|
|
12
|
+
} as const);
|
|
13
|
+
const STRING_INPUT: FlatSchema = { type: "object", properties: { input: { type: "string" } }, additionalProperties: false };
|
|
14
|
+
const QWEN_SKILL_SOURCES = ["bundled-skill", "skill-dir-command"];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* An ACP `available_commands_update` as the capabilities of a probed Kimi or Qwen. Only entries that can be told apart as skills are listed (Qwen by
|
|
18
|
+
* `_meta.source`, Kimi by its `skill:` prefix): a builtin such as `compact` or `clear` changes session or host state and is never listed. A native
|
|
19
|
+
* listing carries no schema and no class, so each entry takes the one-string input and declares no class at all (`sideEffect` is absent, not `read`): the
|
|
20
|
+
* person's share class is then the only one that counts, and with none it is `destructive` and refused (O6). Any other agent or version gives an empty manifest.
|
|
21
|
+
*/
|
|
22
|
+
export function acpManifest(agent: unknown, version: unknown, update: unknown, observedAt: number): CapabilityManifest {
|
|
23
|
+
const empty: CapabilityManifest = { capabilities: [], observedAt };
|
|
24
|
+
const kind = agent === NATIVE_PINS.kimi.agent && version === NATIVE_PINS.kimi.version ? "kimi"
|
|
25
|
+
: agent === NATIVE_PINS.qwen.agent && version === NATIVE_PINS.qwen.version ? "qwen" : undefined;
|
|
26
|
+
if (!kind || !object(update) || !Array.isArray(update.availableCommands)) return empty;
|
|
27
|
+
const candidates: Candidate[] = [];
|
|
28
|
+
for (const command of update.availableCommands) {
|
|
29
|
+
if (!object(command) || typeof command.name !== "string") continue;
|
|
30
|
+
const meta = object(command._meta) ? command._meta : {};
|
|
31
|
+
const skill = kind === "kimi" ? command.name.startsWith("skill:") : typeof meta.source === "string" && QWEN_SKILL_SOURCES.includes(meta.source);
|
|
32
|
+
if (skill) candidates.push({ kind: "command", name: command.name, description: command.description, inputSchema: STRING_INPUT, source: "acp", invoke: "native" });
|
|
33
|
+
}
|
|
34
|
+
return buildManifest(candidates, observedAt);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A Codex `skills/list` answer as capabilities, on the probed version only. Only a skill that says `enabled: true` is listed: a flag that is missing or unreadable is not consent. */
|
|
38
|
+
export function codexSkillsManifest(version: unknown, result: unknown, observedAt: number): CapabilityManifest {
|
|
39
|
+
const candidates: Candidate[] = [];
|
|
40
|
+
if (version === NATIVE_PINS.codex.version && object(result) && Array.isArray(result.data)) {
|
|
41
|
+
for (const entry of result.data) {
|
|
42
|
+
if (!object(entry) || !Array.isArray(entry.skills)) continue;
|
|
43
|
+
for (const skill of entry.skills) {
|
|
44
|
+
if (!object(skill) || skill.enabled !== true) continue;
|
|
45
|
+
candidates.push({ kind: "skill", name: skill.name, description: skill.shortDescription ?? skill.description, inputSchema: STRING_INPUT, source: "codex-skills", invoke: "native" });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return buildManifest(candidates, observedAt);
|
|
50
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ClaudeImplementerOptions } from "./claude-implementer.ts";
|
|
2
2
|
import type { ReviewerBackend, ReviewerPrintOptions } from "./reviewer-print.ts";
|
|
3
3
|
import type { SettingDef } from "../hub/settings.ts";
|
|
4
|
+
import type { CapabilityManifest } from "../hub/capabilities.ts";
|
|
4
5
|
import type { Addon } from "../hub/tooling.ts";
|
|
5
6
|
import type { RecoveryContinuityBookmark } from "../hub/recovery-continuity.ts";
|
|
6
7
|
import type { RecoveryPeer } from "../cli/upgrade.ts";
|
|
@@ -117,16 +118,8 @@ export interface ContextReader {
|
|
|
117
118
|
read(value: unknown, sessionId: string, measuredAt: number): ContextReading;
|
|
118
119
|
}
|
|
119
120
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
id: string;
|
|
123
|
-
kind: "command" | "skill" | "mcp_tool";
|
|
124
|
-
name: string;
|
|
125
|
-
description: string;
|
|
126
|
-
inputSchema?: unknown;
|
|
127
|
-
sideEffect: "read" | "write" | "exec" | "external";
|
|
128
|
-
}[];
|
|
129
|
-
}
|
|
121
|
+
/** #445 C1: the manifest shape lives with its digest, sanitizer and share rules in `src/hub/capabilities.ts`; the `exec` class is gone (arbitrary execution is `destructive`). */
|
|
122
|
+
export type { Capability, CapabilityManifest, SideEffect } from "../hub/capabilities.ts";
|
|
130
123
|
|
|
131
124
|
export interface NativeFactsInput {
|
|
132
125
|
event: "SessionStart" | "PreInvocation" | "PostInvocation" | "PreToolUse" | "PostToolUse" | "Stop";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Human output for `ahub capabilities` (#445 C1). Pure: parsed hub-tool JSON in, spans out, no I/O. `--json` is the caller's branch and prints the same text.
|
|
2
|
+
// A description is another agent's words: the daemon has already sanitized it, and it is shown here as data on its own row, never as part of a command line.
|
|
3
|
+
import { blank, grid, heading, pair, s } from "./sdlc-output.ts";
|
|
4
|
+
import type { Span } from "./console-state.ts";
|
|
5
|
+
|
|
6
|
+
const list = (value: unknown): any[] => Array.isArray(value) ? value : [];
|
|
7
|
+
const dash = (value: unknown) => value === undefined || value === null || value === "" ? "-" : String(value);
|
|
8
|
+
const STATE_TONE: Record<string, "attention" | "muted" | undefined> = { listed: undefined, "digest-changed": "attention", unshared: "muted" };
|
|
9
|
+
|
|
10
|
+
/** `ahub capabilities`: per peer, each capability the hub can see, whether you share it, and the class that would apply to a call. */
|
|
11
|
+
export function renderCapabilities(result: unknown, columns?: number): Span[][] {
|
|
12
|
+
const peers = list((result as { peers?: unknown } | null)?.peers);
|
|
13
|
+
const out: Span[][] = [...heading(`Capabilities: ${peers.length} peer${peers.length === 1 ? "" : "s"} listing`, columns)];
|
|
14
|
+
if (!peers.length) {
|
|
15
|
+
out.push(blank, [s("no peer lists a capability: an agent must be attached, on a version the hub has probed, and offer a skill or command", "muted")]);
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
for (const peer of peers) {
|
|
19
|
+
const entries = list(peer.entries);
|
|
20
|
+
const shared = entries.filter(entry => entry.state === "listed").length;
|
|
21
|
+
out.push(blank, ...heading(`${dash(peer.peer)}: ${entries.length} found, ${shared} shared, mode ${dash(peer.permission_mode)}`, columns));
|
|
22
|
+
out.push(...grid(["ID", "STATE", "CLASS", "SOURCE", "DIGEST"], entries.map((entry: any) => [
|
|
23
|
+
s(dash(entry.id)), s(dash(entry.state), STATE_TONE[String(entry.state)]), s(dash(entry.class), entry.class === "destructive" ? "attention" : undefined), s(`${dash(entry.source)}/${dash(entry.invoke)}`), s(dash(entry.digest)),
|
|
24
|
+
]), columns));
|
|
25
|
+
const stale = entries.filter(entry => entry.state === "digest-changed");
|
|
26
|
+
for (const entry of stale) out.push(...pair("changed", `${dash(entry.id)} no longer matches its pinned digest and is unlisted until you pin the new one`, columns, 8, "attention"));
|
|
27
|
+
}
|
|
28
|
+
out.push(blank, [s("Share an entry by adding its digest under invocation.share.<peer> in .agenthub/config.local.json (this machine only). A class you set can only raise the one the source declared. An entry with no class anywhere counts as destructive and is refused.", "muted")]);
|
|
29
|
+
return out;
|
|
30
|
+
}
|
package/src/cli/console-state.ts
CHANGED
|
@@ -29,6 +29,8 @@ export interface SettingsEdit {
|
|
|
29
29
|
text: string; value?: SettingValue; reason?: string; lines?: string[]; confirm?: string; undoDigest?: string;
|
|
30
30
|
}
|
|
31
31
|
export interface ConsoleState {
|
|
32
|
+
/** Launch-captured local presentation only; absent on legacy pure-state callers. */
|
|
33
|
+
noticeMs?: number; historyLimit?: number;
|
|
32
34
|
/** #689: the structural config form (Settings panel, key c) and the file revisions the hub last reported. */
|
|
33
35
|
configForm?: ConfigForm; configRevisions?: Record<string, string>; configInstances?: string[];
|
|
34
36
|
settingsRows?: SettingRow[]; settingsKnown?: boolean; settingsUndo?: string; settingsProblem?: string;
|
|
@@ -637,7 +639,7 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
637
639
|
if (!peer || !reason) throw new Error("queue discard needs --peer and --reason (it is recorded on every discarded delivery)");
|
|
638
640
|
const count = s.queue.filter(row => row.peer === peer && row.state === state).length;
|
|
639
641
|
const args = ["queue", "discard", "--peer", peer, ...(parsed.includes("--state") ? ["--state", state] : []), "--reason", reason, "--yes"];
|
|
640
|
-
s.history = [...s.history, input].slice(-
|
|
642
|
+
s.history = [...s.history, input].slice(-(s.historyLimit ?? CONSOLE_HISTORY_LIMIT)); s.historyIndex = s.history.length;
|
|
641
643
|
s.confirm = { type: "command", args, prompt: `discard all ${count} ${state} deliver${count === 1 ? "y" : "ies"} of ${peer}; no task or native turn is cancelled, other peers are untouched` };
|
|
642
644
|
return done();
|
|
643
645
|
}
|
|
@@ -655,7 +657,7 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
655
657
|
if (lock.syntax === "planning" && args.length < (lock.prefix[1] === "plan" ? 6 : 5)) throw new Error("backlog row actions require a reason");
|
|
656
658
|
if (lock.prefix[0] === "task" && !["owner", "reviewer"].includes(commandFlag(args, "--role") ?? "owner")) throw new Error("role must be owner or reviewer");
|
|
657
659
|
}
|
|
658
|
-
s.history = [...s.history, input].slice(-
|
|
660
|
+
s.history = [...s.history, input].slice(-(s.historyLimit ?? CONSOLE_HISTORY_LIMIT)); s.historyIndex = s.history.length;
|
|
659
661
|
if (commandNeedsConfirmation(args)) s.confirm = { type: "command", args, ...(lock ? { prompt: lockedCommandPrompt(lock, args), ...(lock.revisionField ? { lock } : {}) } : {}) };
|
|
660
662
|
else effects.push({ type: "command", args });
|
|
661
663
|
} catch (error) { notify(s, String((error as Error).message), now); }
|
|
@@ -900,9 +902,10 @@ export function reduceConsole(state: ConsoleState, key: string, now = Date.now()
|
|
|
900
902
|
export function renderConsole(s: ConsoleState, columns: number, rows = 24, now = Date.now()): string[] {
|
|
901
903
|
return renderConsoleLines(s, columns, rows, now).map(line => paint(line, false));
|
|
902
904
|
}
|
|
903
|
-
const NOTICE_MS = 10_000;
|
|
904
|
-
export
|
|
905
|
-
|
|
905
|
+
export const NOTICE_MS = 10_000;
|
|
906
|
+
export const CONSOLE_HISTORY_LIMIT = 100;
|
|
907
|
+
export function activeNotice(s: Pick<ConsoleState, "notice" | "noticeAt" | "noticeMs">, now = Date.now()): boolean {
|
|
908
|
+
return !!s.notice && now - (s.noticeAt ?? now) < (s.noticeMs ?? NOTICE_MS);
|
|
906
909
|
}
|
|
907
910
|
export const TABLES: Record<number, string[]> = {
|
|
908
911
|
1: ["PEER", "STATE", "LINK", "Q", "!", "WIP", "REVIEW", "PAUSE", "QUOTA", "MODEL", "DOING"],
|
package/src/cli/console.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { consoleOperationalSnapshot } from "../hub/operational-settings.ts";
|
|
1
2
|
import { basename } from "node:path";
|
|
2
3
|
import { fileURLToPath } from "node:url";
|
|
3
4
|
import { StringDecoder } from "node:string_decoder";
|
|
@@ -24,6 +25,8 @@ export interface ConsoleTerminal {
|
|
|
24
25
|
onSignal(listener: () => void): () => void;
|
|
25
26
|
onError?(listener: (error: Error) => void): () => void;
|
|
26
27
|
}
|
|
28
|
+
/** Legacy startup defaults, independently pinned against the H1 rows. */
|
|
29
|
+
export const CONSOLE_POLL_MS = 4000, CONSOLE_PENDING_STREAM_LIMIT = 1000, CONSOLE_ATTENTION_LIMIT = 1000;
|
|
27
30
|
export interface ConsoleOptions {
|
|
28
31
|
client: Pick<ControlClient, "request" | "send" | "close" | "onPush" | "onClose">;
|
|
29
32
|
cwd: string; stateDir: string; panels?: boolean; columns?: number; rows?: number;
|
|
@@ -101,6 +104,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
101
104
|
const removers: (() => void)[] = []; const timers: ReturnType<typeof setInterval>[] = [];
|
|
102
105
|
let resolveDone: () => void = () => {}; const done = new Promise<void>(resolve => { resolveDone = resolve; });
|
|
103
106
|
let pendingStream: ConsoleEvent[] = []; let droppedStream = 0;
|
|
107
|
+
let presentation = consoleOperationalSnapshot(undefined);
|
|
104
108
|
let priorStreamFooterStart: number | undefined;
|
|
105
109
|
let lastExpandedNotice = "";
|
|
106
110
|
const notice = (text: string, tone: Tone = "failure") => notify(state, text, Date.now(), tone);
|
|
@@ -123,7 +127,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
123
127
|
terminal.write(paint([...(color ? streamTokens(header ?? "", event.tone, event.kind) : [{ text: header ?? "" }]), ...body.map(text => ({ text: "\n" + text }))], color) + "\n");
|
|
124
128
|
}
|
|
125
129
|
else if (state.mode === "stream") writeStream(event);
|
|
126
|
-
else { pendingStream.push(event); if (pendingStream.length >
|
|
130
|
+
else { pendingStream.push(event); if (pendingStream.length > presentation.pendingStreamLimit) { pendingStream.shift(); droppedStream++; } }
|
|
127
131
|
};
|
|
128
132
|
const draw = () => {
|
|
129
133
|
if (!active) return;
|
|
@@ -389,7 +393,7 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
389
393
|
if (!active) return;
|
|
390
394
|
try {
|
|
391
395
|
if (msg.t === "event") {
|
|
392
|
-
if (state.petEnabled && msg.e.t === "envelope" && !msg.e.dropped && msg.e.env.to?.includes("user")) state.attentionMessages = [...(state.attentionMessages ?? []), { id: msg.e.env.id, peer: msg.e.env.from, important: msg.e.env.priority === "important", since: msg.e.env.ts }].slice(-
|
|
396
|
+
if (state.petEnabled && msg.e.t === "envelope" && !msg.e.dropped && msg.e.env.to?.includes("user")) state.attentionMessages = [...(state.attentionMessages ?? []), { id: msg.e.env.id, peer: msg.e.env.from, important: msg.e.env.priority === "important", since: msg.e.env.ts }].slice(-presentation.attentionLimit);
|
|
393
397
|
stream({ text: renderTailEvent(msg.e), peer: msg.e.peer ?? msg.e.env?.from, kind: msg.e.env?.kind ?? msg.e.t, tone: eventTone(msg.e) });
|
|
394
398
|
}
|
|
395
399
|
else if (msg.t === "context") stream({ text: ` ${msg.peer}: ${contextLine(msg.reading, at => new Date(at).toLocaleTimeString())}`, peer: msg.peer, kind: "context" });
|
|
@@ -454,8 +458,27 @@ export async function runConsole(options: ConsoleOptions): Promise<void> {
|
|
|
454
458
|
removers.push(terminal.onSignal(() => stop()));
|
|
455
459
|
if (terminal.onError) removers.push(terminal.onError(error => { process.exitCode = 1; stop(`Console error: ${error.message}`); }));
|
|
456
460
|
timers.push(setInterval(() => { try { state = pruneApprovals(state, Date.now()); draw(); } catch (error) { stop(`Console error: ${String((error as Error).message)}`); } }, 1000));
|
|
457
|
-
|
|
458
|
-
|
|
461
|
+
const initialPollMs = Math.max(2000, options.pollMs ?? CONSOLE_POLL_MS);
|
|
462
|
+
const initialPoll = setInterval(() => void refresh(), initialPollMs); timers.push(initialPoll);
|
|
463
|
+
// Subscribe and render synchronously as before; close, signal and permission handlers stay live during this bounded read.
|
|
464
|
+
draw(); client.send({ t: "tail" }); void refresh();
|
|
465
|
+
const startup = client.request({ t: "settings_get" }, 3000).catch(() => undefined);
|
|
466
|
+
const accepted = await Promise.race([startup, done]);
|
|
467
|
+
if (!active) return;
|
|
468
|
+
presentation = consoleOperationalSnapshot(accepted?.ok === false ? undefined : accepted?.rows);
|
|
469
|
+
state.noticeMs = presentation.noticeMs; state.historyLimit = presentation.historyLimit;
|
|
470
|
+
// Only this launch's pre-capture presentation buffers can need trimming. Never touch approvals or delivery queues.
|
|
471
|
+
const omitted = Math.max(0, pendingStream.length - presentation.pendingStreamLimit);
|
|
472
|
+
if (omitted) { pendingStream.splice(0, omitted); droppedStream += omitted; }
|
|
473
|
+
state.attentionMessages = state.attentionMessages?.slice(-presentation.attentionLimit);
|
|
474
|
+
const forgotten = Math.max(0, state.history.length - presentation.historyLimit);
|
|
475
|
+
if (forgotten) { state.history.splice(0, forgotten); state.historyIndex = Math.max(0, state.historyIndex - forgotten); }
|
|
476
|
+
const capturedPollMs = Math.max(2000, options.pollMs ?? presentation.pollMs);
|
|
477
|
+
if (capturedPollMs !== initialPollMs) {
|
|
478
|
+
clearInterval(initialPoll);
|
|
479
|
+
timers.push(setInterval(() => void refresh(), capturedPollMs));
|
|
480
|
+
}
|
|
481
|
+
draw(); await done;
|
|
459
482
|
} catch (error) { stop(`Console error: ${String((error as Error).message)}`); }
|
|
460
483
|
finally { stop(); }
|
|
461
484
|
}
|
package/src/cli/help.ts
CHANGED
|
@@ -11,7 +11,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
11
11
|
["ahub projects [--json] [--full] [--color=auto|always|never]", "list registered projects and live status"],
|
|
12
12
|
["ahub projects remove <id>", "forget a stopped registration (keeps project files)"],
|
|
13
13
|
["ahub setup [--yes]", "install or update the Claude Code channel plugin from this package, then run doctor"],
|
|
14
|
-
["ahub init [--profile dev|research|writing|general|<pack>] [--shadow] [--release-guard] [--dry-run --json]", "preview or write .agenthub/config.json and the AGENTS.md marker block (drops a legacy CLAUDE.md block); a pack is a project file .agenthub/roles/<name>.pack.json"],
|
|
14
|
+
["ahub init [--profile dev|research|writing|general|<pack>] [--shadow] [--release-guard] [--dry-run --json]", "preview or write .agenthub/config.json and the AGENTS.md marker block (drops a legacy CLAUDE.md block); a pack is a shipped one (writing-pack, analysis, ops) or a project file .agenthub/roles/<name>.pack.json"],
|
|
15
15
|
["ahub --live-test [--project <path|id>] <command> [args...]", "user-authorized operational console delegation; ordinary calls retain peer identity; person-authored content refuses"],
|
|
16
16
|
["ahub help [command]", "this help, or one command's entries; also --help and -h"],
|
|
17
17
|
["ahub version | --version", "print the installed version"],
|
|
@@ -102,6 +102,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
102
102
|
["ahub source add <path|url> | list [--json] [--color=auto|always|never]", "list is person only, add the person's or the configured conductor's: register an input as a content-hashed source (src:<16 hex of the SHA-256>) under the hub state dir sources/<id>/ (meta.json, raw, text); a URL is fetched once by this command, https only, at most 5 redirects; up to 16 MiB, never truncated; a path under pii.paths or text matching signals.pii_patterns is private, and private is never downgraded"],
|
|
103
103
|
["ahub schedule list | run <id> | pause <id> | resume <id> [--json] [--color=auto|always|never]", "list is person only; run, pause and resume are the person's or the configured conductor's: the machine-local schedules (`schedules` in .agenthub/config.local.json); list shows each one's next run, run proposes one now through the same hold, open-task and PII path as the timer and restarts its period, pause stops the timer proposing it without counting a skip, resume starts a fresh period so a pause is never replayed as a run"],
|
|
104
104
|
["ahub intake \"<text>\" [--source <id>...] | approve <id> | discard <id> [--json] [--color=auto|always|never]", "person only, except approve (person or configured conductor): turn a plain request into a draft backlog card that nothing offers or pulls until you approve it; the configured planner is asked to split it with hub_backlog_split, or the hub model proposes the split (a PII request only on an on-prem model, never to a planner); each --source must be a registered src: id; approve clears the draft on it and its parts, discard cancels them through task cancel"],
|
|
105
|
+
["ahub capabilities [--json] [--color=auto|always|never]", "person only, never a conductor or an agent shell: list the skills and commands each attached peer offers (Kimi 2.3.2 and Qwen 0.26.1 sessions, Codex 0.166.0 skills; other versions list nothing), whether you share each one, its digest and the class a call would count as; sharing is invocation.share in .agenthub/config.local.json, this machine only; it only lists and never calls anything"],
|
|
105
106
|
["ahub learn list [--all] | accept <id> | reject <id> [--json] [--color=auto|always|never]", "person only, never a conductor or an agent shell: review a recurring-unmet-criterion candidate the hub filed (#514a); accept writes its one review checklist line, added to your current checklist, to .agenthub/config.local.json through the settings writer (this machine only, never a tracked file, ahub settings undo puts it back, applies after a restart) and closes the card; reject only closes it"],
|
|
106
107
|
["ahub deliverables [--task <id>] [--approved] [--check] [--json] [--color=auto|always|never]", "what each task delivered and a reviewer approved, by id, hash and size (a path only where the PII screen allows); --check measures the files now and marks one changed since review; an agent sees only the tasks hub_task_show shows it"],
|
|
107
108
|
["ahub task show <id> [--json] [--full] [--color=auto|always|never] | escalate <id>", "full task with history (the person sees PII text, an agent the public view) / hand it to the next peer in escalate_to (person or configured conductor)"],
|
package/src/cli/identity.ts
CHANGED
|
@@ -187,6 +187,7 @@ export function cliCommandLabel(command: string, args: readonly string[], catalo
|
|
|
187
187
|
// #334: intake names its two subcommands in the audit and a refusal, never the request text (it can carry a secret).
|
|
188
188
|
if (command === "intake") return ["approve", "discard"].includes(args.find(arg => arg !== "--json") ?? "") ? `intake ${args.find(arg => arg !== "--json")}` : "intake";
|
|
189
189
|
// #514b: the audit and a refusal name the subcommand only, never the card id or any text.
|
|
190
|
+
if (command === "capabilities") return "capabilities"; // #445 C1: no argument is ever echoed into the audit
|
|
190
191
|
if (command === "learn") return ["list", "accept", "reject"].includes(args.find(arg => arg !== "--json") ?? "") ? `learn ${args.find(arg => arg !== "--json")}` : "learn";
|
|
191
192
|
if (command === "source") return ["add", "list"].includes(args.find(arg => arg !== "--json") ?? "") ? `source ${args.find(arg => arg !== "--json")}` : "source";
|
|
192
193
|
if (command === "release") return ["new", "list", "show", "plan", "activate", "freeze", "ship", "drop"].includes(args[0] ?? "") ? `release ${args[0]}` : "release";
|
package/src/cli/main.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { renderSourceAdd, renderSourceList } from "./source-output.ts";
|
|
|
27
27
|
import { renderScheduleChange, renderScheduleList, renderScheduleRun } from "./schedule-output.ts";
|
|
28
28
|
import { renderIntake, renderIntakeChange } from "./intake-output.ts";
|
|
29
29
|
import { renderLearnChange, renderLearnList } from "./learn-output.ts";
|
|
30
|
+
import { renderCapabilities } from "./capabilities-output.ts";
|
|
30
31
|
import { terminalText } from "./console-state.ts";
|
|
31
32
|
import { parseSettingsEditArgs, runSettingsEdit, SETTINGS_EDIT_USAGE } from "./settings-edit.ts";
|
|
32
33
|
import { petView, readPet, renderPet } from "./pet.ts";
|
|
@@ -1451,6 +1452,13 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1451
1452
|
return showToolText(data => renderScheduleChange(sub as "pause" | "resume", data, present.columns, Date.now()), await taskOp(sub === "pause" ? "schedule_pause" : "schedule_resume", { id }), present);
|
|
1452
1453
|
},
|
|
1453
1454
|
|
|
1455
|
+
capabilities: async () => {
|
|
1456
|
+
// #445 C1: what each attached peer offers and which of it you share. Person only, like `learn`: `capabilities` has no entry in classifyPeerCommand, so an agent shell is refused before this runs and the daemon refuses any caller but the person again. It lists; it never calls anything.
|
|
1457
|
+
const split = splitPresentation(args);
|
|
1458
|
+
const present = outputOptions(split.flags);
|
|
1459
|
+
if (split.operands.length) return fail("usage: ahub capabilities [--json] [--color=auto|always|never]");
|
|
1460
|
+
return showToolText(data => renderCapabilities(data, present.columns), await taskOp("capabilities_list", {}), present);
|
|
1461
|
+
},
|
|
1454
1462
|
learn: async () => {
|
|
1455
1463
|
// #514b: the person's verdict on a recurring-criterion candidate (#514a). Person only, end to end: `learn` is a `console` command for an agent shell (classifyPeerCommand has no entry for it, so the
|
|
1456
1464
|
// identity layer refuses it before this runs), and the daemon refuses any caller but the person again. `accept` writes ONE key (profile_overrides.review_checklist) to the machine-local
|
package/src/hub/board.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { InvocationRow } from "./invocations.ts";
|
|
1
2
|
import { HOST_CODE_SYMBOLS, parseTaskCodeSymbols, type TaskCodeSymbols } from "./task-code-symbols.ts";
|
|
2
3
|
import { createHash } from "node:crypto";
|
|
3
4
|
import { planFingerprint } from "./plan-conformance.ts";
|
|
@@ -57,7 +58,11 @@ export const OUTCOMES_KEPT_MS = 7 * 24 * 3_600_000;
|
|
|
57
58
|
|
|
58
59
|
export const TASK_MOVE_REASONS = ["manual", "budget", "offline", "idle", "rejections", "declined", "inference_failed", "delivery_failed"] as const;
|
|
59
60
|
export type TaskMoveReason = (typeof TASK_MOVE_REASONS)[number];
|
|
61
|
+
export type InvocationHistory = Pick<InvocationRow, "id" | "caller" | "target" | "capability" | "digest" | "class" | "approval" | "argsSha256" | "outcome">;
|
|
62
|
+
|
|
60
63
|
export interface HistoryEntry {
|
|
64
|
+
/** Host-owned invocation metadata only, never arguments or results. */
|
|
65
|
+
invocation?: InvocationHistory;
|
|
61
66
|
at: number;
|
|
62
67
|
by: PeerId;
|
|
63
68
|
event: string; // proposed | assigned | accepted | declined | done | approved | changes_requested | escalated | reassigned
|
|
@@ -483,7 +488,7 @@ export class Board {
|
|
|
483
488
|
|
|
484
489
|
/** The only way a task changes. Validates the move, records who did what, returns the new row. */
|
|
485
490
|
/** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
|
|
486
|
-
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "planner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled" | "codeSymbols">>, note?: string, extra: Pick<HistoryEntry, "plannerReason" | "planAuthor" | "selfReview" | "profile" | "sweep" | "reason" | "from" | "capacity" | "conductorManual" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | "reviewScope" | "evidenceOnly" | "reviewFindings" | "unmetCriteria" | "deliverables" | "checkKey" | "missingKeys" | "claim" | "planObservation" | "planDeparture" | "planDepartureInvalidated" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number, codeSymbolsAuthority?: typeof HOST_CODE_SYMBOLS): Task {
|
|
491
|
+
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "planner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled" | "codeSymbols">>, note?: string, extra: Pick<HistoryEntry, "invocation" | "plannerReason" | "planAuthor" | "selfReview" | "profile" | "sweep" | "reason" | "from" | "capacity" | "conductorManual" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | "reviewScope" | "evidenceOnly" | "reviewFindings" | "unmetCriteria" | "deliverables" | "checkKey" | "missingKeys" | "claim" | "planObservation" | "planDeparture" | "planDepartureInvalidated" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number, codeSymbolsAuthority?: typeof HOST_CODE_SYMBOLS): Task {
|
|
487
492
|
const task = this.get(id);
|
|
488
493
|
if (!task) throw new Error(`no task #${id}`);
|
|
489
494
|
if (expectedFlowRevision !== undefined && (!Number.isSafeInteger(expectedFlowRevision) || expectedFlowRevision < 0 || task.history.length !== expectedFlowRevision)) throw new Error("task flow revision changed");
|
|
@@ -499,6 +504,7 @@ export class Board {
|
|
|
499
504
|
if (extra.selfReview !== undefined && extra.selfReview !== true) throw new Error("invalid self-review metadata");
|
|
500
505
|
if (extra.conductorManual !== undefined && (extra.conductorManual !== true || by === "user" || by === "hub" || extra.reason !== "manual"
|
|
501
506
|
|| !["assigned", "reassigned", "reserved", "unassigned", "reviewer changed"].includes(event))) throw new Error("invalid conductor manual metadata");
|
|
507
|
+
if (extra.invocation && (by !== HUB || event !== "invocation")) throw new Error("invocation audit is host-only");
|
|
502
508
|
const next = { ...task, ...patch, planner, refs: { ...task.refs, ...patch.refs } };
|
|
503
509
|
const headOf = (row: Task) => row.refs.commit ?? row.sdlc?.head ?? row.sdlc?.localHead;
|
|
504
510
|
const privateSymbols = next.signals.includes("pii") || next.signals.includes("pii_path");
|
|
@@ -542,7 +548,7 @@ export class Board {
|
|
|
542
548
|
// Disabling reporting must not let plan or repository refs edits/reverts resurrect prior consent.
|
|
543
549
|
const repositoryRefsChanged = patch.refs !== undefined && JSON.stringify([task.refs.repo, task.refs.paths]) !== JSON.stringify([next.refs.repo, next.refs.paths]);
|
|
544
550
|
const departureInvalidated = extra.planDepartureInvalidated === true || hasDeparture && (planChanged || repositoryRefsChanged);
|
|
545
|
-
const history = [...task.history, { at: Date.now(), by, event, ...plannerHistory, ...authorHistory, ...(extra.selfReview === true ? { selfReview: true as const } : {}), ...(planRevision ? { planRevision } : {}), ...(extra.planObservation ? { planObservation: extra.planObservation } : {}), ...(extra.planDeparture ? { planDeparture: extra.planDeparture } : {}), ...(departureInvalidated ? { planDepartureInvalidated: true as const } : {}), ...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.conductorManual === true ? { conductorManual: true as const } : {}), ...(extra.withheld && (note || reviewFindings) ? { withheld: true as const } : {}), ...(reviewFindings ? { reviewFindings } : {}), ...(unmetCriteria?.length ? { unmetCriteria } : {}), ...(deliverables ? { deliverables } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewScope === "code" ? { reviewScope: "code" as const } : {}), ...(extra.evidenceOnly === true && event === "changes_requested" ? { evidenceOnly: true as const } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}), ...(["check passed", "check failed"].includes(event) && typeof extra.checkKey === "string" && /^[a-z][a-z0-9_]{0,47}$/.test(extra.checkKey) ? { checkKey: extra.checkKey } : {}), ...(["check passed", "check failed"].includes(event) && Number.isSafeInteger(extra.missingKeys) && extra.missingKeys! >= 0 && extra.missingKeys! <= 100_000 ? { missingKeys: extra.missingKeys } : {}), ...(event === "claim verdict" && extra.claim ? { claim: extra.claim } : {}) }];
|
|
551
|
+
const history = [...task.history, { at: Date.now(), by, event, ...(extra.invocation ? { invocation: extra.invocation } : {}), ...plannerHistory, ...authorHistory, ...(extra.selfReview === true ? { selfReview: true as const } : {}), ...(planRevision ? { planRevision } : {}), ...(extra.planObservation ? { planObservation: extra.planObservation } : {}), ...(extra.planDeparture ? { planDeparture: extra.planDeparture } : {}), ...(departureInvalidated ? { planDepartureInvalidated: true as const } : {}), ...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.conductorManual === true ? { conductorManual: true as const } : {}), ...(extra.withheld && (note || reviewFindings) ? { withheld: true as const } : {}), ...(reviewFindings ? { reviewFindings } : {}), ...(unmetCriteria?.length ? { unmetCriteria } : {}), ...(deliverables ? { deliverables } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewScope === "code" ? { reviewScope: "code" as const } : {}), ...(extra.evidenceOnly === true && event === "changes_requested" ? { evidenceOnly: true as const } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}), ...(["check passed", "check failed"].includes(event) && typeof extra.checkKey === "string" && /^[a-z][a-z0-9_]{0,47}$/.test(extra.checkKey) ? { checkKey: extra.checkKey } : {}), ...(["check passed", "check failed"].includes(event) && Number.isSafeInteger(extra.missingKeys) && extra.missingKeys! >= 0 && extra.missingKeys! <= 100_000 ? { missingKeys: extra.missingKeys } : {}), ...(event === "claim verdict" && extra.claim ? { claim: extra.claim } : {}) }];
|
|
546
552
|
this.taskWrite(() => {
|
|
547
553
|
const result = this.db.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ?, planner = ? WHERE id = ?")
|
|
548
554
|
.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(), planner, id);
|