@staix/agent-hub 0.34.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +35 -6
  3. package/docs/adding-an-ai-tool.md +2 -2
  4. package/docs/addons/claude-mem.md +4 -4
  5. package/docs/addons/open-code-review.md +1 -1
  6. package/docs/agent-notes/README.md +6 -0
  7. package/docs/agent-notes/adapters.md +9 -5
  8. package/docs/agent-notes/budget.md +1 -1
  9. package/docs/agent-notes/bus.md +1 -1
  10. package/docs/agent-notes/daemon.md +4 -3
  11. package/docs/agent-notes/local-worker.md +1 -1
  12. package/docs/agent-notes/models.md +2 -0
  13. package/docs/agent-notes/tasks.md +1 -1
  14. package/docs/agent-notes/tests.md +5 -0
  15. package/docs/design/adapters.md +110 -2
  16. package/docs/design/agent-hub-architecture.md +63 -1
  17. package/docs/design/agent-hub.md +6 -0
  18. package/docs/design/bus.md +18 -0
  19. package/docs/design/execution-reference.md +146 -0
  20. package/docs/design/task-board.md +10 -0
  21. package/docs/dogfood.md +33 -12
  22. package/docs/events.md +38 -20
  23. package/docs/memory.md +21 -7
  24. package/docs/operations.md +539 -24
  25. package/docs/quickstart.md +18 -4
  26. package/docs/research.md +71 -13
  27. package/docs/roles.md +1 -1
  28. package/docs/sdlc.md +9 -7
  29. package/docs/security.md +87 -11
  30. package/docs/smoke.md +14 -0
  31. package/docs/specs/2026-09-19-agent-hub-design.md +8 -3
  32. package/docs/verification/2026-11-07-0.34.1.md +54 -0
  33. package/docs/verification/2026-11-07-compat-python-startup-1248.md +51 -0
  34. package/docs/verification/2026-11-07-compat-receipt-harness-1248.md +105 -0
  35. package/docs/verification/2026-11-07-compat-receipt-producers-1248.md +96 -0
  36. package/docs/verification/2026-11-07-compat-workflow-1248.md +94 -0
  37. package/docs/verification/2026-11-07-daemon-startup-1232.md +169 -0
  38. package/docs/verification/2026-11-07-jcode-receipt-reconstruction-1248.md +118 -0
  39. package/docs/verification/2026-11-07-kimi-delayed-usage.md +34 -0
  40. package/docs/verification/2026-11-07-kimi-review-watch-1284.md +115 -0
  41. package/docs/verification/2026-11-08-agy-1.5.3.md +96 -0
  42. package/docs/verification/2026-11-08-agy-acp-t0.md +56 -0
  43. package/docs/verification/2026-11-08-hub-visual-1058.md +161 -0
  44. package/docs/verification/2026-11-08-jcode-0991-model-setter-1317.md +143 -0
  45. package/docs/verification/2026-11-08-kimi-252-model-setter-1317.md +137 -0
  46. package/docs/verification/2026-11-08-kimi-tui-completion-1282.md +45 -0
  47. package/docs/verification/2026-11-08-kimi-wire-yield-1283.md +31 -0
  48. package/docs/verification/2026-11-08-live-model-override-1317.md +250 -0
  49. package/docs/verification/2026-11-08-qwen-0269-acp.md +138 -0
  50. package/docs/verification/2026-11-08-qwen-0269-model-setter-1317.md +91 -0
  51. package/docs/verification/2026-11-09-0.35.0.md +129 -0
  52. package/docs/verification/2026-11-09-headless-records-1320.md +157 -0
  53. package/docs/verification/claude-permission-transport-native.py +10 -3
  54. package/docs/verification/claude-shell-wrapper-native.py +8 -8
  55. package/docs/verified.json +245 -15
  56. package/package.json +3 -3
  57. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  58. package/plugins/agent-hub/kimi-hook.js +20397 -0
  59. package/plugins/agent-hub/kimi-review-policy-worker.js +217 -0
  60. package/plugins/agent-hub/server.js +2701 -664
  61. package/scripts/check-docs.mjs +12 -1
  62. package/src/adapters/acp-model-policy.ts +56 -0
  63. package/src/adapters/acp.ts +175 -5
  64. package/src/adapters/agy/entry.ts +8 -5
  65. package/src/adapters/agy/hooks.ts +65 -15
  66. package/src/adapters/channel-peer.ts +33 -1
  67. package/src/adapters/claude-implementer.ts +16 -3
  68. package/src/adapters/codex-appserver.ts +118 -8
  69. package/src/adapters/codex-review-isolation.ts +3 -1
  70. package/src/adapters/compat.ts +11 -6
  71. package/src/adapters/definitions/builtin-metadata.ts +8 -1
  72. package/src/adapters/definitions/native-cli-entry-kimi.ts +76 -7
  73. package/src/adapters/definitions/native-cli-preview-common.ts +2 -2
  74. package/src/adapters/definitions/native-cli-preview-kimi.ts +9 -2
  75. package/src/adapters/definitions/reviewer-variants.ts +3 -1
  76. package/src/adapters/kimi-review-policy-worker.js +2 -0
  77. package/src/adapters/kimi-review-policy-worker.ts +85 -0
  78. package/src/adapters/kimi-review-policy.ts +68 -0
  79. package/src/adapters/kimi-review.ts +41 -41
  80. package/src/adapters/kimi-tui-facts.ts +153 -0
  81. package/src/adapters/kimi-wire-usage.ts +33 -8
  82. package/src/adapters/local-worker.ts +59 -20
  83. package/src/adapters/model-token.ts +3 -0
  84. package/src/adapters/pi.ts +27 -5
  85. package/src/adapters/registry-types.ts +34 -3
  86. package/src/adapters/registry.ts +1 -0
  87. package/src/adapters/reviewer-policy-watch.ts +86 -0
  88. package/src/adapters/reviewer-print.ts +80 -18
  89. package/src/cli/agents.ts +1 -1
  90. package/src/cli/facts-hook.ts +4 -2
  91. package/src/cli/help.ts +3 -2
  92. package/src/cli/identity.ts +3 -0
  93. package/src/cli/kimi-facts-hook.ts +11 -0
  94. package/src/cli/kimi-plugin.ts +157 -0
  95. package/src/cli/main.ts +48 -9
  96. package/src/cli/model.ts +52 -0
  97. package/src/cli/output.ts +23 -1
  98. package/src/cli/recovery-package.ts +15 -7
  99. package/src/cli/reset.ts +1 -1
  100. package/src/cli/status-lines.ts +8 -4
  101. package/src/hub/budget.ts +1 -1
  102. package/src/hub/bus.ts +40 -2
  103. package/src/hub/code-facts.ts +10 -3
  104. package/src/hub/completions.ts +3 -1
  105. package/src/hub/config-apply.ts +2 -1
  106. package/src/hub/control-client.ts +1 -1
  107. package/src/hub/daemon.ts +1050 -106
  108. package/src/hub/deliverable-files.ts +38 -5
  109. package/src/hub/dogfood-profile.ts +1 -1
  110. package/src/hub/envelope.ts +1 -1
  111. package/src/hub/events.ts +28 -18
  112. package/src/hub/execution-lineage.ts +120 -0
  113. package/src/hub/execution-reference.ts +36 -0
  114. package/src/hub/hub-tools.ts +21 -2
  115. package/src/hub/instances.ts +4 -3
  116. package/src/hub/kimi-events.ts +46 -0
  117. package/src/hub/kimi-usage.ts +132 -29
  118. package/src/hub/lifecycle.ts +31 -15
  119. package/src/hub/limits.ts +13 -4
  120. package/src/hub/manager.ts +15 -2
  121. package/src/hub/memory-policy.ts +2 -1
  122. package/src/hub/memory-sync-attestation.ts +54 -0
  123. package/src/hub/memory-sync-state.ts +20 -5
  124. package/src/hub/model-overrides.ts +115 -0
  125. package/src/hub/native-hook-launch.ts +12 -3
  126. package/src/hub/operational-view.ts +2 -2
  127. package/src/hub/peers.ts +27 -0
  128. package/src/hub/permission-mode.ts +1 -1
  129. package/src/hub/person-figures.ts +88 -0
  130. package/src/hub/report.ts +38 -4
  131. package/src/hub/research-dataset.ts +158 -0
  132. package/src/hub/research-memory.ts +68 -0
  133. package/src/hub/research-quality.ts +118 -0
  134. package/src/hub/research-record.ts +279 -0
  135. package/src/hub/research.ts +126 -27
  136. package/src/hub/restart.ts +14 -0
  137. package/src/hub/settings.ts +3 -0
  138. package/src/hub/startup-event-seeds.ts +51 -0
  139. package/src/hub/startup-readiness.ts +65 -0
  140. package/src/hub/startup-timing.ts +38 -0
  141. package/src/hub/tasks.ts +29 -9
  142. package/src/hub/ui.ts +9 -5
  143. package/src/hub/visual-assets.ts +50 -0
  144. package/src/hub/visual.ts +257 -0
  145. package/src/memory/brief.ts +169 -14
  146. package/src/memory/capture.ts +2 -1
  147. package/src/memory/recall.ts +46 -5
  148. package/src/memory/record-provenance.ts +82 -0
  149. package/src/memory/records.ts +116 -0
  150. package/src/models/route/adec.ts +190 -0
  151. package/src/models/route/config.ts +37 -3
  152. package/src/models/route/decision-evidence.ts +83 -0
  153. package/src/models/route/decision.ts +143 -0
  154. package/src/models/route/runtime.ts +13 -6
  155. package/src/ui/index.html +134 -6
  156. package/src/ui/plugin.ts +65 -4
  157. package/templates/AGENTS.block.md +1 -1
  158. package/templates/config.json +1 -1
  159. package/templates/profiles/general.json +1 -1
  160. package/templates/profiles/research.json +1 -1
  161. package/templates/profiles/writing.json +1 -1
@@ -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
- const required = ["README.md", "docs/security.md", "docs/operations.md", "docs/quickstart.md", ...noteNames.filter((name) => name.endsWith(".md")).map((name) => `docs/agent-notes/${name}`)];
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
+ }
@@ -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: this.primed || !this.opts.preamble ? renderDigest(envs, this.primed) : `${this.opts.preamble}\n\n${renderDigest(envs, false)}` }] });
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.onMessage?.(body, { inReplyTo: replyParent(answering), to: replyAudience(answering) });
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.state !== "busy" || this.cancelWait) return;
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
- options = { projectRoot: cwd, stateDir, serverJs: bundleInStatus(status) ?? resolve(cliDir, "../../plugins/agent-hub/server.js"),
73
- hookScript: resolve(cliDir, "facts-hook.ts"), peer: "agy", launchId: admitted.launchId, instanceId: admitted.instanceId };
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
- // Only the observed native read is classified. Unobserved edits never receive a scoped grant or effect attribution.
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 it as offset/limit so it never counts as a whole-file view.
26
- const range = observedRead && Number.isSafeInteger(native?.StartLine) && (native!.StartLine as number) > 0
27
- ? { offset: native!.StartLine as number, ...(Number.isSafeInteger(native?.EndLine) && (native!.EndLine as number) >= (native!.StartLine as number) ? { limit: (native!.EndLine as number) - (native!.StartLine as number) + 1 } : {}) } : {};
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: observedRead ? "Read" : typeof call?.name === "string" ? call.name : "", input: observedRead ? { file_path: readPath, ...range } : native ?? {},
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
- // ponytail: completion lookback is capped at 4 MiB (as for Claude). A larger final row is unverified;
57
- // upgrade to a native completion cursor if that ceiling becomes insufficient.
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(0, stat.size - window), from = start > 0 ? start - 1 : 0;
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
- const out: NativeFactsTranscript = { factIds: [] };
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 === 0 || window === cap) return out;
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
  }