@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.
- package/CHANGELOG.md +51 -0
- package/README.md +35 -6
- package/docs/adding-an-ai-tool.md +2 -2
- package/docs/addons/claude-mem.md +4 -4
- package/docs/addons/open-code-review.md +1 -1
- package/docs/agent-notes/README.md +6 -0
- package/docs/agent-notes/adapters.md +9 -5
- package/docs/agent-notes/budget.md +1 -1
- package/docs/agent-notes/bus.md +1 -1
- package/docs/agent-notes/daemon.md +4 -3
- package/docs/agent-notes/local-worker.md +1 -1
- package/docs/agent-notes/models.md +2 -0
- package/docs/agent-notes/tasks.md +1 -1
- package/docs/agent-notes/tests.md +5 -0
- package/docs/design/adapters.md +110 -2
- package/docs/design/agent-hub-architecture.md +63 -1
- package/docs/design/agent-hub.md +6 -0
- package/docs/design/bus.md +18 -0
- package/docs/design/execution-reference.md +146 -0
- package/docs/design/task-board.md +10 -0
- package/docs/dogfood.md +33 -12
- package/docs/events.md +38 -20
- package/docs/memory.md +21 -7
- package/docs/operations.md +539 -24
- package/docs/quickstart.md +18 -4
- package/docs/research.md +71 -13
- package/docs/roles.md +1 -1
- package/docs/sdlc.md +9 -7
- package/docs/security.md +87 -11
- package/docs/smoke.md +14 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +8 -3
- package/docs/verification/2026-11-07-0.34.1.md +54 -0
- package/docs/verification/2026-11-07-compat-python-startup-1248.md +51 -0
- package/docs/verification/2026-11-07-compat-receipt-harness-1248.md +105 -0
- package/docs/verification/2026-11-07-compat-receipt-producers-1248.md +96 -0
- package/docs/verification/2026-11-07-compat-workflow-1248.md +94 -0
- package/docs/verification/2026-11-07-daemon-startup-1232.md +169 -0
- package/docs/verification/2026-11-07-jcode-receipt-reconstruction-1248.md +118 -0
- package/docs/verification/2026-11-07-kimi-delayed-usage.md +34 -0
- package/docs/verification/2026-11-07-kimi-review-watch-1284.md +115 -0
- package/docs/verification/2026-11-08-agy-1.5.3.md +96 -0
- package/docs/verification/2026-11-08-agy-acp-t0.md +56 -0
- package/docs/verification/2026-11-08-hub-visual-1058.md +161 -0
- package/docs/verification/2026-11-08-jcode-0991-model-setter-1317.md +143 -0
- package/docs/verification/2026-11-08-kimi-252-model-setter-1317.md +137 -0
- package/docs/verification/2026-11-08-kimi-tui-completion-1282.md +45 -0
- package/docs/verification/2026-11-08-kimi-wire-yield-1283.md +31 -0
- package/docs/verification/2026-11-08-live-model-override-1317.md +250 -0
- package/docs/verification/2026-11-08-qwen-0269-acp.md +138 -0
- package/docs/verification/2026-11-08-qwen-0269-model-setter-1317.md +91 -0
- package/docs/verification/2026-11-09-0.35.0.md +129 -0
- package/docs/verification/2026-11-09-headless-records-1320.md +157 -0
- package/docs/verification/claude-permission-transport-native.py +10 -3
- package/docs/verification/claude-shell-wrapper-native.py +8 -8
- package/docs/verified.json +245 -15
- package/package.json +3 -3
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/kimi-hook.js +20397 -0
- package/plugins/agent-hub/kimi-review-policy-worker.js +217 -0
- package/plugins/agent-hub/server.js +2701 -664
- package/scripts/check-docs.mjs +12 -1
- package/src/adapters/acp-model-policy.ts +56 -0
- package/src/adapters/acp.ts +175 -5
- package/src/adapters/agy/entry.ts +8 -5
- package/src/adapters/agy/hooks.ts +65 -15
- package/src/adapters/channel-peer.ts +33 -1
- package/src/adapters/claude-implementer.ts +16 -3
- package/src/adapters/codex-appserver.ts +118 -8
- package/src/adapters/codex-review-isolation.ts +3 -1
- package/src/adapters/compat.ts +11 -6
- package/src/adapters/definitions/builtin-metadata.ts +8 -1
- package/src/adapters/definitions/native-cli-entry-kimi.ts +76 -7
- package/src/adapters/definitions/native-cli-preview-common.ts +2 -2
- package/src/adapters/definitions/native-cli-preview-kimi.ts +9 -2
- package/src/adapters/definitions/reviewer-variants.ts +3 -1
- package/src/adapters/kimi-review-policy-worker.js +2 -0
- package/src/adapters/kimi-review-policy-worker.ts +85 -0
- package/src/adapters/kimi-review-policy.ts +68 -0
- package/src/adapters/kimi-review.ts +41 -41
- package/src/adapters/kimi-tui-facts.ts +153 -0
- package/src/adapters/kimi-wire-usage.ts +33 -8
- package/src/adapters/local-worker.ts +59 -20
- package/src/adapters/model-token.ts +3 -0
- package/src/adapters/pi.ts +27 -5
- package/src/adapters/registry-types.ts +34 -3
- package/src/adapters/registry.ts +1 -0
- package/src/adapters/reviewer-policy-watch.ts +86 -0
- package/src/adapters/reviewer-print.ts +80 -18
- package/src/cli/agents.ts +1 -1
- package/src/cli/facts-hook.ts +4 -2
- package/src/cli/help.ts +3 -2
- package/src/cli/identity.ts +3 -0
- package/src/cli/kimi-facts-hook.ts +11 -0
- package/src/cli/kimi-plugin.ts +157 -0
- package/src/cli/main.ts +48 -9
- package/src/cli/model.ts +52 -0
- package/src/cli/output.ts +23 -1
- package/src/cli/recovery-package.ts +15 -7
- package/src/cli/reset.ts +1 -1
- package/src/cli/status-lines.ts +8 -4
- package/src/hub/budget.ts +1 -1
- package/src/hub/bus.ts +40 -2
- package/src/hub/code-facts.ts +10 -3
- package/src/hub/completions.ts +3 -1
- package/src/hub/config-apply.ts +2 -1
- package/src/hub/control-client.ts +1 -1
- package/src/hub/daemon.ts +1050 -106
- package/src/hub/deliverable-files.ts +38 -5
- package/src/hub/dogfood-profile.ts +1 -1
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +28 -18
- package/src/hub/execution-lineage.ts +120 -0
- package/src/hub/execution-reference.ts +36 -0
- package/src/hub/hub-tools.ts +21 -2
- package/src/hub/instances.ts +4 -3
- package/src/hub/kimi-events.ts +46 -0
- package/src/hub/kimi-usage.ts +132 -29
- package/src/hub/lifecycle.ts +31 -15
- package/src/hub/limits.ts +13 -4
- package/src/hub/manager.ts +15 -2
- package/src/hub/memory-policy.ts +2 -1
- package/src/hub/memory-sync-attestation.ts +54 -0
- package/src/hub/memory-sync-state.ts +20 -5
- package/src/hub/model-overrides.ts +115 -0
- package/src/hub/native-hook-launch.ts +12 -3
- package/src/hub/operational-view.ts +2 -2
- package/src/hub/peers.ts +27 -0
- package/src/hub/permission-mode.ts +1 -1
- package/src/hub/person-figures.ts +88 -0
- package/src/hub/report.ts +38 -4
- package/src/hub/research-dataset.ts +158 -0
- package/src/hub/research-memory.ts +68 -0
- package/src/hub/research-quality.ts +118 -0
- package/src/hub/research-record.ts +279 -0
- package/src/hub/research.ts +126 -27
- package/src/hub/restart.ts +14 -0
- package/src/hub/settings.ts +3 -0
- package/src/hub/startup-event-seeds.ts +51 -0
- package/src/hub/startup-readiness.ts +65 -0
- package/src/hub/startup-timing.ts +38 -0
- package/src/hub/tasks.ts +29 -9
- package/src/hub/ui.ts +9 -5
- package/src/hub/visual-assets.ts +50 -0
- package/src/hub/visual.ts +257 -0
- package/src/memory/brief.ts +169 -14
- package/src/memory/capture.ts +2 -1
- package/src/memory/recall.ts +46 -5
- package/src/memory/record-provenance.ts +82 -0
- package/src/memory/records.ts +116 -0
- package/src/models/route/adec.ts +190 -0
- package/src/models/route/config.ts +37 -3
- package/src/models/route/decision-evidence.ts +83 -0
- package/src/models/route/decision.ts +143 -0
- package/src/models/route/runtime.ts +13 -6
- package/src/ui/index.html +134 -6
- package/src/ui/plugin.ts +65 -4
- package/templates/AGENTS.block.md +1 -1
- package/templates/config.json +1 -1
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +1 -1
- 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
|
|
246
|
-
await this.kill(); //
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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 {
|
package/src/cli/facts-hook.ts
CHANGED
|
@@ -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
|
-
|
|
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"],
|
package/src/cli/identity.ts
CHANGED
|
@@ -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
|
|
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
|
},
|