@staix/agent-hub 0.34.0 → 0.35.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 (161) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +35 -6
  3. package/docs/adding-an-ai-tool.md +2 -2
  4. package/docs/addons/claude-mem.md +4 -4
  5. package/docs/addons/open-code-review.md +1 -1
  6. package/docs/agent-notes/README.md +6 -0
  7. package/docs/agent-notes/adapters.md +9 -5
  8. package/docs/agent-notes/budget.md +1 -1
  9. package/docs/agent-notes/bus.md +1 -1
  10. package/docs/agent-notes/daemon.md +4 -3
  11. package/docs/agent-notes/local-worker.md +1 -1
  12. package/docs/agent-notes/models.md +2 -0
  13. package/docs/agent-notes/tasks.md +1 -1
  14. package/docs/agent-notes/tests.md +5 -0
  15. package/docs/design/adapters.md +110 -2
  16. package/docs/design/agent-hub-architecture.md +63 -1
  17. package/docs/design/agent-hub.md +6 -0
  18. package/docs/design/bus.md +18 -0
  19. package/docs/design/execution-reference.md +146 -0
  20. package/docs/design/task-board.md +10 -0
  21. package/docs/dogfood.md +33 -12
  22. package/docs/events.md +38 -20
  23. package/docs/memory.md +21 -7
  24. package/docs/operations.md +539 -24
  25. package/docs/quickstart.md +18 -4
  26. package/docs/research.md +71 -13
  27. package/docs/roles.md +1 -1
  28. package/docs/sdlc.md +9 -7
  29. package/docs/security.md +87 -11
  30. package/docs/smoke.md +14 -0
  31. package/docs/specs/2026-09-19-agent-hub-design.md +8 -3
  32. package/docs/verification/2026-11-07-0.34.1.md +54 -0
  33. package/docs/verification/2026-11-07-compat-python-startup-1248.md +51 -0
  34. package/docs/verification/2026-11-07-compat-receipt-harness-1248.md +105 -0
  35. package/docs/verification/2026-11-07-compat-receipt-producers-1248.md +96 -0
  36. package/docs/verification/2026-11-07-compat-workflow-1248.md +94 -0
  37. package/docs/verification/2026-11-07-daemon-startup-1232.md +169 -0
  38. package/docs/verification/2026-11-07-jcode-receipt-reconstruction-1248.md +118 -0
  39. package/docs/verification/2026-11-07-kimi-delayed-usage.md +34 -0
  40. package/docs/verification/2026-11-07-kimi-review-watch-1284.md +115 -0
  41. package/docs/verification/2026-11-08-agy-1.5.3.md +96 -0
  42. package/docs/verification/2026-11-08-agy-acp-t0.md +56 -0
  43. package/docs/verification/2026-11-08-hub-visual-1058.md +161 -0
  44. package/docs/verification/2026-11-08-jcode-0991-model-setter-1317.md +143 -0
  45. package/docs/verification/2026-11-08-kimi-252-model-setter-1317.md +137 -0
  46. package/docs/verification/2026-11-08-kimi-tui-completion-1282.md +45 -0
  47. package/docs/verification/2026-11-08-kimi-wire-yield-1283.md +31 -0
  48. package/docs/verification/2026-11-08-live-model-override-1317.md +250 -0
  49. package/docs/verification/2026-11-08-qwen-0269-acp.md +138 -0
  50. package/docs/verification/2026-11-08-qwen-0269-model-setter-1317.md +91 -0
  51. package/docs/verification/2026-11-09-0.35.0.md +129 -0
  52. package/docs/verification/2026-11-09-headless-records-1320.md +157 -0
  53. package/docs/verification/claude-permission-transport-native.py +10 -3
  54. package/docs/verification/claude-shell-wrapper-native.py +8 -8
  55. package/docs/verified.json +245 -15
  56. package/package.json +3 -3
  57. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  58. package/plugins/agent-hub/kimi-hook.js +20397 -0
  59. package/plugins/agent-hub/kimi-review-policy-worker.js +217 -0
  60. package/plugins/agent-hub/server.js +2701 -664
  61. package/scripts/check-docs.mjs +12 -1
  62. package/src/adapters/acp-model-policy.ts +56 -0
  63. package/src/adapters/acp.ts +175 -5
  64. package/src/adapters/agy/entry.ts +8 -5
  65. package/src/adapters/agy/hooks.ts +65 -15
  66. package/src/adapters/channel-peer.ts +33 -1
  67. package/src/adapters/claude-implementer.ts +16 -3
  68. package/src/adapters/codex-appserver.ts +118 -8
  69. package/src/adapters/codex-review-isolation.ts +3 -1
  70. package/src/adapters/compat.ts +11 -6
  71. package/src/adapters/definitions/builtin-metadata.ts +8 -1
  72. package/src/adapters/definitions/native-cli-entry-kimi.ts +76 -7
  73. package/src/adapters/definitions/native-cli-preview-common.ts +2 -2
  74. package/src/adapters/definitions/native-cli-preview-kimi.ts +9 -2
  75. package/src/adapters/definitions/reviewer-variants.ts +3 -1
  76. package/src/adapters/kimi-review-policy-worker.js +2 -0
  77. package/src/adapters/kimi-review-policy-worker.ts +85 -0
  78. package/src/adapters/kimi-review-policy.ts +68 -0
  79. package/src/adapters/kimi-review.ts +41 -41
  80. package/src/adapters/kimi-tui-facts.ts +153 -0
  81. package/src/adapters/kimi-wire-usage.ts +33 -8
  82. package/src/adapters/local-worker.ts +59 -20
  83. package/src/adapters/model-token.ts +3 -0
  84. package/src/adapters/pi.ts +27 -5
  85. package/src/adapters/registry-types.ts +34 -3
  86. package/src/adapters/registry.ts +1 -0
  87. package/src/adapters/reviewer-policy-watch.ts +86 -0
  88. package/src/adapters/reviewer-print.ts +80 -18
  89. package/src/cli/agents.ts +1 -1
  90. package/src/cli/facts-hook.ts +4 -2
  91. package/src/cli/help.ts +3 -2
  92. package/src/cli/identity.ts +3 -0
  93. package/src/cli/kimi-facts-hook.ts +11 -0
  94. package/src/cli/kimi-plugin.ts +157 -0
  95. package/src/cli/main.ts +48 -9
  96. package/src/cli/model.ts +52 -0
  97. package/src/cli/output.ts +23 -1
  98. package/src/cli/recovery-package.ts +15 -7
  99. package/src/cli/reset.ts +1 -1
  100. package/src/cli/status-lines.ts +8 -4
  101. package/src/hub/budget.ts +1 -1
  102. package/src/hub/bus.ts +40 -2
  103. package/src/hub/code-facts.ts +10 -3
  104. package/src/hub/completions.ts +3 -1
  105. package/src/hub/config-apply.ts +2 -1
  106. package/src/hub/control-client.ts +1 -1
  107. package/src/hub/daemon.ts +1050 -106
  108. package/src/hub/deliverable-files.ts +38 -5
  109. package/src/hub/dogfood-profile.ts +1 -1
  110. package/src/hub/envelope.ts +1 -1
  111. package/src/hub/events.ts +28 -18
  112. package/src/hub/execution-lineage.ts +120 -0
  113. package/src/hub/execution-reference.ts +36 -0
  114. package/src/hub/hub-tools.ts +21 -2
  115. package/src/hub/instances.ts +4 -3
  116. package/src/hub/kimi-events.ts +46 -0
  117. package/src/hub/kimi-usage.ts +132 -29
  118. package/src/hub/lifecycle.ts +31 -15
  119. package/src/hub/limits.ts +13 -4
  120. package/src/hub/manager.ts +15 -2
  121. package/src/hub/memory-policy.ts +2 -1
  122. package/src/hub/memory-sync-attestation.ts +54 -0
  123. package/src/hub/memory-sync-state.ts +20 -5
  124. package/src/hub/model-overrides.ts +115 -0
  125. package/src/hub/native-hook-launch.ts +12 -3
  126. package/src/hub/operational-view.ts +2 -2
  127. package/src/hub/peers.ts +27 -0
  128. package/src/hub/permission-mode.ts +1 -1
  129. package/src/hub/person-figures.ts +88 -0
  130. package/src/hub/report.ts +38 -4
  131. package/src/hub/research-dataset.ts +158 -0
  132. package/src/hub/research-memory.ts +68 -0
  133. package/src/hub/research-quality.ts +118 -0
  134. package/src/hub/research-record.ts +279 -0
  135. package/src/hub/research.ts +126 -27
  136. package/src/hub/restart.ts +14 -0
  137. package/src/hub/settings.ts +3 -0
  138. package/src/hub/startup-event-seeds.ts +51 -0
  139. package/src/hub/startup-readiness.ts +65 -0
  140. package/src/hub/startup-timing.ts +38 -0
  141. package/src/hub/tasks.ts +29 -9
  142. package/src/hub/ui.ts +9 -5
  143. package/src/hub/visual-assets.ts +50 -0
  144. package/src/hub/visual.ts +257 -0
  145. package/src/memory/brief.ts +169 -14
  146. package/src/memory/capture.ts +2 -1
  147. package/src/memory/recall.ts +46 -5
  148. package/src/memory/record-provenance.ts +82 -0
  149. package/src/memory/records.ts +116 -0
  150. package/src/models/route/adec.ts +190 -0
  151. package/src/models/route/config.ts +37 -3
  152. package/src/models/route/decision-evidence.ts +83 -0
  153. package/src/models/route/decision.ts +143 -0
  154. package/src/models/route/runtime.ts +13 -6
  155. package/src/ui/index.html +134 -6
  156. package/src/ui/plugin.ts +65 -4
  157. package/templates/AGENTS.block.md +1 -1
  158. package/templates/config.json +1 -1
  159. package/templates/profiles/general.json +1 -1
  160. package/templates/profiles/research.json +1 -1
  161. package/templates/profiles/writing.json +1 -1
@@ -1,3 +1,5 @@
1
+ import type { ModelOverrideSelection } from "./registry-types.ts";
2
+ import { isNativeModelToken } from "./model-token.ts";
1
3
  import type { ChildProcess } from "node:child_process";
2
4
  import { OwnedPrintChild, PrintChildCleanupError, type OwnedInputContext, type OwnedInputObserver } from "./claude-print-child.ts";
3
5
  import { randomUUID } from "node:crypto";
@@ -6,6 +8,7 @@ import { BasePeer } from "../hub/peers.ts";
6
8
  import type { DeliveryFailureCause } from "../hub/delivery-failure.ts";
7
9
  import type { Envelope } from "../hub/envelope.ts";
8
10
  import type { ReviewerMcpSession, ReviewerToolName } from "./reviewer-mcp.ts";
11
+ import { createReviewerPolicyWatch, type ReviewerPolicyProbe, type ReviewerPolicyWatch } from "./reviewer-policy-watch.ts";
9
12
 
10
13
  /** #722: the closed list of legs a reviewer's per-review start can fail on. A class names a leg, never a path, content or token. */
11
14
  export const REVIEWER_CAUSE_CLASSES = ["version", "binary", "mcp_inventory", "plugins_hooks_apps", "sandbox_proof", "credential_root", "timeout", "unknown"] as const;
@@ -33,6 +36,9 @@ class ReviewerAdmissionError extends Error {
33
36
  constructor(readonly reason: "stale_binding" | "policy_changed") { super("review admission changed"); }
34
37
  }
35
38
  class ReviewerInputAdmissionError extends Error {}
39
+ class ReviewerPolicyCleanupError extends PrintChildCleanupError {
40
+ constructor() { super("review policy worker cleanup unconfirmed; run ahub status and stop the peer before retrying"); }
41
+ }
36
42
 
37
43
  export interface ReviewerPrintUsage { input_tokens?: number; output_tokens?: number; cache_creation_input_tokens?: number; cache_read_input_tokens?: number }
38
44
  export interface ReviewerIsolationReceipt {
@@ -60,6 +66,8 @@ export interface ReviewerPrepareContext {
60
66
  /** Host-framed public review evidence, never configuration-selected argv. */
61
67
  input?: string;
62
68
  task?: number;
69
+ /** Captured host runtime override, absent leaves the backend's native default unchanged. */
70
+ modelOverride?: string;
63
71
  }
64
72
  export interface ReviewerLaunch {
65
73
  command: readonly string[];
@@ -74,6 +82,8 @@ export interface ReviewerLaunch {
74
82
  stop?: (child: ChildProcess) => Promise<void>;
75
83
  /** Latch a change in native policy for the running review, not just at spawn. */
76
84
  watchCurrent?: true;
85
+ /** Fixed host factory: periodic work is separate from the synchronous current() authority fence. */
86
+ startPolicyWatch?: () => ReviewerPolicyProbe;
77
87
  }
78
88
  export interface ReviewerBackend {
79
89
  id: string;
@@ -120,6 +130,8 @@ export interface ReviewerFailure {
120
130
 
121
131
  export interface ReviewerReviewBinding { readonly head?: string; readonly flowRevision?: number }
122
132
  export interface ReviewerPrintOptions {
133
+ /** Host-only choice sampled once at the next turn/review boundary. */
134
+ getModelOverride?: () => ModelOverrideSelection;
123
135
  cwd: string;
124
136
  command?: readonly string[];
125
137
  mcpConfig: string;
@@ -149,6 +161,7 @@ export interface ReviewerPrintOptions {
149
161
 
150
162
  type ReviewTarget = Omit<ReviewerFailure, "cause">;
151
163
  interface ReviewBatch { targets: (ReviewTarget | undefined)[]; next: number; failures: ReviewerFailure[] }
164
+ interface ReviewPolicyOwner { readonly child: OwnedPrintChild; readonly watch: ReviewerPolicyWatch; readonly cleanup: () => Promise<void>; readonly closed: () => void }
152
165
  const reviewTarget = (env: Envelope): ReviewTarget | undefined => {
153
166
  const task = reviewTask(env);
154
167
  if (task === undefined || env.private) return undefined;
@@ -170,6 +183,7 @@ export class ReviewerPrintPeer extends BasePeer {
170
183
  private child: OwnedPrintChild | undefined;
171
184
  private childCleanup: (() => Promise<void>) | undefined;
172
185
  private childCurrent: (() => boolean) | undefined;
186
+ private policyWatch: ReviewPolicyOwner | undefined;
173
187
  private reviewMcpClose: (() => void) | undefined;
174
188
  private activeDeliveryId: string | undefined;
175
189
  private batch: ReviewBatch | undefined;
@@ -221,6 +235,7 @@ export class ReviewerPrintPeer extends BasePeer {
221
235
  async start(): Promise<void> {
222
236
  // A retained child means a review's process group was never confirmed gone: no new review beside it.
223
237
  if (this.child) throw new Error(`${this.id}: an earlier review's process group is still running; stop it first`);
238
+ if (this.policyWatch) throw new ReviewerPolicyCleanupError();
224
239
  if (this.preparation) throw new Error(`${this.id}: reviewer preparation is still running`);
225
240
  this.receipt = undefined;
226
241
  const generation = ++this.generation;
@@ -242,8 +257,8 @@ export class ReviewerPrintPeer extends BasePeer {
242
257
  this.preparation?.abort.abort();
243
258
  await this.preparation?.done.catch(() => {});
244
259
  this.settle("needs_review", reason ?? "review stopped before settlement");
245
- if (this.child) this.setState("busy"); // unavailable, not offline, until the group is confirmed gone
246
- await this.kill(); // throws when the child could not be stopped; its handle is kept and the peer stays busy
260
+ if (this.child || this.policyWatch) this.setState("busy"); // unavailable until both owned resources are confirmed gone
261
+ await this.kill(true); // An explicit stop may retry an earlier failed worker join.
247
262
  this.setState("offline");
248
263
  }
249
264
 
@@ -319,7 +334,7 @@ export class ReviewerPrintPeer extends BasePeer {
319
334
  if (error instanceof PrintChildCleanupError) {
320
335
  // Unavailable until a stop confirms the group gone: no new review runs beside the survivors.
321
336
  this.opts.log?.(`[${this.id}] review of task #${task}: ${error.message}`);
322
- this.settle("needs_review", `review process group still running: ${error.message}`);
337
+ this.settle("needs_review", error instanceof ReviewerPolicyCleanupError ? error.message : `review process group still running: ${error.message}`);
323
338
  return;
324
339
  }
325
340
  // Once an earlier review in this batch ran, the batch had effects: hold it for a person, never requeue it.
@@ -376,20 +391,21 @@ export class ReviewerPrintPeer extends BasePeer {
376
391
  return reoffered;
377
392
  }
378
393
 
379
- private prepare(sessionId: string, input?: string, task?: number, mcpConfig?: string): Promise<ReviewerLaunch> {
394
+ private prepare(sessionId: string, input?: string, task?: number, mcpConfig?: string, modelOverride?: string): Promise<ReviewerLaunch> {
380
395
  const abort = new AbortController();
381
396
  const done = (async () => {
382
397
  if (this.opts.backend.taskMcp && !this.opts.taskMcp) throw new Error("task-bound reviewer MCP is unavailable");
383
398
  const launch = await this.opts.backend.prepare({ peer: this.id, cwd: this.opts.cwd, sessionId,
384
399
  mcpConfig: this.opts.backend.taskMcp ? (mcpConfig ?? '{"mcpServers":{}}') : this.opts.mcpConfig,
385
400
  systemPrompt: this.opts.systemPrompt, privatePaths: this.opts.privatePaths ?? [], command: this.opts.command, signal: abort.signal,
386
- ...(input === undefined ? {} : { input }), ...(task === undefined ? {} : { task }) });
401
+ ...(input === undefined ? {} : { input }), ...(task === undefined ? {} : { task }), ...(modelOverride === undefined ? {} : { modelOverride }) });
387
402
  if ("unavailable" in launch) throw new ReviewerUnavailableError(launch.unavailable, launch.causeClass ?? "unknown", launch.detail);
388
403
  const proof = launch.isolation;
389
404
  const policy = this.opts.backend.nativePolicy
390
405
  ? proof?.enforcement === "native-policy" && proof.freshSession === true && proof.hubToolsOnly === true
391
406
  : proof?.strictMcp === true && proof.ephemeral === true;
392
- if (abort.signal.aborted || !proof || proof.backend !== this.opts.backend.id || proof.readOnly !== true || !policy || !proof.binary || !proof.version || !proof.profile || !proof.evidence.length || !launch.command.length) {
407
+ if (abort.signal.aborted || !proof || proof.backend !== this.opts.backend.id || proof.readOnly !== true || !policy || !proof.binary || !proof.version || !proof.profile || !proof.evidence.length || !launch.command.length
408
+ || launch.watchCurrent && (!launch.current || !launch.startPolicyWatch)) {
393
409
  await launch.cleanup?.();
394
410
  throw new ReviewerUnavailableError("reviewer isolation is unavailable or preparation was cancelled", "unknown");
395
411
  }
@@ -410,6 +426,8 @@ export class ReviewerPrintPeer extends BasePeer {
410
426
  const revision=Number(env.refs?.reviewRevision);
411
427
  const binding: ReviewerReviewBinding=Object.freeze({head:env.refs?.commit,flowRevision:Number.isSafeInteger(revision)&&revision>0?revision:undefined});
412
428
  this.assertAdmission(env, task, binding);
429
+ const selection = this.opts.getModelOverride?.();
430
+ if (selection?.model !== undefined && !isNativeModelToken(selection.model)) throw new ReviewerAdmissionError("policy_changed");
413
431
  let facts: string | undefined;
414
432
  try { facts = this.opts.facts?.(env); } catch (error) { this.opts.log?.(`[${this.id}] review facts for task #${task} failed: ${(error as Error).message}`); }
415
433
  const input = `${frame(env)}${facts ? `\n\n${facts}` : ""}`;
@@ -433,10 +451,18 @@ export class ReviewerPrintPeer extends BasePeer {
433
451
  this.reviewMcpClose=closeMcp;
434
452
  let ownedChild: OwnedPrintChild | undefined, releasing: Promise<void> | undefined;
435
453
  let inputReleased = false, childClosed = false, launchCleanupConfirmed = false;
436
- let policyTimer: ReturnType<typeof setInterval> | undefined;
454
+ let inputFinished = false;
455
+ const finishInput = () => {
456
+ const closedWithoutChild = !ownedChild || ownedChild.process.pid === undefined;
457
+ if (!inputFinished && mcpCloseConfirmed && launchCleanupConfirmed && (closedWithoutChild || inputReleased && childClosed)) {
458
+ inputFinished = true;
459
+ try { this.opts.inputFinished?.(inputContext, [env]); } catch { /* A cleanup receipt never changes the verdict. */ }
460
+ }
461
+ };
462
+ let policyOwner: ReviewPolicyOwner | undefined;
437
463
  const release = () => releasing ??= Promise.resolve().then(() => launch?.cleanup?.()).then(() => {});
438
464
  try {
439
- launch = await this.prepare(sessionId, input, task, mcp?.config);
465
+ launch = await this.prepare(sessionId, input, task, mcp?.config, selection?.model);
440
466
  if (generation !== this.generation) throw new ReviewerAdmissionError("stale_binding");
441
467
  this.assertAdmission(env, task, binding);
442
468
  if (launch.current?.() === false) throw new ReviewerUnavailableError("reviewer isolation changed before child spawn", "binary");
@@ -448,9 +474,24 @@ export class ReviewerPrintPeer extends BasePeer {
448
474
  onInputReleased: () => { inputReleased = true; this.opts.inputReleased?.(inputContext, [env]); },
449
475
  onError: () => { if (this.child === child) this.child = undefined; },
450
476
  onSpawn: () => {
477
+ try { selection?.applied(); } catch { /* Native spawn remains authoritative. */ }
451
478
  this.runningBinding={task,binding};
452
479
  this.reviewing = task; this.reviewSeq++;
453
- if (this.childCurrent) policyTimer = setInterval(() => { this.reviewToken(task); }, 100);
480
+ if (launch!.watchCurrent) {
481
+ try {
482
+ const sequence = this.reviewSeq, probe = launch!.startPolicyWatch!();
483
+ const watch = createReviewerPolicyWatch(probe, () => {
484
+ // Batch generation alone is insufficient: later reviews can reuse its task and generation.
485
+ if (this.policyWatch === policyOwner && this.child === child && generation === this.generation
486
+ && this.reviewing === task && this.reviewSeq === sequence) this.abortReview("review policy changed");
487
+ });
488
+ policyOwner = { child, watch, cleanup: release, closed: () => {
489
+ childClosed = true; launchCleanupConfirmed = true; finishInput();
490
+ } };
491
+ this.policyWatch = policyOwner;
492
+ watch.start();
493
+ } catch { this.abortReview("review policy changed"); }
494
+ }
454
495
  },
455
496
  onFrame: event => {
456
497
  try {
@@ -465,6 +506,7 @@ export class ReviewerPrintPeer extends BasePeer {
465
506
  onLimit: () => this.abortReview("review output limit exceeded"),
466
507
  onLeaderExit: () => {
467
508
  if (this.child === child) { this.reviewing = undefined; this.runningBinding=undefined; }
509
+ void this.stopPolicyWatch(policyOwner).catch(() => {});
468
510
  },
469
511
  });
470
512
  // Owned before any await, so a stop that lands before "spawn" still finds and kills it.
@@ -478,16 +520,16 @@ export class ReviewerPrintPeer extends BasePeer {
478
520
  } finally {
479
521
  if (this.activeTrace?.value === trace) this.activeTrace = undefined;
480
522
  closeMcp();
481
- if (policyTimer) clearInterval(policyTimer);
523
+ // Joining the watcher precedes deletion of files it can still read, even when native cleanup failed.
524
+ await this.stopPolicyWatch(policyOwner);
482
525
  try {
483
526
  if (!ownedChild || this.child !== ownedChild) {
484
527
  await release(); if (this.childCleanup === release) this.childCleanup = undefined;
485
528
  launchCleanupConfirmed = true;
529
+ if (this.policyWatch === policyOwner) this.policyWatch = undefined;
486
530
  }
487
531
  } catch { this.opts.log?.(`[${this.id}] reviewer isolation cleanup incomplete`); }
488
- const closedWithoutChild = !ownedChild || ownedChild.process.pid === undefined;
489
- if (mcpCloseConfirmed && launchCleanupConfirmed && (closedWithoutChild || inputReleased && childClosed))
490
- try { this.opts.inputFinished?.(inputContext, [env]); } catch { /* receipt cleanup never changes the review verdict */ }
532
+ finishInput();
491
533
  }
492
534
  }
493
535
 
@@ -498,13 +540,33 @@ export class ReviewerPrintPeer extends BasePeer {
498
540
  }
499
541
 
500
542
  /** Revokes the verdict authority at once, then stops the child; on failure the handle is kept and the error thrown. */
501
- private async kill(): Promise<void> {
543
+ private async stopPolicyWatch(owner: ReviewPolicyOwner | undefined, retry = false): Promise<void> {
544
+ if (!owner) return;
545
+ try { await owner.watch.stop(retry); }
546
+ catch { throw new ReviewerPolicyCleanupError(); }
547
+ // Retain the owner until native teardown and private cleanup also succeed, so a failed stop can be retried.
548
+ }
549
+
550
+ private async kill(retryWorker = false): Promise<void> {
502
551
  this.reviewing = undefined;
503
552
  this.runningBinding=undefined;
504
- const child = this.child;
505
- if (!child) return;
506
- await child.stop();
507
- if (this.child === child) { await this.childCleanup?.(); this.child = undefined; this.childCleanup = undefined; this.childCurrent = undefined; }
553
+ const owner = this.policyWatch, child = this.child ?? owner?.child;
554
+ const cleanup = this.childCleanup ?? owner?.cleanup;
555
+ const workerStopped = this.stopPolicyWatch(owner, retryWorker);
556
+ void workerStopped.catch(() => {}); // Native OAuth grace may outlast a failed join; observe it immediately.
557
+ let childError: unknown, workerError: unknown;
558
+ try { await child?.stop(); } catch (error) { childError = error; }
559
+ try { await workerStopped; } catch (error) { workerError = error; }
560
+ if (workerError) throw workerError;
561
+ if (childError) throw childError;
562
+ if (child) {
563
+ await cleanup?.();
564
+ owner?.closed();
565
+ if (this.child === child) this.child = undefined;
566
+ if (this.childCleanup === cleanup) this.childCleanup = undefined;
567
+ if (this.policyWatch === owner) this.policyWatch = undefined;
568
+ if (!this.child && !this.policyWatch) this.childCurrent = undefined;
569
+ }
508
570
  }
509
571
  }
510
572
 
package/src/cli/agents.ts CHANGED
@@ -6,7 +6,7 @@ import { withoutOrcaEnv } from "../hub/orca-env.ts";
6
6
  /** #691: what `ahub agents` and `ahub doctor` show for each gated native CLI. */
7
7
  export interface AgentVersionRow { id: CompatId; executable?: string; verdict: CompatVerdict; policy: string }
8
8
 
9
- const PROBE_ENV = { JCODE_NO_TELEMETRY: "1", JCODE_DISABLE_CLAUDE_MCP: "1" };
9
+ const PROBE_ENV = { JCODE_NO_TELEMETRY: "1", JCODE_DISABLE_CLAUDE_MCP: "1", AGY_CLI_DISABLE_AUTO_UPDATE: "1" };
10
10
 
11
11
  /** The one place that decides what is run for an id: the configured argv (including pi.cmd) plus the probe words. */
12
12
  function probeTarget(id: CompatId, commands: Partial<Record<CompatId, readonly string[]>>): { executable: string; args: string[] } | undefined {
@@ -64,10 +64,12 @@ export async function factsHook(stdin: string, stateDir: string, peer: string, t
64
64
  if (Buffer.byteLength(stdin, "utf8") > 1024 * 1024) return undefined;
65
65
  const port = definitionOf(peer)?.hooks?.factsHook;
66
66
  const payload: unknown = JSON.parse(stdin);
67
- if (!port?.decode(nativeEvent, payload)) return undefined;
67
+ const decoded = port?.decode(nativeEvent, payload);
68
+ if (!port || !decoded) return undefined;
69
+ const forwarded = payload && typeof payload === "object" && !Array.isArray(payload) ? { ...payload, agentHubTranscriptPath: decoded.transcriptPath } : payload;
68
70
  const hub = await ControlClient.connect(stateDir, { role: "tools", peer, ...(nativeHookIdentity(stateDir, peer).nativeLaunchId ? { launchId: nativeHookIdentity(stateDir, peer).nativeLaunchId } : {}) }, timeoutMs);
69
71
  try {
70
- const reply = await hub.request({ t: "native_facts", event: nativeEvent, payload, ...nativeHookIdentity(stateDir, peer) }, timeoutMs);
72
+ const reply = await hub.request({ t: "native_facts", event: nativeEvent, payload: forwarded, ...nativeHookIdentity(stateDir, peer), startedMs: performance.now() }, timeoutMs);
71
73
  const output = reply.ok ? port.encode(nativeEvent, typeof reply.text === "string" ? reply.text : undefined) : undefined;
72
74
  return output ? JSON.stringify(output) : undefined;
73
75
  } finally { hub.close(); }
package/src/cli/help.ts CHANGED
@@ -79,6 +79,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
79
79
  ["ahub roster [--json] [--color=auto|always|never]", "every known peer, participation, start mode and retained work"],
80
80
  ["ahub roster add <peer> | remove <peer> [--yes] [--now]", "person or configured conductor: change live participation through settings; removal previews retained messages and task handoff; remove also deletes the roster key of an instance no longer declared"],
81
81
  ["ahub autonomy on [--all-projects] [--yes] | off [--all-projects] | status [--json]", "person only: apply the autonomy preset and machine default with one confirmation, optionally to every registered project; off removes fixed preset keys (manual edits included); status reports modes, floor enforcement and Claude homes; settings undo restores a live-hub project write"],
82
+ ["ahub model set <peer> <model> | clear <peer> | list [--json]", "person only: select a runtime model at the next turn, clear its override, or inspect effective models and pending changes; unsupported peers need a restart"],
82
83
  ["ahub settings [list] [--json] | get <key> [--json]", "the settings the dashboard can change, each with its value, the file it comes from and when it applies"],
83
84
  ["ahub settings set <key> <value|inherit> [--yes] [--preview] [--now] | undo | reload", "validated settings: conductor set is limited to exact live operational keys; undo/reload and other keys are person only; --preview saves nothing; conductor writes keep the person undo record"],
84
85
  ["ahub settings edit <config.local.json|sdlc.toml> (set <path> <json> | remove <path>)... [--reason <text>] [--preview] [--yes] | backups [--json]", "human only: structural config (instances, review, snapshots, approvals, context.gate, research.runners, sdlc.toml mode and required_checks) validated by the hub's own loaders, previewed as a diff and written with a backup; a key that runs code or locates secrets needs its name typed, never --yes"],
@@ -145,9 +146,9 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
145
146
  ["ahub usage limits [--peer <id>] [--limit <n>] [--json] [--color=auto|always|never]", "current quota windows and the history of pauses, resumes and resets"],
146
147
  ["ahub usage total [--json] [--color=auto|always|never]", "lifetime development tokens since the first event, per peer and overall"],
147
148
  ["ahub export [--since 7d|<iso>]", "structured events (events.jsonl) as JSON lines; never message bodies; the configured conductor gets a bounded projection (last 1000 events)"],
148
- ["ahub research [--since 30d] [--all] [--json]", "success, first-pass, rework and check-failure rates, tokens and wall time per approved task, from the opt-in research records"],
149
+ ["ahub research [--since 30d] [--all] [--json]", "success, first-pass, rework and check-failure rates, tokens and wall time per approved task (cancelled tasks have records too and are not counted), from the opt-in research records"],
149
150
  ["ahub research addons [--since 30d] [--all] [--json]", "approved tasks with each add-on (claude-mem, ponytail, ripwire, ...) on against off, with intervals; association, not cause"],
150
- ["ahub research export [--format jsonl|csv] [--since 30d] [--all]", "the research records for outside analysis: ids, counts and tokens, never text"],
151
+ ["ahub research export [--format jsonl|csv | --dataset <dir>] [--since 30d] [--all]", "the research records for outside analysis: ids, counts and tokens, never text; --dataset writes per-project tasks.jsonl, manifest.json and quality.json (family names only, with each field's unknown count, quota gaps and token attribution share) to a directory in your home outside every project"],
151
152
  ["ahub research backfill", "build this project's research records once from events.jsonl (person or configured conductor)"],
152
153
  ["ahub bench run <suite.json> --arm <label> [--repeat N] [--tasks a,b] [--addon <name>=on|off]... [--memory off|claude-mem|native|both]", "run a benchmark suite against the peers attached here, in a project kept for benchmarks (it resets the work tree; person or configured conductor); --addon and --memory declare the arm's add-on and memory state and refuse a setup that differs"],
153
154
  ["ahub bench list|status [--json]", "benchmark runs on this machine / the one running now"],
@@ -15,6 +15,7 @@ export const LIVE_TEST_PERSON_REFUSAL = "live-test cannot write person-attribute
15
15
 
16
16
  /** Person verdicts and user-authored workflow content are never delegated. */
17
17
  export function liveTestRefusal(command: string, args: readonly string[], context: { repairedFresh?: boolean } = {}): boolean {
18
+ if (command === "model") return true; // #1317: even listing runtime overrides is person-only, never live-test delegated.
18
19
  if (command === "sdlc" && (args[0] === "authorize-release" || args[0] === "plan-review" || args[0] === "release-assign" || args[0] === "merge" || args[0] === "train" && args[1] !== "report")) return true;
19
20
  if (args.some(arg => arg === "--as-user" || arg.startsWith("--as-user="))) return true;
20
21
  if (command === "pet" && (args.includes("--window") || args.includes("--palette"))) return false;
@@ -143,6 +144,7 @@ const isPeerReadForm = (command: string, args: readonly string[]): boolean => {
143
144
  * The branches above them are the older authority rules and stay unchanged.
144
145
  */
145
146
  export function classifyPeerCommand(command: string, args: readonly string[], catalog?: ProjectAdapterCatalog): PeerCommandAccess {
147
+ if (command === "model") return "console";
146
148
  if (command === "recovery" && recoveryAction(args) === "reattach") return "console";
147
149
  const native = catalog?.definitionOf(command) ?? definitionOf(command);
148
150
  if (native?.cli.personOnlyFlags?.some(flag => args.includes(flag))) return "console";
@@ -194,6 +196,7 @@ export function classifyPeerCommand(command: string, args: readonly string[], ca
194
196
 
195
197
  /** No argument text is included: notices and refusal logs must not copy task text or credentials. */
196
198
  export function cliCommandLabel(command: string, args: readonly string[], catalog?: ProjectAdapterCatalog): string {
199
+ if (command === "model") return ["list", "set", "clear"].includes(args[0] ?? "") ? `model ${args[0]}` : "model";
197
200
  if (command === "peer") return args[0] === "takeover" ? "peer takeover" : "peer";
198
201
  if (command === "backlog") return ["list", "add", "pull", "pull-ready", "split", "return", "plan", "pin", "undo"].includes(args[0] ?? "") ? `backlog ${args[0]}` : "backlog";
199
202
  if (command === "board" && args[0] === "import") return "board import";
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env bun
2
+ import { factsHook } from "./facts-hook.ts";
3
+
4
+ try {
5
+ const stateDir = process.env.AGENTHUB_STATE_DIR, event = process.argv[2], peer = process.env.AGENTHUB_PEER_ID;
6
+ const out = stateDir && event && peer ? await factsHook(await Bun.stdin.text(), stateDir, peer, 2000, event) : undefined;
7
+ if (out) console.log(out);
8
+ } catch {
9
+ // Native hooks fail open; a missing or refused observation never changes the turn.
10
+ }
11
+ process.exit(0);
@@ -0,0 +1,157 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { closeSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmdirSync, unlinkSync, writeFileSync, type Stats } from "node:fs";
3
+ import { dirname, isAbsolute, join } from "node:path";
4
+ import { processLiveness, processSignature } from "../pi/process-signature.ts";
5
+
6
+ export interface KimiPluginOptions { home: string; hookScript: string; launchId: string; replaceStale?: boolean; now?: number; ownerPid?: number; ownerSignature?: string; liveness?: typeof processLiveness }
7
+ type JsonObject = Record<string, any>;
8
+ type Snapshot = { stat: Stats; bytes: Buffer };
9
+ type Target = { path: string; next: Buffer; before?: Snapshot };
10
+ type Plan = { root: string; managed: string; installed: string; record?: JsonObject; targets: Target[]; changed: boolean };
11
+
12
+ const EVENTS = ["TurnStarted", "Stop", "StopFailure", "Interrupt", "SessionEnd", "TaskStarted", "Notification"] as const;
13
+ const ID = "agent-hub";
14
+ const MAX_INSTALLED = 1024 * 1024;
15
+ const MAX_HOOK = 8 * 1024 * 1024;
16
+ const object = (value: unknown): value is JsonObject => !!value && typeof value === "object" && !Array.isArray(value);
17
+ const directory = (path: string, required = true) => {
18
+ const stat = lstatSync(path, { throwIfNoEntry: false });
19
+ if (stat && (!stat.isDirectory() || stat.isSymbolicLink())) throw new Error(`${path} must be a plain directory`);
20
+ if (!stat && required) throw new Error(`${path} must exist`);
21
+ return stat;
22
+ };
23
+ function file(path: string, cap: number): Snapshot | undefined {
24
+ const stat = lstatSync(path, { throwIfNoEntry: false });
25
+ if (!stat) return undefined;
26
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > cap) throw new Error(`${path} must be a bounded regular file`);
27
+ return { stat, bytes: readFileSync(path) };
28
+ }
29
+ function parse(value: Snapshot | undefined, name: string): JsonObject | undefined {
30
+ if (!value) return undefined;
31
+ try {
32
+ const row = JSON.parse(value.bytes.toString("utf8"));
33
+ if (!object(row)) throw new Error("not an object");
34
+ return row;
35
+ } catch { throw new Error(`${name} is not valid JSON`); }
36
+ }
37
+ function atomic(path: string, bytes: Buffer, before: Stats | undefined): void {
38
+ const current = lstatSync(path, { throwIfNoEntry: false });
39
+ if (before ? !current || current.dev !== before.dev || current.ino !== before.ino : current) throw new Error(`${path} changed before its managed write`);
40
+ const temporary = `${path}.${randomUUID()}.tmp`;
41
+ let fd: number | undefined;
42
+ try {
43
+ fd = openSync(temporary, "wx", 0o600);
44
+ writeFileSync(fd, bytes);
45
+ closeSync(fd); fd = undefined;
46
+ renameSync(temporary, path);
47
+ } finally {
48
+ if (fd !== undefined) closeSync(fd);
49
+ try { unlinkSync(temporary); } catch { /* rename consumed it */ }
50
+ }
51
+ }
52
+ function removeFile(path: string, expected: Buffer): void {
53
+ const current = lstatSync(path, { throwIfNoEntry: false });
54
+ if (!current || !current.isFile() || current.isSymbolicLink() || current.nlink !== 1 || !readFileSync(path).equals(expected)) return;
55
+ unlinkSync(path);
56
+ }
57
+ function manifest(): JsonObject {
58
+ return { name: ID, version: "1.0.0", description: "Managed by the ahub kimi launcher for this launch only.", hooks: EVENTS.map(event => ({ event, command: `bun ./kimi-hook.js ${event}`, timeout: 5 })) };
59
+ }
60
+ function ownerProof(options: KimiPluginOptions): { pid: number; signature: string } {
61
+ const pid = options.ownerPid ?? process.pid, signature = options.ownerSignature ?? processSignature(pid);
62
+ if (!Number.isSafeInteger(pid) || pid <= 0 || !signature) throw new Error("Kimi plugin ownership cannot be proven");
63
+ return { pid, signature };
64
+ }
65
+ function staleOwner(record: JsonObject, options: KimiPluginOptions): boolean {
66
+ const owner = record.agentHubOwner;
67
+ if (!object(owner) || !Number.isSafeInteger(owner.pid) || owner.pid <= 0 || typeof owner.signature !== "string" || !owner.signature) return false;
68
+ try { return (options.liveness ?? processLiveness)(owner.pid, owner.signature) === "gone"; } catch { return false; }
69
+ }
70
+ function plan(options: KimiPluginOptions): Plan {
71
+ if (!isAbsolute(options.home) || !isAbsolute(options.hookScript) || !/^[A-Za-z0-9._:-]{1,128}$/.test(options.launchId)) throw new Error("Kimi home, hook bundle and launch identity must be valid");
72
+ directory(options.home);
73
+ const hook = file(options.hookScript, MAX_HOOK);
74
+ if (!hook) throw new Error("Kimi hook bundle is missing");
75
+ const root = join(options.home, "plugins"), managed = join(root, "managed", ID), installed = join(root, "installed.json");
76
+ const installedBefore = file(installed, MAX_INSTALLED), document = parse(installedBefore, installed);
77
+ if (document && (document.version !== 1 || !Array.isArray(document.plugins))) throw new Error(`${installed} has an unsupported shape`);
78
+ const records = document?.plugins ?? [];
79
+ if (records.some((row: unknown) => !object(row))) throw new Error(`${installed} has an invalid plugin row`);
80
+ const record = records.find((row: JsonObject) => row.id === ID);
81
+ if (record && record.agentHubLaunch !== options.launchId && options.replaceStale === true && !staleOwner(record, options)) throw new Error("Kimi's agent-hub plugin owner is active or unknown; it was not changed");
82
+ if (record && (record.root !== managed || record.source !== "local-path" || record.origin !== "custom" || record.enabled !== true || typeof record.originalSource !== "string"
83
+ || record.agentHubLaunch !== options.launchId && options.replaceStale !== true)) {
84
+ throw new Error("Kimi's agent-hub plugin belongs to another launch or is disabled; it was not changed");
85
+ }
86
+ const manifestBefore = file(join(managed, "kimi.plugin.json"), MAX_INSTALLED), hookBefore = file(join(managed, "kimi-hook.js"), MAX_HOOK);
87
+ const nextManifest = Buffer.from(`${JSON.stringify(manifest(), null, 2)}\n`);
88
+ const now = new Date(options.now ?? Date.now()).toISOString();
89
+ const changed = !record || record.agentHubLaunch !== options.launchId || !record.agentHubOwner || !manifestBefore?.bytes.equals(nextManifest) || !hookBefore?.bytes.equals(hook.bytes) || record.originalSource !== dirname(options.hookScript);
90
+ const nextRecord = {
91
+ id: ID, root: managed, source: "local-path", origin: "custom", enabled: true,
92
+ installedAt: typeof record?.installedAt === "string" ? record.installedAt : now,
93
+ updatedAt: changed ? now : typeof record?.updatedAt === "string" ? record.updatedAt : now,
94
+ originalSource: dirname(options.hookScript), agentHubLaunch: options.launchId,
95
+ agentHubOwner: record && record.agentHubLaunch === options.launchId && record.agentHubOwner ? record.agentHubOwner : ownerProof(options),
96
+ };
97
+ const nextDocument = { version: 1, plugins: [...records.filter((row: JsonObject) => row.id !== ID), nextRecord] };
98
+ return { root, managed, installed, ...(record ? { record } : {}), changed,
99
+ targets: [
100
+ { path: join(managed, "kimi.plugin.json"), next: nextManifest, ...(manifestBefore ? { before: manifestBefore } : {}) },
101
+ { path: join(managed, "kimi-hook.js"), next: hook.bytes, ...(hookBefore ? { before: hookBefore } : {}) },
102
+ { path: installed, next: Buffer.from(`${JSON.stringify(nextDocument, null, 2)}\n`), ...(installedBefore ? { before: installedBefore } : {}) },
103
+ ] };
104
+ }
105
+
106
+ export function kimiPluginPreview(home: string, hookScript: string): { file: string; changed: true; action: string } {
107
+ if (!isAbsolute(home) || !isAbsolute(hookScript)) throw new Error("Kimi home and hook bundle must be absolute paths");
108
+ directory(home, false);
109
+ if (!file(hookScript, MAX_HOOK)) throw new Error("Kimi hook bundle is missing");
110
+ return { file: join(home, "plugins", "managed", ID), changed: true, action: "only after Kimi 2.5.2 is confirmed, install the launch-scoped completion plugin and remove it on exit; other versions keep the existing TUI with no plugin" };
111
+ }
112
+
113
+ export function kimiPluginPlan(options: KimiPluginOptions): { file: string; changed: boolean; action: string } {
114
+ const current = plan(options);
115
+ return { file: current.managed, changed: current.changed, action: current.changed ? "create or update the launch-scoped Kimi plugin" : "keep the launch-scoped Kimi plugin" };
116
+ }
117
+
118
+ function locked<T>(options: KimiPluginOptions, run: () => T): T {
119
+ if (!isAbsolute(options.home)) throw new Error("Kimi home must be an absolute path");
120
+ directory(options.home);
121
+ const path = join(options.home, "agent-hub-plugin.lock");
122
+ let fd: number;
123
+ try { fd = openSync(path, "wx", 0o600); }
124
+ catch { throw new Error(`Another Kimi plugin replacement is active or left a stale lock; retry after it exits or inspect ${path}`); }
125
+ try { return run(); }
126
+ finally { closeSync(fd); unlinkSync(path); }
127
+ }
128
+
129
+ export function ensureKimiPlugin(options: KimiPluginOptions): { changed: boolean } {
130
+ return locked(options, () => {
131
+ const current = plan(options);
132
+ if (!current.changed) return { changed: false };
133
+ for (const path of [join(options.home, "plugins"), join(options.home, "plugins", "managed"), current.managed]) {
134
+ if (!directory(path, false)) mkdirSync(path, { mode: 0o700 });
135
+ }
136
+ for (const target of current.targets) atomic(target.path, target.next, target.before?.stat);
137
+ return { changed: true };
138
+ });
139
+ }
140
+
141
+ export function cleanupKimiPlugin(options: KimiPluginOptions): void {
142
+ locked(options, () => {
143
+ const current = plan({ ...options, replaceStale: false });
144
+ const installedBefore = file(current.installed, MAX_INSTALLED), document = parse(installedBefore, current.installed);
145
+ const records = document?.plugins;
146
+ if (!document || !Array.isArray(records) || records.some((row: unknown) => !object(row))) throw new Error(`${current.installed} changed during the launch`);
147
+ const record = records.find((row: JsonObject) => row.id === ID);
148
+ if (!record) return;
149
+ if (record.root !== current.managed || record.enabled !== true) throw new Error("Kimi's agent-hub plugin changed during the launch; leaving it installed");
150
+ const next = { version: 1, plugins: records.filter((row: JsonObject) => row.id !== ID) };
151
+ atomic(current.installed, Buffer.from(`${JSON.stringify(next, null, 2)}\n`), installedBefore?.stat);
152
+ const [manifestTarget, hookTarget] = current.targets;
153
+ removeFile(join(current.managed, "kimi.plugin.json"), manifestTarget!.next);
154
+ removeFile(join(current.managed, "kimi-hook.js"), hookTarget!.next);
155
+ try { rmdirSync(current.managed); } catch { /* foreign or changed files stay */ }
156
+ });
157
+ }
package/src/cli/main.ts CHANGED
@@ -70,6 +70,8 @@ import { loadConfig } from "../hub/daemon.ts";
70
70
  import { factsHook } from "./facts-hook.ts";
71
71
  import { hubHome, projectContext, projectRoot, realPath } from "../hub/project.ts";
72
72
  import { Registry, type Project } from "../hub/registry.ts";
73
+ import { datasetInputFor, exportResearchDataset, projectFamilyOf } from "../hub/research-dataset.ts";
74
+ import { readEvidence } from "../hub/research-record.ts";
73
75
  import { inspectProject, startProject, stopProject, runProjectDaemon } from "../hub/lifecycle.ts";
74
76
  import { processLiveness, processSignature } from "../pi/process-signature.ts";
75
77
  import { openManager, startManager, stopManager } from "../hub/manager.ts";
@@ -114,7 +116,7 @@ import { renderTidyPreview, renderTidyResult, renderWorktrees } from "./worktree
114
116
  import { formatMemoryStaleAnchors } from "../hub/memory-stale-anchors.ts";
115
117
  import { AGENT_MEM_VERSION, assertAgentMemVersion, selectedMemoryProfile } from "../hub/memory-profile.ts";
116
118
  import { checkMemoryProfiles, formatMemoryProfileCheck } from "../hub/memory-profile-check.ts";
117
- import { addonReport, appendRecords, formatAddons, formatResearch, LABELS, labelTarget, RESEARCH_SCHEMA, projectKey, readStores, researchReport, taskRecords, toCsv, type Label, type TaskRecord } from "../hub/research.ts";
119
+ import { addonReport, appendRecords, closedAtOf, formatAddons, formatResearch, LABELS, labelTarget, RESEARCH_SCHEMA, projectKey, readStores, researchReport, taskRecords, toCsv, type Label, type TaskRecord } from "../hub/research.ts";
118
120
  import { summarize, summarizeByTask } from "../hub/report.ts";
119
121
  import { readDogfood } from "../hub/dogfood-read.ts";
120
122
  import { dogfoodSince, formatDogfood } from "../hub/dogfood.ts";
@@ -126,7 +128,7 @@ import { agentShellCause, classifyPeerCommand, cliCommandLabel, detectCliIdentit
126
128
  import { recordCliAudit, recordDelegatedCliAudit } from "./identity-audit.ts";
127
129
  import { runConsole } from "./console.ts";
128
130
  import { paint, permissionBoundary, resolveColor, wrap, type Span } from "./console-state.ts";
129
- import { outputWidth, renderRoster, renderStatus, renderBoard, renderBacklog, renderBoardColumns, boardColumnModel, renderBudget, renderDoctor, renderProjects, renderQueue, renderTurns, renderOrphans, renderOrphanCleanup, renderQueueShow, renderModelsStatus, renderExecutionBudgetStatus, renderReport, renderUsage, renderLimits, renderTotal, type DoctorCheck } from "./output.ts";
131
+ import { outputWidth, renderRuntimeModels, renderRoster, renderStatus, renderBoard, renderBacklog, renderBoardColumns, boardColumnModel, renderBudget, renderDoctor, renderProjects, renderQueue, renderTurns, renderOrphans, renderOrphanCleanup, renderQueueShow, renderModelsStatus, renderExecutionBudgetStatus, renderReport, renderUsage, renderLimits, renderTotal, type DoctorCheck } from "./output.ts";
130
132
  import { renderHelp, configuredAcpHelp } from "./help.ts";
131
133
  import { renderBacklogResult, renderGroom, renderReleaseChange, renderReleaseList, renderReleaseNew, renderReleaseShow, renderTaskCancel, renderTaskShow } from "./task-output.ts";
132
134
  import { renderAuthorization, renderSdlcCosts, renderSdlcExplain, renderSdlcMerge, renderSdlcMetrics, renderSdlcQueue, renderSdlcRelease, renderSdlcStatus } from "./sdlc-output.ts";
@@ -851,11 +853,14 @@ function benchRunArgs(rest: string[]) {
851
853
  }
852
854
  /** Another project is `ahub --project <dir> research`; anything unknown is refused rather than ignored. */
853
855
  function researchArgs(): void {
854
- const sub = args[0], flags = new Set(["--all", "--json", "--since", "--format"]), valued = new Set(["--since", "--format"]);
856
+ const sub = args[0], flags = new Set(["--all", "--json", "--since", "--format", "--dataset"]), valued = new Set(["--since", "--format", "--dataset"]);
855
857
  for (let i = sub === "export" || sub === "backfill" || sub === "addons" ? 1 : 0; i < args.length; i++) {
856
858
  if (!flags.has(args[i]!)) fail(`ahub research: unknown argument ${args[i]} (another project: ahub --project <dir> research)`);
857
859
  if (valued.has(args[i]!)) i++;
858
860
  }
861
+ if (args.includes("--dataset") && (sub !== "export" || args.includes("--format"))) fail("ahub research: --dataset belongs to export and replaces --format (usage: ahub research export --dataset <dir> [--since] [--all])");
862
+ const at = args.indexOf("--dataset");
863
+ if (at >= 0 && (args[at + 1] === undefined || args[at + 1]!.startsWith("--"))) fail("ahub research: --dataset takes a directory");
859
864
  }
860
865
  const SOURCE_USAGE = "usage: ahub source add <path|url> [--json] [--color=auto|always|never] | list [--json] [--color=auto|always|never]";
861
866
  /** #933: a local conductor form's argument check, run before the `admit` handshake so a usage error leaves no admitted row. */
@@ -1179,7 +1184,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1179
1184
  up: async () => {
1180
1185
  const unattended = unattendedEnv || args.includes("--unattended");
1181
1186
  if (unattended) console.error(UNATTENDED_WARNING);
1182
- const status = await startProject(registeredProject(), { unattended, liveTest, ...(recoveryLauncherCurrent ? { current: recoveryLauncherCurrent, env: { ...personEnv(process.env), AGENTHUB_RECOVERY_OPERATION: continuationId, AGENTHUB_PEER_ID: continuationPeer, AGENTHUB_RECOVERY_CONDUCTOR: continuationPeer, AGENTHUB_RECOVERY_CONTINUATION: continuationId } } : {}) });
1187
+ const status = await startProject(registeredProject(), { unattended, liveTest, starting: pid => console.error(`ahub: hub is still starting, pid ${pid}; waiting for readiness, run ahub status in another terminal to inspect progress`), ...(recoveryLauncherCurrent ? { current: recoveryLauncherCurrent, env: { ...personEnv(process.env), AGENTHUB_RECOVERY_OPERATION: continuationId, AGENTHUB_PEER_ID: continuationPeer, AGENTHUB_RECOVERY_CONDUCTOR: continuationPeer, AGENTHUB_RECOVERY_CONTINUATION: continuationId } } : {}) });
1183
1188
  console.log(`ahub up (ws://127.0.0.1:${status.controlPort}), state in ${stateDir}`);
1184
1189
  if (recoveryLauncherCurrent) { recoveryLauncherCurrent(); return; }
1185
1190
  // #608: the default agent set, only for a person's own `ahub up` (never an agent's, a live test's, or an upgrade or recovery relaunch, which set AGENTHUB_RECOVERY_OPERATION).
@@ -1220,6 +1225,26 @@ const commands: Record<string, () => Promise<void> | void> = {
1220
1225
  return entry.execute(context);
1221
1226
  }])),
1222
1227
 
1228
+ model: async () => {
1229
+ const { parseModelCommand, projectModelStatus } = await import("./model.ts");
1230
+ let command;
1231
+ try { command = parseModelCommand(args); } catch (error) { return fail((error as Error).message); }
1232
+ const hub = await connect();
1233
+ try {
1234
+ if (!hub.features.includes("model_overrides")) return fail("running hub lacks model_overrides support; upgrade and restart the hub, then retry ahub model list");
1235
+ const { json, ...message } = command;
1236
+ const result = await hub.request({ t: "model", ...message });
1237
+ if (result.ok !== true) return fail(String(result.error ?? "model request refused; upgrade and restart the hub before retrying"), result.refusalAudited === true, result.authorityRefused);
1238
+ if (command.op !== "list" && result.appliesFrom !== "next_turn") return fail("model change is unconfirmed; inspect ahub model list before retrying");
1239
+ const values = command.op === "list" ? result.models : [result.model];
1240
+ if (!Array.isArray(values) || values.length > 512) return fail("model reply is not a supported bounded row list; upgrade and restart the hub, then retry ahub model list");
1241
+ const models = values.map(projectModelStatus);
1242
+ if (models.some(row => row === null)) return fail("model reply invalid; upgrade and restart the hub, then retry ahub model list");
1243
+ const options = { json };
1244
+ if (options.json) console.log(JSON.stringify({ models }, null, 2));
1245
+ else printOutput(renderRuntimeModels(models.filter((row): row is NonNullable<typeof row> => row !== null)), false);
1246
+ } finally { hub.close(); }
1247
+ },
1223
1248
  models: async () => {
1224
1249
  const action = outputArguments()[0] ?? "status";
1225
1250
  const options = ["status", "start", "setup"].includes(action) ? outputOptions() : undefined;
@@ -2526,17 +2551,31 @@ const commands: Record<string, () => Promise<void> | void> = {
2526
2551
  const [sub] = args;
2527
2552
  researchArgs();
2528
2553
  if (sub === "backfill") {
2529
- const project = researchProject();
2530
- const written = appendRecords(project.id, taskRecords(readEvents(join(stateDir, "events.jsonl")), project.id, { version: VERSION, source: "backfill" }));
2554
+ const project = researchProject(), familyOf = projectFamilyOf(cwd);
2555
+ const written = appendRecords(project.id, taskRecords(readEvents(join(stateDir, "events.jsonl")), project.id, { version: VERSION, source: "backfill" }, { ...readEvidence(stateDir, project.id), ...(familyOf ? { familyOf } : {}) }));
2531
2556
  return console.log(`${written} research record${written === 1 ? "" : "s"} written from this project's events`);
2532
2557
  }
2533
2558
  const all = args.includes("--all");
2534
- const records = readStores(all ? undefined : researchProject(false).id);
2535
2559
  const from = since();
2560
+ if (sub === "export" && args.includes("--dataset")) {
2561
+ // #1277: the dataset is read-only over the stores; a project that was never researched has no file and no directory. The target is refused inside any registered project.
2562
+ const registry = new Registry();
2563
+ let registered: Project[];
2564
+ try { registered = registry.list(); } finally { registry.close(); }
2565
+ const here = researchProject(false), chosen = all ? registered : [here];
2566
+ const inputs = chosen.flatMap(p => { const input = datasetInputFor(p); return input ? [input] : []; });
2567
+ if (!inputs.length) return fail(all ? "no registered project has research records" : "this project has no research records; turn research on and run ahub research backfill");
2568
+ try {
2569
+ const written = exportResearchDataset(args[args.indexOf("--dataset") + 1]!, inputs, { ...(from > 0 ? { since: from } : {}) }, [...registered, here]);
2570
+ for (const w of written) console.log(`${w.projectKeyHash}: ${w.counts.tasks} task${w.counts.tasks === 1 ? "" : "s"} (${w.counts.approved} approved, ${w.counts.cancelled} cancelled), ${w.counts.labels} label${w.counts.labels === 1 ? "" : "s"} -> ${w.dir}`);
2571
+ } catch (error) { return fail((error as Error).message); }
2572
+ return;
2573
+ }
2574
+ const records = readStores(all ? undefined : researchProject(false).id);
2536
2575
  if (sub === "export") {
2537
2576
  const format = args[args.indexOf("--format") + 1] ?? "jsonl";
2538
2577
  if (args.includes("--format") && !["jsonl", "csv"].includes(format)) fail("--format takes jsonl or csv");
2539
- const kept = records.filter((r) => r.kind === "label" || Date.parse(r.approvedAt) >= from);
2578
+ const kept = records.filter((r) => r.kind === "label" || Date.parse(closedAtOf(r) ?? "") >= from);
2540
2579
  return void process.stdout.write(args.includes("--format") && format === "csv" ? toCsv(kept) : kept.map((r) => JSON.stringify(r)).join("\n") + (kept.length ? "\n" : ""));
2541
2580
  }
2542
2581
  if (sub === "addons") {
@@ -2544,7 +2583,7 @@ const commands: Record<string, () => Promise<void> | void> = {
2544
2583
  const rows = addonReport(records, from);
2545
2584
  return console.log(args.includes("--json") ? JSON.stringify(rows, null, 2) : formatAddons(rows).join("\n"));
2546
2585
  }
2547
- if (sub !== undefined && !sub.startsWith("--")) fail("usage: ahub research [--since 30d] [--all] [--json] | addons [--since] [--all] [--json] | export [--format jsonl|csv] [--since] [--all] | backfill");
2586
+ if (sub !== undefined && !sub.startsWith("--")) fail("usage: ahub research [--since 30d] [--all] [--json] | addons [--since] [--all] [--json] | export [--format jsonl|csv | --dataset <dir>] [--since] [--all] | backfill");
2548
2587
  const r = researchReport(records, from);
2549
2588
  console.log(args.includes("--json") ? JSON.stringify(r, null, 2) : formatResearch(r).join("\n"));
2550
2589
  },