@staix/agent-hub 0.21.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.
Files changed (100) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +2 -2
  3. package/docs/agent-notes/adapters.md +2 -1
  4. package/docs/agent-notes/budget.md +1 -1
  5. package/docs/agent-notes/bus.md +5 -2
  6. package/docs/agent-notes/daemon.md +5 -4
  7. package/docs/agent-notes/local-worker.md +1 -1
  8. package/docs/agent-notes/models.md +2 -1
  9. package/docs/agent-notes/tasks.md +2 -1
  10. package/docs/agent-notes/tests.md +2 -2
  11. package/docs/dogfood.md +10 -2
  12. package/docs/operations.md +192 -6
  13. package/docs/profiles.md +29 -4
  14. package/docs/quickstart.md +9 -1
  15. package/docs/security.md +7 -1
  16. package/docs/smoke.md +6 -0
  17. package/docs/specs/2026-09-19-agent-hub-design.md +94 -1
  18. package/docs/verification/2026-10-30-0.22.0.md +49 -0
  19. package/docs/verification/2026-10-30-0.23.0.md +70 -0
  20. package/docs/verification/2026-10-30-445-capability-probes.md +172 -0
  21. package/docs/verification/2026-10-30-adapter-operational-settings.md +261 -0
  22. package/docs/verification/2026-10-30-bus-delivery-operational-settings.md +77 -0
  23. package/docs/verification/2026-10-30-jcode-ac4.md +24 -0
  24. package/docs/verification/2026-10-30-jcode-ac6.md +52 -0
  25. package/docs/verification/2026-10-30-model-operational-settings.md +211 -0
  26. package/docs/verification/2026-10-30-operational-settings-closure.md +1217 -0
  27. package/docs/verification/2026-10-30-recovery-wait-settings.md +141 -0
  28. package/docs/verification/2026-10-30-ui-console-operational-settings.md +1845 -0
  29. package/docs/verified.json +15 -15
  30. package/package.json +1 -1
  31. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  32. package/plugins/agent-hub/ROLE-NOTICE.md +1 -1
  33. package/plugins/agent-hub/server.js +559 -115
  34. package/src/adapters/acp.ts +48 -2
  35. package/src/adapters/claude-channel.ts +6 -4
  36. package/src/adapters/codex-appserver.ts +19 -0
  37. package/src/adapters/jcode.ts +1 -1
  38. package/src/adapters/local-worker.ts +3 -3
  39. package/src/adapters/native-capabilities.ts +50 -0
  40. package/src/adapters/registry-types.ts +3 -10
  41. package/src/cli/capabilities-output.ts +30 -0
  42. package/src/cli/console-state.ts +8 -5
  43. package/src/cli/console.ts +27 -4
  44. package/src/cli/help.ts +3 -1
  45. package/src/cli/identity.ts +4 -1
  46. package/src/cli/learn-output.ts +38 -0
  47. package/src/cli/main.ts +28 -2
  48. package/src/cli/recovery-waits.ts +26 -0
  49. package/src/cli/smoke-pty-bridge.ts +67 -18
  50. package/src/cli/smoke-pty.ts +10 -2
  51. package/src/cli/upgrade-runtime.ts +16 -14
  52. package/src/cli/upgrade.ts +2 -1
  53. package/src/hub/board.ts +8 -2
  54. package/src/hub/bus.ts +117 -17
  55. package/src/hub/capabilities.ts +215 -0
  56. package/src/hub/config-apply.ts +8 -4
  57. package/src/hub/config-trust.ts +1 -0
  58. package/src/hub/control-client.ts +41 -8
  59. package/src/hub/daemon.ts +436 -33
  60. package/src/hub/deferred-hub.ts +2 -2
  61. package/src/hub/delivery-hints.ts +2 -2
  62. package/src/hub/delivery-journal.ts +80 -61
  63. package/src/hub/envelope.ts +12 -2
  64. package/src/hub/events.ts +2 -0
  65. package/src/hub/hub-tools.ts +16 -1
  66. package/src/hub/inference.ts +10 -5
  67. package/src/hub/invocations.ts +316 -0
  68. package/src/hub/learning.ts +33 -1
  69. package/src/hub/operational-settings.ts +180 -1
  70. package/src/hub/peers.ts +3 -0
  71. package/src/hub/restart.ts +2 -2
  72. package/src/hub/room-invocations.ts +145 -0
  73. package/src/hub/settings.ts +22 -2
  74. package/src/hub/shipped-packs.ts +5 -2
  75. package/src/hub/shipped-roles.ts +13 -0
  76. package/src/hub/tasks.ts +92 -1
  77. package/src/memory/capture.ts +2 -2
  78. package/src/models/relay.ts +20 -9
  79. package/src/rooms/application.ts +67 -2
  80. package/src/rooms/boundary.ts +43 -11
  81. package/src/rooms/service.ts +40 -2
  82. package/templates/checks/analysis.mjs +59 -0
  83. package/templates/checks/fixtures/analysis-expected.json +1 -0
  84. package/templates/checks/fixtures/analysis-input.json +1 -0
  85. package/templates/checks/fixtures/analysis.mjs +7 -0
  86. package/templates/checks/fixtures/ops.json +4 -0
  87. package/templates/checks/fixtures/writing-rules.json +4 -0
  88. package/templates/checks/fixtures/writing.json +5 -0
  89. package/templates/checks/ops.mjs +40 -0
  90. package/templates/checks/writing.mjs +40 -0
  91. package/templates/packs/analysis.pack.json +14 -0
  92. package/templates/packs/ops.pack.json +14 -0
  93. package/templates/packs/writing-pack.pack.json +14 -0
  94. package/templates/roles/NOTICE.md +1 -1
  95. package/templates/roles/ops/meeting-actions-owner.md +26 -0
  96. package/templates/roles/ops/project-manager.md +26 -0
  97. package/templates/roles/writing/editor.md +26 -0
  98. package/templates/roles/writing/style-checker.md +26 -0
  99. package/templates/roles/writing/terminology-checker.md +26 -0
  100. package/templates/roles/writing/translator.md +26 -0
@@ -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(), 50);
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
- const res = await hub.request({ t: "task", op: name, args: args ?? {} });
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;
@@ -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(env => env.from.includes("@"));
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(env => env.from.includes("@"));
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
- export interface CapabilityManifest {
121
- capabilities: {
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
+ }
@@ -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(-100); s.historyIndex = s.history.length;
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(-100); s.historyIndex = s.history.length;
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 function activeNotice(s: Pick<ConsoleState, "notice" | "noticeAt">, now = Date.now()): boolean {
905
- return !!s.notice && now - (s.noticeAt ?? now) < NOTICE_MS;
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"],
@@ -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 > 1000) { pendingStream.shift(); droppedStream++; } }
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(-1000);
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
- timers.push(setInterval(() => void refresh(), Math.max(2000, options.pollMs ?? 4000)));
458
- draw(); client.send({ t: "tail" }); void refresh(); await done;
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,8 @@ 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"],
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"],
105
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"],
106
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)"],
107
109
  ["ahub task role <id> <role|none>", "the person, or an agent on a task it owns: change bounded catalog guidance on an open task; routing and authority are unchanged"],
@@ -186,9 +186,12 @@ export function cliCommandLabel(command: string, args: readonly string[], catalo
186
186
  if (command === "schedule") return ["list", "run", "pause", "resume"].includes(args.find(arg => arg !== "--json") ?? "") ? `schedule ${args.find(arg => arg !== "--json")}` : "schedule";
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
+ // #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
191
+ if (command === "learn") return ["list", "accept", "reject"].includes(args.find(arg => arg !== "--json") ?? "") ? `learn ${args.find(arg => arg !== "--json")}` : "learn";
189
192
  if (command === "source") return ["add", "list"].includes(args.find(arg => arg !== "--json") ?? "") ? `source ${args.find(arg => arg !== "--json")}` : "source";
190
193
  if (command === "release") return ["new", "list", "show", "plan", "activate", "freeze", "ship", "drop"].includes(args[0] ?? "") ? `release ${args[0]}` : "release";
191
- if (!(catalog?.definitionOf(command) ?? definitionOf(command)) && !["upgrade", "restart", "recovery", "recovery-run", "help", "--help", "--version", "version", "completion", "projects", "ui", "manager", "setup", "init", "daemon", "up", "console", "stop", "models", "say", "queue", "tail", "watch", "budget", "board", "task", "review", "remember", "ask", "route", "pause", "resume", "permit", "permission", "settings", "roster", "status", "pet", "logs", "export", "report", "research", "memory", "goal", "bench", "sdlc", "role", "facts", "check-path", "turns", "undo", "kill", "down", "reset", "doctor", "agents", "source", "broker", "schedule", "intake"].includes(command)) return "unknown";
194
+ if (!(catalog?.definitionOf(command) ?? definitionOf(command)) && !["upgrade", "restart", "recovery", "recovery-run", "help", "--help", "--version", "version", "completion", "projects", "ui", "manager", "setup", "init", "daemon", "up", "console", "stop", "models", "say", "queue", "tail", "watch", "budget", "board", "task", "review", "remember", "ask", "route", "pause", "resume", "permit", "permission", "settings", "roster", "status", "pet", "logs", "export", "report", "research", "memory", "goal", "bench", "sdlc", "role", "facts", "check-path", "turns", "undo", "kill", "down", "reset", "doctor", "agents", "source", "broker", "schedule", "intake", "learn"].includes(command)) return "unknown";
192
195
  if (["task", "queue", "budget", "route", "recovery", "models", "projects", "research", "memory", "goal", "bench", "agents", "roster", "settings"].includes(command)) {
193
196
  const sub = command === "bench" || command === "memory" || command === "goal" ? args.find((arg) => arg !== "--json") : args[0];
194
197
  if (sub && ["propose", "show", "assign", "unassign", "escalate", "cancel", "claim", "label", "list", "add", "resolve", "resume", "set", "execution", "explain", "status", "abort", "dispose", "reattach", "setup", "start", "stop", "remove", "discard", "export", "backfill", "addons", "run", "report", "compare", "search", "graph", "promote", "demote", "forget", "supersede", "dedupe", "decay", "rebuild", "sync", "machine", "import", "suggestions", "confirm", "reject", "new", "link", "state", "done", "take", "trust"].includes(sub)) return `${command} ${sub}`;
@@ -0,0 +1,38 @@
1
+ // Human output for `ahub learn` (#514b). Pure: parsed hub-tool JSON in, spans out, no I/O. `--json` is the caller's branch and prints the same text.
2
+ // Ids, counts, the key parts and the one line the hub itself generates are all it shows: the card's own text is never echoed (a card is data to the parser, not to this printer).
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
+
9
+ /** `ahub learn list [--all]`: one row per candidate, and what each command would do. */
10
+ export function renderLearnList(result: unknown, columns?: number, all = false): Span[][] {
11
+ const rows = list(result);
12
+ const open = rows.filter(row => row.status === "open").length;
13
+ const out: Span[][] = [...heading(all ? `Review checklist candidates: ${open} open, ${rows.length - open} decided` : `Review checklist candidates: ${open} open`, columns)];
14
+ if (!rows.length) {
15
+ out.push(blank, [s(all ? "no candidate has been filed" : "no open candidate; pass --all to include decided ones", "muted")]);
16
+ return out;
17
+ }
18
+ out.push(...grid(["ID", "STATUS", "PROFILE/CLASS", "CRITERION", "TASKS", "LINE TO ADD"], rows.map((row: any) => [
19
+ s(`#${row.id}`, "taskRef"), s(dash(row.status), row.status === "open" ? "attention" : "muted"), s(`${dash(row.profile)}/${dash(row.class)}`), s(dash(row.criterion)), s(String(list(row.tasks).length)), s(dash(row.line)),
20
+ ]), columns));
21
+ if (open) out.push(blank, [s("accept writes the line to .agenthub/config.local.json (ahub settings undo puts it back); reject closes the card. Nothing else changes.", "muted")]);
22
+ return out;
23
+ }
24
+
25
+ /** `ahub learn accept|reject <id>`: what was decided, and for accept exactly what was written and where. */
26
+ export function renderLearnChange(op: "accept" | "reject", result: unknown, columns?: number): Span[][] {
27
+ const r = (result ?? {}) as any;
28
+ const card = op === "accept" ? r.card : r;
29
+ const out: Span[][] = [...heading(op === "accept" ? `Candidate #${dash(card?.id)} accepted` : `Candidate #${dash(card?.id)} rejected and closed`, columns)];
30
+ if (op === "reject") return [...out, ...pair("written", "nothing", columns, 8, "muted")];
31
+ const a = r.accepted ?? {};
32
+ if (a.alreadyAccepted) return [...heading(`Candidate #${dash(card?.id)} was already accepted`, columns), ...pair("profile", `${dash(a.profile)}/${dash(a.class)}`, columns, 8), ...pair("criterion", a.criterion, columns, 8), ...pair("written", "nothing: it was accepted before (ahub settings undo still puts back that write if it is the latest)", columns, 8, "muted")];
33
+ out.push(...pair("profile", `${dash(a.profile)}/${dash(a.class)}`, columns, 8), ...pair("criterion", a.criterion, columns, 8), ...pair("line", a.line, columns, 8),
34
+ ...pair("file", `${dash(a.file)} (this machine only; the shared config is untouched)`, columns, 8), ...pair("now", a.alreadyPresent ? `the checklist already had this line: nothing was written` : `${dash(a.lines)} lines in the checklist`, columns, 8),
35
+ ...pair("applies", dash(a.applies), columns, 8, "attention"));
36
+ if (a.undo) out.push(blank, [s(`put it back with ${a.undo}`, "muted")]);
37
+ return out;
38
+ }
package/src/cli/main.ts CHANGED
@@ -26,6 +26,8 @@ import { fetchSourceUrl, MAX_SOURCE_BYTES, publicSourceView, screenRegistration,
26
26
  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
+ import { renderLearnChange, renderLearnList } from "./learn-output.ts";
30
+ import { renderCapabilities } from "./capabilities-output.ts";
29
31
  import { terminalText } from "./console-state.ts";
30
32
  import { parseSettingsEditArgs, runSettingsEdit, SETTINGS_EDIT_USAGE } from "./settings-edit.ts";
31
33
  import { petView, readPet, renderPet } from "./pet.ts";
@@ -38,7 +40,7 @@ import { verifiedJcodeCommand } from "../adapters/jcode.ts";
38
40
  import { configureJcodeMcp, jcodeExtraMcpServers } from "./jcode-mcp.ts";
39
41
  import { qwenSessionId } from "../hub/crash.ts";
40
42
  import { isPermissionMode } from "../hub/permission-mode.ts";
41
- import { parseSettingText, settingDef, settingText, type SettingRow } from "../hub/settings.ts";
43
+ import { parseSettingText, settingDef, settingText, undoableDef, type SettingRow } from "../hub/settings.ts";
42
44
  import { startModeOf } from "../hub/start-mode.ts";
43
45
  import { currentRouting, OVERLAY_FILE, parseOverlay, parseRouting, routingText, type Routing, type RoutingOverlay } from "../hub/routing.ts";
44
46
  import { spawn, spawnSync } from "node:child_process";
@@ -1450,6 +1452,30 @@ const commands: Record<string, () => Promise<void> | void> = {
1450
1452
  return showToolText(data => renderScheduleChange(sub as "pause" | "resume", data, present.columns, Date.now()), await taskOp(sub === "pause" ? "schedule_pause" : "schedule_resume", { id }), present);
1451
1453
  },
1452
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
+ },
1462
+ learn: async () => {
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
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
1465
+ // config through the settings writer; nothing in a card's text is written.
1466
+ const split = splitPresentation(args);
1467
+ const present = outputOptions(split.flags); // before any request: an invalid --color must refuse before a mutation is sent
1468
+ const usage = "usage: ahub learn list [--all] | accept <id> | reject <id> [--json] [--color=auto|always|never]";
1469
+ const [sub, ...rest] = split.operands;
1470
+ if (sub === "list" && rest.every(arg => arg === "--all")) {
1471
+ const all = rest.includes("--all");
1472
+ return showToolText(data => renderLearnList(data, present.columns, all), await taskOp("learn_list", { ...(all ? { all: true } : {}) }), present);
1473
+ }
1474
+ const [id, ...extra] = rest;
1475
+ if ((sub !== "accept" && sub !== "reject") || !id || extra.length || !/^[1-9]\d*$/.test(id)) return fail(usage);
1476
+ return showToolText(data => renderLearnChange(sub, data, present.columns), await taskOp(sub === "accept" ? "learn_accept" : "learn_reject", { id: Number(id) }), present);
1477
+ },
1478
+
1453
1479
  intake: async () => {
1454
1480
  // #334 (#511a): natural-language intake. Person only, except approve (#933: the configured conductor too, admitted and audited by the daemon).
1455
1481
  // The request is a draft backlog card that nothing offers until `approve`; the hub, not this process, splits it.
@@ -1952,7 +1978,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1952
1978
  return console.log(paint([{ text: result.text }], false));
1953
1979
  }
1954
1980
  if (sub === "undo") {
1955
- const view = await request({ t: "settings_get" }), undoDef = settingDef(view.undo, settingScope.instances, settingScope.catalog);
1981
+ const view = await request({ t: "settings_get" }), undoDef = undoableDef(view.undo, settingScope.instances, settingScope.catalog);
1956
1982
  // A roster undo of a declared peer needs its confirmation prompt; without readable declarations, refuse rather than skip it.
1957
1983
  if (typeof undoDef === "string" && settingScopeError) fail(`pending undo ${String(view.undo)}: ${undoDef}; the project config could not be read: ${settingScopeError}`);
1958
1984
  const rosterPeer = typeof undoDef !== "string" && undoDef.group === "Roster" ? undoDef.path[1] : undefined;