@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
package/scripts/check-docs.mjs
CHANGED
|
@@ -83,7 +83,18 @@ try {
|
|
|
83
83
|
if (notes.status !== 0) throw new Error("committed agent notes unavailable");
|
|
84
84
|
noteNames = notes.stdout.split("\0").filter(Boolean);
|
|
85
85
|
} else noteNames = readdirSync(join(root, "docs/agent-notes"));
|
|
86
|
-
|
|
86
|
+
// #1268: every user-facing page directly under docs/ and docs/addons/ needs a stamp too; an absent directory lists nothing.
|
|
87
|
+
// The smoke ledger and the retired GSD analysis are dated records with their own evidence, like specs and the changelog.
|
|
88
|
+
const records = new Set(["docs/smoke.md", "docs/gsd-collaboration-analysis.md"]);
|
|
89
|
+
const pages = (dir) => {
|
|
90
|
+
if (!committed) return existsSync(join(root, dir)) ? readdirSync(join(root, dir)) : [];
|
|
91
|
+
const listed = git("ls-tree", "-z", "--name-only", `${headId}:${dir}`);
|
|
92
|
+
if (listed.status === 0) return listed.stdout.split("\0").filter(Boolean);
|
|
93
|
+
if (git("cat-file", "-e", `${headId}:${dir}`).status === 0) throw new Error(`committed ${dir} unavailable`);
|
|
94
|
+
return [];
|
|
95
|
+
};
|
|
96
|
+
const userDocs = ["docs", "docs/addons"].flatMap((dir) => pages(dir).filter((name) => name.endsWith(".md")).map((name) => `${dir}/${name}`)).filter((path) => !records.has(path));
|
|
97
|
+
const required = [...new Set(["README.md", "docs/security.md", "docs/operations.md", "docs/quickstart.md", ...userDocs, ...noteNames.filter((name) => name.endsWith(".md")).map((name) => `docs/agent-notes/${name}`)])];
|
|
87
98
|
for (const path of required.sort()) if (!Object.hasOwn(manifest.documents, path)) errors.push(`missing manifest entry: ${path}`);
|
|
88
99
|
const pkg = JSON.parse(read("package.json"));
|
|
89
100
|
if (typeof pkg.version === "string" && /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:[+][0-9A-Za-z.-]+)?$/.test(pkg.version)) packageVersion = pkg.version;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { isNativeModelToken } from "./model-token.ts";
|
|
2
|
+
import type { CompatId } from "./compat.ts";
|
|
3
|
+
|
|
4
|
+
/** Source-owned receipt policy. No native advertisement or project configuration can create one. */
|
|
5
|
+
export interface AcpModelPolicy {
|
|
6
|
+
readonly admission: { readonly compatId: CompatId; readonly nativeIdentity?: never } | { readonly nativeIdentity: true; readonly compatId?: never };
|
|
7
|
+
readonly agentName: string;
|
|
8
|
+
readonly version: string;
|
|
9
|
+
readonly setter: "set_model" | "set_config_option";
|
|
10
|
+
readonly receipt: string;
|
|
11
|
+
readonly idCodec?: "qwen-openai";
|
|
12
|
+
readonly modelUpdate?: "current_model_update" | "config_option_update" | "qwen/notify/session/model-update";
|
|
13
|
+
}
|
|
14
|
+
export interface AcpModelAdvertisement {
|
|
15
|
+
readonly current: string;
|
|
16
|
+
readonly available: readonly string[];
|
|
17
|
+
readonly wire: Readonly<Record<string, string>>;
|
|
18
|
+
}
|
|
19
|
+
const ACP_MODEL_OPTIONS_MAX = 256;
|
|
20
|
+
/** Person ids retain the native-word grammar; only an exact receipt-owned wire codec may wrap one. */
|
|
21
|
+
export function acpLogicalModelId(policy: AcpModelPolicy, wire: unknown): string | undefined {
|
|
22
|
+
if (policy.idCodec === "qwen-openai") {
|
|
23
|
+
const suffix = "(openai)";
|
|
24
|
+
if (typeof wire !== "string" || wire.length > 128 + suffix.length || !wire.endsWith(suffix)) return;
|
|
25
|
+
const logical = wire.slice(0, -suffix.length);
|
|
26
|
+
return isNativeModelToken(logical) ? logical : undefined;
|
|
27
|
+
}
|
|
28
|
+
return isNativeModelToken(wire) ? wire : undefined;
|
|
29
|
+
}
|
|
30
|
+
/** Only the receipt-backed ACP shapes are recognized; labels never stand in for model ids. */
|
|
31
|
+
export function acpModelAdvertisement(policy: AcpModelPolicy, session: unknown): AcpModelAdvertisement | undefined {
|
|
32
|
+
if (!session || typeof session !== "object" || Array.isArray(session)) return;
|
|
33
|
+
const value = session as Record<string, any>;
|
|
34
|
+
let current: unknown, options: unknown;
|
|
35
|
+
if (policy.setter === "set_model") {
|
|
36
|
+
current = value.models?.currentModelId;
|
|
37
|
+
options = value.models?.availableModels;
|
|
38
|
+
} else {
|
|
39
|
+
if (!Array.isArray(value.configOptions) || value.configOptions.length > ACP_MODEL_OPTIONS_MAX) return;
|
|
40
|
+
const models = value.configOptions.filter((option: any) => option?.id === "model");
|
|
41
|
+
if (models.length !== 1 || models[0]?.type !== "select") return;
|
|
42
|
+
current = models[0].currentValue;
|
|
43
|
+
options = models[0].options;
|
|
44
|
+
}
|
|
45
|
+
const logicalCurrent = acpLogicalModelId(policy, current);
|
|
46
|
+
if (!logicalCurrent || !Array.isArray(options) || options.length === 0 || options.length > ACP_MODEL_OPTIONS_MAX) return;
|
|
47
|
+
const ids: string[] = [], wire: Record<string, string> = Object.create(null);
|
|
48
|
+
for (const option of options) {
|
|
49
|
+
const id = policy.setter === "set_model" ? option?.modelId : option?.value;
|
|
50
|
+
const logical = acpLogicalModelId(policy, id);
|
|
51
|
+
if (!logical || ids.includes(logical)) return;
|
|
52
|
+
ids.push(logical); wire[logical] = id;
|
|
53
|
+
}
|
|
54
|
+
if (!ids.includes(logicalCurrent)) return;
|
|
55
|
+
return Object.freeze({ current: logicalCurrent, available: Object.freeze(ids), wire: Object.freeze(wire) });
|
|
56
|
+
}
|
package/src/adapters/acp.ts
CHANGED
|
@@ -12,7 +12,10 @@ import { BasePeer } from "../hub/peers.ts";
|
|
|
12
12
|
import type { DeliveryFailureCause } from "../hub/delivery-failure.ts";
|
|
13
13
|
import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
|
|
14
14
|
import { AcpOwnedGroup } from "./acp-owned-group.ts";
|
|
15
|
-
import type { StartForm } from "./registry-types.ts";
|
|
15
|
+
import type { StartForm, ModelOverrideSelection } from "./registry-types.ts";
|
|
16
|
+
import { assessVersion, readTrust } from "./compat.ts";
|
|
17
|
+
import { acpLogicalModelId, acpModelAdvertisement, type AcpModelAdvertisement, type AcpModelPolicy } from "./acp-model-policy.ts";
|
|
18
|
+
import { isNativeModelToken } from "./model-token.ts";
|
|
16
19
|
import type { CapabilityManifest } from "../hub/capabilities.ts";
|
|
17
20
|
import { acpManifest } from "./native-capabilities.ts";
|
|
18
21
|
|
|
@@ -103,6 +106,13 @@ export interface AcpOptions {
|
|
|
103
106
|
cmd: string[];
|
|
104
107
|
/** Coordinator-visible selected model only; contains no prompts or command arguments. */
|
|
105
108
|
launchModel?: string;
|
|
109
|
+
/** Fixed factory receipt policy only; absent means live model changes are unavailable. */
|
|
110
|
+
modelPolicy?: AcpModelPolicy;
|
|
111
|
+
/** Testing/host deadline seam; defaults to the existing ACP handshake bound. */
|
|
112
|
+
modelSetterTimeoutMs?: number;
|
|
113
|
+
/** Host/test trust-record path; omitted uses the existing person-owned compatibility record. */
|
|
114
|
+
modelTrustFile?: string;
|
|
115
|
+
getModelOverride?: () => ModelOverrideSelection | undefined;
|
|
106
116
|
permissionMode?: PermissionMode;
|
|
107
117
|
/** Presence replaces all identity-inferred native mode and quota policy. */
|
|
108
118
|
nativePolicy?: { modes?: Partial<Record<PermissionMode, string>>; readOnly?: string; lockedReadOnly?: boolean };
|
|
@@ -238,6 +248,14 @@ export class AcpPeer extends BasePeer {
|
|
|
238
248
|
private permissionMode: PermissionMode;
|
|
239
249
|
private readonly nativePolicy: AcpOptions["nativePolicy"];
|
|
240
250
|
private modeUnknown = false;
|
|
251
|
+
private modelUnknown = false;
|
|
252
|
+
private readonly modelPolicy: AcpModelPolicy | undefined;
|
|
253
|
+
private modelAdvertisement: AcpModelAdvertisement | undefined;
|
|
254
|
+
private modelBaseline: string | undefined;
|
|
255
|
+
private modelSelected: string | undefined;
|
|
256
|
+
private modelObservation: object = {};
|
|
257
|
+
private modelBinding: { proc: ChildProcessWithoutNullStreams; session: string; proof: AcpOwnedGroup } | undefined;
|
|
258
|
+
private modelTurn: { turn: number; selection: ModelOverrideSelection; target: string; sent: boolean; acknowledged: boolean } | undefined;
|
|
241
259
|
private kimiAgent = false;
|
|
242
260
|
private qwenAgent = false;
|
|
243
261
|
/** #445 C1: the agent identity from `initialize`, and the latest `available_commands_update` of the CURRENT session (an update that arrives before the session id is bound waits in `earlyCommands`). */
|
|
@@ -332,6 +350,8 @@ export class AcpPeer extends BasePeer {
|
|
|
332
350
|
private readonly opts: AcpOptions,
|
|
333
351
|
) {
|
|
334
352
|
super(id, opts.watchdogMs);
|
|
353
|
+
this.modelPolicy = opts.modelPolicy ? Object.freeze({ ...opts.modelPolicy, admission: Object.freeze({ ...opts.modelPolicy.admission }) }) : undefined;
|
|
354
|
+
if (opts.modelSetterTimeoutMs !== undefined && (!Number.isSafeInteger(opts.modelSetterTimeoutMs) || opts.modelSetterTimeoutMs <= 0 || opts.modelSetterTimeoutMs > HANDSHAKE_MS)) throw new Error("ACP model acknowledgement deadline must fit the handshake bound");
|
|
335
355
|
this.nativePolicy = opts.nativePolicy === undefined ? undefined : { ...opts.nativePolicy, ...(opts.nativePolicy.modes === undefined ? {} : { modes: { ...opts.nativePolicy.modes } }) };
|
|
336
356
|
this.permissionMode = this.nativePolicy?.lockedReadOnly ? "ask" : opts.permissionMode ?? "ask";
|
|
337
357
|
}
|
|
@@ -346,6 +366,7 @@ export class AcpPeer extends BasePeer {
|
|
|
346
366
|
|
|
347
367
|
async start(): Promise<void> {
|
|
348
368
|
managedLaunches.delete(this);
|
|
369
|
+
this.modelBinding = undefined; this.modelAdvertisement = undefined; this.modelBaseline = undefined; this.modelSelected = undefined; this.modelUnknown = false; this.modelTurn = undefined;
|
|
349
370
|
if (this.opts.requireOwnedGroup && this.proc) throw new Error("Configured ACP still has a process; stop it before starting another");
|
|
350
371
|
this.stopping = false;
|
|
351
372
|
this.binding = true; this.earlyCommands.clear(); // #445 C1: a new process starts a new session (the bind below drops the old list)
|
|
@@ -360,7 +381,7 @@ export class AcpPeer extends BasePeer {
|
|
|
360
381
|
proc.on("exit", (code) => { if (!this.opts.requireOwnedGroup || this.proc === proc) this.down(`exited with code ${code}`); });
|
|
361
382
|
proc.stdin.on("error", () => {}); // EPIPE from a child that died; `exit` / `error` already report it
|
|
362
383
|
proc.stderr.on("data", (d) => this.opts.log?.(this.nativePolicy !== undefined || this.opts.requireOwnedGroup ? "Configured ACP stderr output withheld" : `[${this.id}] ${String(d).trimEnd()}`));
|
|
363
|
-
createInterface({ input: proc.stdout }).on("line", (line) => this.onLine(line));
|
|
384
|
+
createInterface({ input: proc.stdout }).on("line", (line) => { if (!this.modelPolicy || this.proc === proc) this.onLine(line); });
|
|
364
385
|
|
|
365
386
|
const handshake = async () => {
|
|
366
387
|
const init = await this.request("initialize", {
|
|
@@ -400,6 +421,24 @@ export class AcpPeer extends BasePeer {
|
|
|
400
421
|
throw new Error("Configured ACP did not provide a valid native session identity");
|
|
401
422
|
}
|
|
402
423
|
this.sessionId = session.sessionId;
|
|
424
|
+
const modelPolicy = this.modelPolicy;
|
|
425
|
+
if (modelPolicy && this.modelAdmitted()) {
|
|
426
|
+
const advertisement = acpModelAdvertisement(modelPolicy, session);
|
|
427
|
+
if (advertisement && typeof this.sessionId === "string" && this.sessionId.length > 0) {
|
|
428
|
+
try {
|
|
429
|
+
const proof = await AcpOwnedGroup.capture(proc);
|
|
430
|
+
await proof.verify();
|
|
431
|
+
if (this.proc === proc && !this.stopping) {
|
|
432
|
+
this.modelBinding = { proc, session: this.sessionId, proof };
|
|
433
|
+
this.modelAdvertisement = advertisement;
|
|
434
|
+
this.modelSelected = advertisement.current;
|
|
435
|
+
this.modelBaseline = this.opts.launchModel === undefined ? advertisement.current : advertisement.available.includes(this.opts.launchModel) ? this.opts.launchModel : undefined;
|
|
436
|
+
}
|
|
437
|
+
} catch {
|
|
438
|
+
this.opts.log?.("ACP model process ownership is unverified; live model changes are unavailable, inspect status and restart before retrying");
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
403
442
|
this.binding = false;
|
|
404
443
|
this.adoptEarlyCommands(session.sessionId);
|
|
405
444
|
this.onDoing?.("reset");
|
|
@@ -441,6 +480,85 @@ export class AcpPeer extends BasePeer {
|
|
|
441
480
|
this.setState("idle");
|
|
442
481
|
}
|
|
443
482
|
|
|
483
|
+
private modelAdmitted(): boolean {
|
|
484
|
+
const policy = this.modelPolicy;
|
|
485
|
+
if (!policy || !policy.receipt || this.agentIdentity?.name !== policy.agentName || this.agentIdentity.version !== policy.version) return false;
|
|
486
|
+
if (policy.admission.compatId !== undefined) {
|
|
487
|
+
// Verified and compatible-line admission does not depend on a trust record. A trusted exception is re-read,
|
|
488
|
+
// so revocation during a setter await cannot authorize the following prompt.
|
|
489
|
+
return assessVersion(policy.admission.compatId, policy.version, []).ok
|
|
490
|
+
|| assessVersion(policy.admission.compatId, policy.version, readTrust(this.opts.modelTrustFile)).ok;
|
|
491
|
+
}
|
|
492
|
+
return policy.admission.nativeIdentity === true;
|
|
493
|
+
}
|
|
494
|
+
/** Current owned-session readiness, not an assertion about a provider's served model. */
|
|
495
|
+
get modelOverrideState(): Readonly<{ available: boolean; version?: string; baseline?: string }> {
|
|
496
|
+
const binding = this.modelBinding;
|
|
497
|
+
const version = this.agentIdentity?.version;
|
|
498
|
+
const available = !!binding && this.modelAdmitted() && !this.modelUnknown && !this.stopping && this.state !== "offline"
|
|
499
|
+
&& this.proc === binding.proc && binding.proc.exitCode === null && binding.proc.signalCode === null
|
|
500
|
+
&& this.sessionId === binding.session && !!this.modelAdvertisement && !!this.modelBaseline;
|
|
501
|
+
return { available, ...(available ? { baseline: this.modelBaseline! } : {}),
|
|
502
|
+
...(isNativeModelToken(version) && /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.test(version) ? { version } : {}) };
|
|
503
|
+
}
|
|
504
|
+
acceptsModelOverride(model: string): boolean {
|
|
505
|
+
return this.modelOverrideState.available && isNativeModelToken(model) && this.modelAdvertisement!.available.includes(model);
|
|
506
|
+
}
|
|
507
|
+
private observeModelId(model: unknown): void {
|
|
508
|
+
this.modelObservation = {};
|
|
509
|
+
const logical = this.modelPolicy && acpLogicalModelId(this.modelPolicy, model);
|
|
510
|
+
if (!logical || !this.modelAdvertisement?.available.includes(logical)) {
|
|
511
|
+
this.modelAdvertisement = undefined; this.modelSelected = undefined;
|
|
512
|
+
} else this.modelSelected = logical;
|
|
513
|
+
}
|
|
514
|
+
private acknowledgeModel(): void {
|
|
515
|
+
const selected = this.modelTurn;
|
|
516
|
+
if (!selected || !selected.sent || selected.acknowledged || selected.turn !== this.turn || this.stopping || this.modelUnknown
|
|
517
|
+
|| !this.modelOverrideState.available || this.modelSelected !== selected.target) return;
|
|
518
|
+
selected.acknowledged = true;
|
|
519
|
+
selected.selection.applied();
|
|
520
|
+
}
|
|
521
|
+
private async selectTurnModel(turn: number): Promise<void> {
|
|
522
|
+
const selected = this.modelTurn, binding = this.modelBinding, policy = this.modelPolicy;
|
|
523
|
+
if (!selected || !binding || !policy) throw new Error("ACP model setter is unavailable; restart this peer before retrying");
|
|
524
|
+
const current = () => turn === this.turn && this.modelTurn === selected && this.modelBinding === binding
|
|
525
|
+
&& this.proc === binding.proc && this.sessionId === binding.session && !this.stopping && !this.modelUnknown;
|
|
526
|
+
const verify = async () => {
|
|
527
|
+
try { await binding.proof.verify(); }
|
|
528
|
+
catch {
|
|
529
|
+
if (this.modelBinding === binding) this.modelBinding = undefined;
|
|
530
|
+
throw new Error("ACP model process ownership could not be verified; restart this peer before retrying");
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
await verify();
|
|
534
|
+
if (!current() || !this.acceptsModelOverride(selected.target)) throw new Error("ACP model session or advertisement changed; inspect status and retry");
|
|
535
|
+
if (this.modelSelected === selected.target) return;
|
|
536
|
+
const observation = this.modelObservation;
|
|
537
|
+
const wire = this.modelAdvertisement?.wire[selected.target];
|
|
538
|
+
if (!wire) throw new Error("ACP model has no exact advertised wire binding; inspect model choices and retry");
|
|
539
|
+
try {
|
|
540
|
+
const result = await this.request(policy.setter === "set_model" ? "session/set_model" : "session/set_config_option",
|
|
541
|
+
policy.setter === "set_model" ? { sessionId: binding.session, modelId: wire } : { sessionId: binding.session, configId: "model", value: wire }, this.opts.modelSetterTimeoutMs ?? HANDSHAKE_MS);
|
|
542
|
+
if (policy.setter === "set_config_option" && this.modelObservation === observation && result?.configOptions !== undefined) {
|
|
543
|
+
this.modelAdvertisement = acpModelAdvertisement(policy, result);
|
|
544
|
+
this.modelSelected = this.modelAdvertisement?.current;
|
|
545
|
+
this.modelObservation = {};
|
|
546
|
+
}
|
|
547
|
+
} catch (error) {
|
|
548
|
+
if (error instanceof RpcTimeoutError && current()) {
|
|
549
|
+
if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "failed_safe", reason: "ACP model acknowledgement timed out before a prompt was sent; restart this peer before retrying" });
|
|
550
|
+
this.activeDeliveryId = undefined; this.activeLegacy = undefined;
|
|
551
|
+
this.modelUnknown = true; this.turn++; this.setState("offline");
|
|
552
|
+
await this.stop();
|
|
553
|
+
throw new Error("ACP model change was not acknowledged; native selection is unknown, restart this peer before sending work");
|
|
554
|
+
}
|
|
555
|
+
throw new Error("ACP model setter refused the change; inspect model status and retry");
|
|
556
|
+
}
|
|
557
|
+
if (this.modelObservation === observation) this.modelSelected = selected.target;
|
|
558
|
+
await verify();
|
|
559
|
+
if (!current() || !this.acceptsModelOverride(selected.target) || this.modelSelected !== selected.target) throw new Error("ACP model session or advertisement changed; inspect status and retry");
|
|
560
|
+
}
|
|
561
|
+
|
|
444
562
|
getPermissionMode(): PermissionMode { return this.permissionMode; }
|
|
445
563
|
|
|
446
564
|
/** Whether this peer's permission requests pass through an always-ask gate (#636). */
|
|
@@ -533,7 +651,20 @@ export class AcpPeer extends BasePeer {
|
|
|
533
651
|
if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: `${this.id} is ${this.state}` });
|
|
534
652
|
throw new Error(`${this.id} is ${this.state}`);
|
|
535
653
|
}
|
|
654
|
+
let selection: ModelOverrideSelection | undefined;
|
|
655
|
+
try { selection = this.opts.getModelOverride?.(); }
|
|
656
|
+
catch {
|
|
657
|
+
if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: "ACP retained model selection is unavailable; inspect model status, clear the override or restart before retrying" });
|
|
658
|
+
throw new Error("ACP retained model selection is unavailable; inspect model status, clear the override or restart before retrying");
|
|
659
|
+
}
|
|
660
|
+
const target = selection?.model ?? this.modelBaseline;
|
|
661
|
+
if (selection && (!target || !this.acceptsModelOverride(target))) {
|
|
662
|
+
selection.rejected?.();
|
|
663
|
+
if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: "ACP model is not advertised by this verified session; inspect model status and retry" });
|
|
664
|
+
throw new Error("ACP model is not advertised by this verified session; inspect model status and retry");
|
|
665
|
+
}
|
|
536
666
|
const turn = ++this.turn;
|
|
667
|
+
this.modelTurn = selection && target ? { turn, selection, target, sent: false, acknowledged: false } : undefined;
|
|
537
668
|
this.activeDeliveryId = deliveryId;
|
|
538
669
|
this.activeLegacy = deliveryId ? undefined : { turn, envs };
|
|
539
670
|
this.turnEnvs = envs; this.steerIds = [];
|
|
@@ -548,8 +679,27 @@ export class AcpPeer extends BasePeer {
|
|
|
548
679
|
this.setState("busy");
|
|
549
680
|
this.emitActivity("delivery", envs.map(env => `${env.from} ${env.id}`).join(", "));
|
|
550
681
|
|
|
682
|
+
if (selection) {
|
|
683
|
+
try {
|
|
684
|
+
await this.selectTurnModel(turn);
|
|
685
|
+
if (turn !== this.turn || this.stopping || !this.modelOverrideState.available
|
|
686
|
+
|| this.modelTurn?.turn !== turn || this.modelSelected !== target) throw new Error("ACP model owner or selection changed before prompt admission; inspect status and retry");
|
|
687
|
+
}
|
|
688
|
+
catch (error) {
|
|
689
|
+
if (turn === this.turn && !this.modelTurn?.sent) {
|
|
690
|
+
selection.rejected?.();
|
|
691
|
+
if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: "ACP model change did not admit a prompt; inspect status and retry" });
|
|
692
|
+
this.activeDeliveryId = undefined; this.activeLegacy = undefined; this.modelTurn = undefined;
|
|
693
|
+
this.endActivity("model change refused", true);
|
|
694
|
+
if (!this.stopping && (this.state as PeerState) === "busy") this.setState("idle");
|
|
695
|
+
}
|
|
696
|
+
throw error;
|
|
697
|
+
}
|
|
698
|
+
if (this.modelTurn?.turn === turn) this.modelTurn.sent = true;
|
|
699
|
+
}
|
|
700
|
+
const fixedPreamble = this.turnRecordPrompt(this.primed ? "" : this.opts.preamble ?? "");
|
|
551
701
|
// session/prompt answers only when the turn ends, so deliver resolves now and failures come back through onFailed.
|
|
552
|
-
const prompt = this.request("session/prompt", { sessionId: this.sessionId, prompt: [{ type: "text", text:
|
|
702
|
+
const prompt = this.request("session/prompt", { sessionId: this.sessionId, prompt: [{ type: "text", text: fixedPreamble ? `${fixedPreamble}\n\n${renderDigest(envs, this.primed)}` : renderDigest(envs, this.primed) }] });
|
|
553
703
|
const reported = () => { if (this.cancelWait?.turn === turn) this.cancelWait.reported = true; };
|
|
554
704
|
void prompt.then(reported, reported);
|
|
555
705
|
prompt
|
|
@@ -562,11 +712,12 @@ export class AcpPeer extends BasePeer {
|
|
|
562
712
|
this.observeUsage(result, "prompt_result");
|
|
563
713
|
const turnUsage = acpTurnUsage(result);
|
|
564
714
|
if (turnUsage) try { this.opts.onTurnUsage?.(turnUsage, this.sessionId); } catch { /* a counter sink never costs a turn */ }
|
|
715
|
+
if (result?.stopReason === "end_turn") this.acknowledgeModel();
|
|
565
716
|
this.primed = true;
|
|
566
717
|
const body = this.chunks.join("").trim();
|
|
567
718
|
// What was steered into the turn is answered by it too: its senders hear back, and the hop cap still holds.
|
|
568
719
|
const answering = this.turnEnvs.length ? this.turnEnvs : envs;
|
|
569
|
-
if (body) this.
|
|
720
|
+
if (body) this.finalAnswer(body, { inReplyTo: replyParent(answering), to: replyAudience(answering) }, result?.stopReason === "end_turn");
|
|
570
721
|
// Partial output cannot certify normal completion (#160).
|
|
571
722
|
if (result?.stopReason !== "end_turn") this.reportTurnFailure(envs, `ACP prompt ended without normal completion (${String(result?.stopReason ?? "unknown")})`);
|
|
572
723
|
if (deliveryId && this.activeDeliveryId === deliveryId) {
|
|
@@ -580,6 +731,7 @@ export class AcpPeer extends BasePeer {
|
|
|
580
731
|
if (this.cancelWait?.turn === turn) this.opts.log?.(this.nativePolicy !== undefined || this.opts.requireOwnedGroup ? "Configured ACP cancelled prompt rejected; native details withheld" : `[${this.id}] cancelled prompt rejected: ${sanitize(e.message)}`);
|
|
581
732
|
return; // cancelled prompt errors are discarded, like their late results
|
|
582
733
|
}
|
|
734
|
+
if (!this.modelTurn?.acknowledged) this.modelTurn?.selection.rejected?.();
|
|
583
735
|
this.endActivity("failed", true);
|
|
584
736
|
const reason = this.nativePolicy !== undefined || this.opts.requireOwnedGroup ? "Configured ACP prompt failed" : sanitize(e.message);
|
|
585
737
|
this.settleSteers("needs_review", reason);
|
|
@@ -605,6 +757,7 @@ export class AcpPeer extends BasePeer {
|
|
|
605
757
|
if (!this.stopping && this.turn === waiting.successor && this.state === "busy") this.setState("idle");
|
|
606
758
|
return;
|
|
607
759
|
}
|
|
760
|
+
if (this.modelTurn?.turn === turn) this.modelTurn = undefined;
|
|
608
761
|
if (turn === this.turn && !this.stopping && this.state === "busy") this.setState("idle");
|
|
609
762
|
if (this.activeLegacy?.turn === turn) this.activeLegacy = undefined;
|
|
610
763
|
if (turn === this.turn && this.activeDeliveryId === deliveryId) this.activeDeliveryId = undefined;
|
|
@@ -728,6 +881,10 @@ export class AcpPeer extends BasePeer {
|
|
|
728
881
|
}
|
|
729
882
|
if (this.state === "busy" && !this.cancelWait) { this.lastSignAt = Date.now(); this.touch(); }
|
|
730
883
|
|
|
884
|
+
if (this.modelPolicy && msg.method === "qwen/notify/session/model-update") {
|
|
885
|
+
if (this.modelPolicy.modelUpdate === "qwen/notify/session/model-update" && msg.params?.v === 1 && msg.params.sessionId === this.sessionId) this.observeModelId(msg.params.currentModelId);
|
|
886
|
+
return; // A setter/control notification is never prompt admission.
|
|
887
|
+
}
|
|
731
888
|
if (msg.method === "session/update") {
|
|
732
889
|
// #445 C1: the command list is the session's, sent right after `session/new` and again when skills change, whatever the turn state. Only the current session's counts.
|
|
733
890
|
if (msg.params?.update?.sessionUpdate === "available_commands_update" && typeof msg.params.sessionId === "string") {
|
|
@@ -755,7 +912,19 @@ export class AcpPeer extends BasePeer {
|
|
|
755
912
|
return;
|
|
756
913
|
}
|
|
757
914
|
}
|
|
758
|
-
if (this.
|
|
915
|
+
if (this.modelPolicy && (u?.sessionUpdate === "current_model_update" || u?.sessionUpdate === "config_option_update")) {
|
|
916
|
+
if (msg.params?.sessionId === this.sessionId && this.modelPolicy.modelUpdate === u.sessionUpdate) {
|
|
917
|
+
this.modelObservation = {};
|
|
918
|
+
if (u.sessionUpdate === "config_option_update") {
|
|
919
|
+
this.modelAdvertisement = acpModelAdvertisement(this.modelPolicy, { configOptions: u.configOptions });
|
|
920
|
+
this.modelSelected = this.modelAdvertisement?.current;
|
|
921
|
+
}
|
|
922
|
+
else this.observeModelId(u.currentModelId);
|
|
923
|
+
}
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
if (this.state !== "busy" || this.cancelWait || this.modelTurn && !this.modelTurn.sent) return;
|
|
927
|
+
if (msg.params?.sessionId === this.sessionId && ["agent_message_chunk", "agent_thought_chunk", "tool_call", "tool_call_update"].includes(u?.sessionUpdate)) this.acknowledgeModel();
|
|
759
928
|
this.promptActivity = true;
|
|
760
929
|
this.acceptDelivery();
|
|
761
930
|
this.updateWatch(u);
|
|
@@ -783,6 +952,7 @@ export class AcpPeer extends BasePeer {
|
|
|
783
952
|
} else if (msg.method === "session/request_permission") {
|
|
784
953
|
if (this.cancelWait || this.state === "offline") { this.send({ jsonrpc: "2.0", id: msg.id, result: { outcome: { outcome: "cancelled" } } }); return; }
|
|
785
954
|
if (this.state === "busy" && (!msg.params?.sessionId || msg.params.sessionId === this.sessionId)) this.promptActivity = true;
|
|
955
|
+
if (this.state === "busy" && !this.cancelWait && msg.params?.sessionId === this.sessionId) this.acknowledgeModel();
|
|
786
956
|
void this.answerPermission(msg);
|
|
787
957
|
} else if (msg.method && msg.id !== undefined) {
|
|
788
958
|
this.send({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: "method not supported by agent-hub" } });
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { publicArg } from "../definitions/native-cli-preview-common.ts";
|
|
2
2
|
import { assessVersion, COMPAT } from "../compat.ts";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
3
4
|
import { spawn, spawnSync } from "node:child_process";
|
|
4
|
-
import { resolve } from "node:path";
|
|
5
|
+
import { dirname, resolve } from "node:path";
|
|
5
6
|
import type { NativeCliEntryContext } from "../definitions/native-cli-entry-types.ts";
|
|
6
7
|
import { bundleInStatus, rosterInStatus } from "../../hub/status-roster.ts";
|
|
7
8
|
import { ensureAgyMcp, cleanupAgyMcp, type AgyMcpOptions } from "../../cli/agy-mcp.ts";
|
|
@@ -56,7 +57,7 @@ export async function runAgyEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
|
56
57
|
if (unattended) fail("agy P1 native permissions are unmanaged; unattended mode has no verified prompt mapping");
|
|
57
58
|
const argv = nativeArgs;
|
|
58
59
|
if (preview) { console.log(JSON.stringify({ command: "agy", args: argv.map(publicArg), mode: "tui", permission: "unmanaged", mcp: ".agents/plugins/agent-hub/", writes: false })); return; }
|
|
59
|
-
const version = spawnSync("agy", ["--version"], { encoding: "utf8", timeout: 5000, maxBuffer: 4096, env: peerChildEnv("agy") });
|
|
60
|
+
const version = spawnSync("agy", ["--version"], { encoding: "utf8", timeout: 5000, maxBuffer: 4096, env: { ...peerChildEnv("agy"), AGY_CLI_DISABLE_AUTO_UPDATE: "1" } });
|
|
60
61
|
const gate = agyVersionGate(version.status === 0 ? version.stdout : "");
|
|
61
62
|
if (!gate.ok) fail(gate.message);
|
|
62
63
|
if (gate.message) console.error(gate.message);
|
|
@@ -69,13 +70,15 @@ export async function runAgyEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
|
69
70
|
if (rosterInStatus(status, "agy") !== true) fail("agy is off roster; add it with ahub roster add agy");
|
|
70
71
|
const admitted = await hub.request({ t: "native_hook_launch", op: "begin", peer: "agy" });
|
|
71
72
|
if (!admitted.ok || typeof admitted.launchId !== "string" || typeof admitted.instanceId !== "string") fail(admitted.error ?? "native hook launch was not admitted");
|
|
72
|
-
|
|
73
|
-
|
|
73
|
+
const serverJs = bundleInStatus(status) ?? resolve(cliDir, "../../plugins/agent-hub/server.js");
|
|
74
|
+
const bundledHook = resolve(dirname(serverJs), "../../src/cli/facts-hook.ts");
|
|
75
|
+
const hookScript = existsSync(bundledHook) ? bundledHook : resolve(cliDir, "facts-hook.ts");
|
|
76
|
+
options = { projectRoot: cwd, stateDir, serverJs, hookScript, peer: "agy", launchId: admitted.launchId, instanceId: admitted.instanceId };
|
|
74
77
|
const written = ensureAgyMcp(options);
|
|
75
78
|
pluginWritten = written.changed;
|
|
76
79
|
if (written.tracked) console.error("The managed agy plugin is tracked; review its local machine paths before committing");
|
|
77
80
|
console.error(AGY_LAUNCH_NOTICE);
|
|
78
|
-
const env: NodeJS.ProcessEnv = { ...ctx.helpers.nativeLaunchEnv("agy", ctx.helpers.childEnv()), AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd };
|
|
81
|
+
const env: NodeJS.ProcessEnv = { ...ctx.helpers.nativeLaunchEnv("agy", ctx.helpers.childEnv()), AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd, AGY_CLI_DISABLE_AUTO_UPDATE: "1" };
|
|
79
82
|
delete env.AGENTHUB_CHANNEL;
|
|
80
83
|
const code = await runAgyTui(argv, cwd, env);
|
|
81
84
|
if (code !== 0) process.exitCode = code;
|
|
@@ -2,7 +2,7 @@ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { isAbsolute, join, relative } from "node:path";
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
|
-
import type { NativeFactsInput, NativeFactsPort, NativeFactsTranscript } from "../registry-types.ts";
|
|
5
|
+
import type { NativeFactsInput, NativeFactsPort, NativeFactsReadOptions, NativeFactsTranscript } from "../registry-types.ts";
|
|
6
6
|
|
|
7
7
|
const EVENTS = ["SessionStart", "PreInvocation", "PostInvocation", "PreToolUse", "PostToolUse", "Stop"] as const;
|
|
8
8
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
@@ -18,16 +18,40 @@ export function decodeAgyHook(event: string, value: unknown): NativeFactsInput |
|
|
|
18
18
|
const call = record(value.toolCall) ? value.toolCall : undefined;
|
|
19
19
|
if ((event === "PreToolUse" || event === "PostToolUse") && (!call || typeof call.name !== "string" || call.name.length > 128 || !record(call.args))) return;
|
|
20
20
|
const native = call?.args as Record<string, unknown> | undefined;
|
|
21
|
-
//
|
|
22
|
-
// 1.4.2 names it view_file(AbsolutePath); 1.4.3 renamed it read_file(Path, optional StartLine/EndLine).
|
|
21
|
+
// 1.4.2 names it view_file(AbsolutePath); 1.4.3+ renamed it read_file(Path, optional StartLine/EndLine/ByteOffset).
|
|
23
22
|
const readPath = call?.name === "view_file" ? native?.AbsolutePath : call?.name === "read_file" ? native?.Path : undefined;
|
|
24
23
|
const observedRead = typeof readPath === "string";
|
|
25
|
-
// A line range is a partial read: carry
|
|
26
|
-
const
|
|
27
|
-
|
|
24
|
+
// A line range or byte offset is a partial read: carry offset/limit so it never counts as a whole-file view.
|
|
25
|
+
const hasStart = observedRead && Number.isSafeInteger(native?.StartLine) && (native!.StartLine as number) > 0;
|
|
26
|
+
const hasEnd = observedRead && Number.isSafeInteger(native?.EndLine) && (native!.EndLine as number) > 0;
|
|
27
|
+
const hasByteOffset = observedRead && Number.isSafeInteger(native?.ByteOffset) && (native!.ByteOffset as number) >= 0;
|
|
28
|
+
const range = hasStart
|
|
29
|
+
? { offset: native!.StartLine as number, ...(hasEnd && (native!.EndLine as number) >= (native!.StartLine as number) ? { limit: (native!.EndLine as number) - (native!.StartLine as number) + 1 } : {}) }
|
|
30
|
+
: hasEnd
|
|
31
|
+
? { offset: 1, limit: native!.EndLine as number }
|
|
32
|
+
: hasByteOffset
|
|
33
|
+
? { offset: (native!.ByteOffset as number) + 1 }
|
|
34
|
+
: {};
|
|
35
|
+
// 1.4.2 names writes write_to_file(TargetFile, CodeContent); 1.4.3+ names them write_file(Path, Content, optional Append).
|
|
36
|
+
const writePath = call?.name === "write_file" ? native?.Path : call?.name === "write_to_file" ? native?.TargetFile : undefined;
|
|
37
|
+
const writeContent = call?.name === "write_file" ? native?.Content : call?.name === "write_to_file" ? native?.CodeContent : undefined;
|
|
38
|
+
const observedWrite = typeof writePath === "string";
|
|
39
|
+
// 1.4.2 names edits replace_file_content(TargetFile, TargetContent, ReplacementContent); 1.4.3+ names them edit_file(Path, Target, Replacement).
|
|
40
|
+
const editPath = call?.name === "edit_file" ? native?.Path : call?.name === "replace_file_content" ? native?.TargetFile : undefined;
|
|
41
|
+
const oldStr = call?.name === "edit_file" ? native?.Target : call?.name === "replace_file_content" ? native?.TargetContent : undefined;
|
|
42
|
+
const newStr = call?.name === "edit_file" ? native?.Replacement : call?.name === "replace_file_content" ? native?.ReplacementContent : undefined;
|
|
43
|
+
const observedEdit = typeof editPath === "string";
|
|
44
|
+
const tool = observedRead ? "Read" : observedWrite ? "Write" : observedEdit ? "Edit" : typeof call?.name === "string" ? call.name : "";
|
|
45
|
+
const input = observedRead
|
|
46
|
+
? { file_path: readPath, ...range }
|
|
47
|
+
: observedWrite
|
|
48
|
+
? { file_path: writePath, ...(typeof writeContent === "string" && native?.Append !== true ? { content: writeContent } : {}) }
|
|
49
|
+
: observedEdit
|
|
50
|
+
? { file_path: editPath, ...(typeof oldStr === "string" && typeof newStr === "string" ? { old_string: oldStr, new_string: newStr, replace_all: native?.AllowMultiple === true } : {}) }
|
|
51
|
+
: native ?? {};
|
|
28
52
|
const phase = ({ SessionStart: "session", PreInvocation: "start", PostInvocation: "post", PreToolUse: "pre", PostToolUse: "post", Stop: "stop" } as const)[event as typeof EVENTS[number]];
|
|
29
53
|
return { event: event as NativeFactsInput["event"], phase, sessionId: value.conversationId, transcriptPath: value.transcriptPath,
|
|
30
|
-
workspacePaths: value.workspacePaths as string[], tool
|
|
54
|
+
workspacePaths: value.workspacePaths as string[], tool, input,
|
|
31
55
|
...(Number.isSafeInteger(value.stepIdx) && (value.stepIdx as number) >= 0 ? { toolUseId: `${value.conversationId}:${value.stepIdx}` } : {}),
|
|
32
56
|
...(Number.isSafeInteger(value.invocationNum) && (value.invocationNum as number) >= 0 ? { invocation: value.invocationNum as number } : {}),
|
|
33
57
|
...(event === "Stop" ? { fullyIdle: value.fullyIdle as boolean, terminationReason: typeof value.terminationReason === "string" ? value.terminationReason : "" } : {}) };
|
|
@@ -40,7 +64,7 @@ export function encodeAgyHook(event: string, text?: string): Record<string, unkn
|
|
|
40
64
|
}
|
|
41
65
|
|
|
42
66
|
/** Only native-generated rows under the exact session cache path count as readback/completion evidence. */
|
|
43
|
-
export function readAgyTranscript(session: string, transcript: string): NativeFactsTranscript {
|
|
67
|
+
export function readAgyTranscript(session: string, transcript: string, options?: NativeFactsReadOptions): NativeFactsTranscript {
|
|
44
68
|
const empty: NativeFactsTranscript = { factIds: [] };
|
|
45
69
|
if (!UUID.test(session)) return empty;
|
|
46
70
|
const home = process.env.HOME ?? homedir();
|
|
@@ -53,11 +77,15 @@ export function readAgyTranscript(session: string, transcript: string): NativeFa
|
|
|
53
77
|
fd = openSync(transcript, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
54
78
|
const stat = fstatSync(fd);
|
|
55
79
|
if (!stat.isFile() || stat.nlink !== 1) return empty;
|
|
56
|
-
|
|
57
|
-
|
|
80
|
+
if (options?.unchanged && stat.size === options.unchanged.size && stat.mtimeMs === options.unchanged.mtimeMs) return { factIds: [], size: stat.size, mtimeMs: stat.mtimeMs, unchanged: true };
|
|
81
|
+
const hasSince = options?.sinceOffset !== undefined && options.sinceOffset >= 0 && options.sinceOffset <= stat.size;
|
|
82
|
+
const resumeAt = options?.completion === false && hasSince ? options.sinceOffset! : 0;
|
|
83
|
+
if (options?.completion === false && resumeAt === stat.size) return { factIds: [], size: stat.size, mtimeMs: stat.mtimeMs, scannedFrom: stat.size };
|
|
84
|
+
// ponytail: completion and incremental ack lookback are capped at 4 MiB (as for Claude). A larger final row is unverified,
|
|
85
|
+
// and >4 MiB appended between two readbacks advances the ack cursor to EOF; upgrade to a native forward cursor if needed.
|
|
58
86
|
const cap = 4 * 1024 * 1024;
|
|
59
87
|
for (let window = 64 * 1024; ; window = Math.min(cap, window * 2)) {
|
|
60
|
-
const start = Math.max(
|
|
88
|
+
const start = Math.max(resumeAt, stat.size - window), from = start > 0 ? start - 1 : 0;
|
|
61
89
|
const buffer = Buffer.alloc(stat.size - from);
|
|
62
90
|
let bytes = 0;
|
|
63
91
|
while (bytes < buffer.length) {
|
|
@@ -68,17 +96,39 @@ export function readAgyTranscript(session: string, transcript: string): NativeFa
|
|
|
68
96
|
const lines = buffer.subarray(0, bytes).toString("utf8").split("\n");
|
|
69
97
|
// The preceding byte distinguishes a cut inside a row from a row beginning exactly at the window.
|
|
70
98
|
if (start) lines.shift();
|
|
71
|
-
|
|
99
|
+
let incompleteBytes = 0;
|
|
100
|
+
if (lines.length === 0) {
|
|
101
|
+
incompleteBytes = from + bytes - start;
|
|
102
|
+
} else {
|
|
103
|
+
const tail = lines[lines.length - 1]!, prev = lines.length > 1 ? lines[lines.length - 1 - 1]! : "";
|
|
104
|
+
if (tail.length > 0) {
|
|
105
|
+
try { JSON.parse(tail); } catch { incompleteBytes = Buffer.byteLength(tail, "utf8"); }
|
|
106
|
+
} else if (prev.length > 0) {
|
|
107
|
+
try { JSON.parse(prev); } catch { incompleteBytes = Buffer.byteLength(prev, "utf8") + 1; }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const out: NativeFactsTranscript = { factIds: [], size: Math.max(resumeAt, from + bytes - incompleteBytes), mtimeMs: stat.mtimeMs, scannedFrom: window === cap && hasSince ? Math.min(start, options!.sinceOffset!) : start };
|
|
72
111
|
for (const line of lines) {
|
|
73
112
|
let row: unknown; try { row = JSON.parse(line); } catch { continue; }
|
|
74
113
|
if (!record(row) || row.status !== "DONE") continue;
|
|
75
|
-
if (row.source === "SYSTEM_SDK" && row.type === "EPHEMERAL_MESSAGE" && typeof row.content === "string") {
|
|
114
|
+
if (row.source === "SYSTEM_SDK" && (row.type === "EPHEMERAL_MESSAGE" || row.type === "SYSTEM_MESSAGE") && typeof row.content === "string") {
|
|
76
115
|
for (const match of row.content.matchAll(/agent-hub facts \[([a-zA-Z0-9-]{1,128})\]/g)) out.factIds.push(match[1]!);
|
|
77
116
|
}
|
|
78
|
-
if (row.source === "MODEL" && row.type === "PLANNER_RESPONSE" && Number.isSafeInteger(row.step_index)
|
|
117
|
+
if (row.source === "MODEL" && row.type === "PLANNER_RESPONSE" && Number.isSafeInteger(row.step_index) && (row.step_index as number) >= 0
|
|
118
|
+
&& Number.isSafeInteger(row.input_tokens) && (row.input_tokens as number) >= 0
|
|
119
|
+
&& Number.isSafeInteger(row.output_tokens) && (row.output_tokens as number) >= 0) {
|
|
120
|
+
const inputTokens = row.input_tokens as number, outputTokens = row.output_tokens as number;
|
|
121
|
+
const cacheReadTokens = Number.isSafeInteger(row.cache_read_tokens) && (row.cache_read_tokens as number) >= 0 ? (row.cache_read_tokens as number) : 0;
|
|
122
|
+
const totalTokens = inputTokens + outputTokens + cacheReadTokens;
|
|
123
|
+
if (Number.isSafeInteger(totalTokens)) {
|
|
124
|
+
const at = typeof row.created_at === "string" && Number.isFinite(Date.parse(row.created_at)) ? row.created_at : undefined;
|
|
125
|
+
(out.usage ??= []).push({ id: createHash("sha256").update(`${session}:usage:${row.step_index}`).digest("hex"), index: row.step_index as number, inputTokens, outputTokens, cacheReadTokens, totalTokens, ...(at ? { at } : {}) });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (options?.completion !== false && row.source === "MODEL" && row.type === "PLANNER_RESPONSE" && Number.isSafeInteger(row.step_index)
|
|
79
129
|
&& typeof row.content === "string" && row.content.trim().length > 0 && (!Array.isArray(row.tool_calls) || row.tool_calls.length === 0)) out.completion = { id: createHash("sha256").update(`${session}:${row.step_index}:${line}`).digest("hex"), index: row.step_index as number };
|
|
80
130
|
}
|
|
81
|
-
if (out.completion || start ===
|
|
131
|
+
if ((out.completion && (!hasSince || start <= options!.sinceOffset!)) || start === resumeAt || window === cap) return out;
|
|
82
132
|
}
|
|
83
133
|
} catch { return empty; } finally { if (fd !== undefined) closeSync(fd); }
|
|
84
134
|
}
|