super-dsh 0.1.10 → 0.1.11

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 (50) hide show
  1. package/README.md +18 -2
  2. package/agent-agy/dist/agent.js +19 -1
  3. package/agent-claude/dist/adapter.js +106 -31
  4. package/agent-claude/dist/agent.js +177 -22
  5. package/agent-claude/dist/claude-client.js +84 -43
  6. package/agent-claude/dist/claude-launch.js +101 -0
  7. package/agent-claude/dist/claude-native-config.js +169 -0
  8. package/agent-claude/dist/claude-provider-gateway.js +340 -0
  9. package/agent-claude/dist/claude-providers.js +391 -0
  10. package/agent-claude/dist/claude-route.js +152 -0
  11. package/agent-claude/dist/index.js +214 -37
  12. package/agent-claude/dist/models.js +14 -0
  13. package/agent-claude/dist/sdk-runtime.js +320 -0
  14. package/agent-claude/package.json +6 -4
  15. package/agent-codex/README.md +18 -6
  16. package/agent-codex/dist/adapter.js +3 -2
  17. package/agent-codex/dist/agent.js +41 -7
  18. package/agent-codex/dist/app-server-client.js +7 -1
  19. package/agent-codex/dist/app-server-wire.js +10 -22
  20. package/agent-codex/dist/codex-launch.js +10 -32
  21. package/agent-codex/dist/codex-native-config.js +9 -3
  22. package/agent-codex/dist/codex-oauth.js +153 -0
  23. package/agent-codex/dist/codex-provider-gateway.js +36 -38
  24. package/agent-codex/dist/codex-providers.js +20 -7
  25. package/agent-codex/dist/codex-runtime.js +120 -0
  26. package/agent-codex/dist/index.js +18 -11
  27. package/agent-codex/package.json +1 -1
  28. package/agent-codex-sdk/dist/adapter.js +3 -2
  29. package/agent-codex-sdk/dist/agent.js +35 -1
  30. package/agent-codex-sdk/dist/codex-native-config.js +9 -3
  31. package/agent-codex-sdk/dist/codex-oauth.js +153 -0
  32. package/agent-codex-sdk/dist/codex-provider-gateway.js +36 -38
  33. package/agent-codex-sdk/dist/codex-providers.js +20 -7
  34. package/agent-codex-sdk/dist/codex-sdk-launch.js +5 -38
  35. package/agent-codex-sdk/dist/index.js +18 -11
  36. package/agent-codex-sdk/dist/sdk-runtime.js +2 -2
  37. package/agent-hermes/dist/agent.js +46 -2
  38. package/agent-hermes/dist/hermes-client.js +49 -1
  39. package/agent-omp/dist/agent.js +17 -4
  40. package/agent-omp-sdk/dist/agent.d.ts +11 -0
  41. package/agent-omp-sdk/dist/agent.js +17 -4
  42. package/agent-pi/dist/agent.js +33 -1
  43. package/client/index.js +150 -58
  44. package/package.json +2 -3
  45. package/superd-hub/dist/client/agent-settings.d.ts +66 -12
  46. package/superd-hub/dist/client/agent-settings.js +85 -14
  47. package/superd-hub/dist/client/runtime-settings-api.js +5 -5
  48. package/superd-hub/dist/world-mount.d.ts +6 -5
  49. package/superd-hub/dist/world-mount.js +6 -6
  50. package/superd-hub/lib/client/index.js +150 -58
package/README.md CHANGED
@@ -14,7 +14,7 @@ the foreign store.
14
14
  ## Install
15
15
 
16
16
  ```bash
17
- dsh plugin --profile <name> add super-dsh@0.1.0
17
+ dsh plugin --profile <name> add super-dsh@0.1.11
18
18
  ```
19
19
 
20
20
  That single package composes the whole line (`@pgmi-builds/superd-hub` plus the
@@ -27,12 +27,28 @@ home under `<DSH_HOME>/agents/<label>/` on first boot.
27
27
  | Label | Runtime | Integration |
28
28
  |---|---|---|
29
29
  | OMP | OMP (`~/.omp`) | host `omp` binary over RPC (`omp --mode rpc`, NDJSON v1), lazy per session |
30
- | Codex | Codex CLI (`~/.codex`) | adapter world plugin |
30
+ | Codex | Codex app-server (`~/.codex`) | desktop core first, CLI and bundled core fallback |
31
31
  | Claude | Claude Code (`~/.claude`) | standalone adapter, joined by the hub |
32
32
  | Pi | pi coding agent (`~/.pi`) | in-process SDK |
33
33
  | Hermes | Hermes (`~/.hermes`) | gateway child |
34
34
  | Antigravity | agy SDK | Python bridge (`bridge/agy_bridge.py`) |
35
35
 
36
+ ### Codex in 0.1.11
37
+
38
+ Codex discovers the local OpenAI OAuth login as a read-only provider in the
39
+ model selector. Selecting it explicitly overrides a third-party provider in the
40
+ native config, while credentials and native configuration stay in the native
41
+ home. Third-party providers remain selectable per session.
42
+
43
+ The adapter prefers the installed desktop core, then the CLI, then the bundled
44
+ 0.160.0 core (`CODEX_BIN` overrides discovery). Desktop host browser and computer
45
+ use services are separate and are not attached by this adapter.
46
+
47
+ **Known limitation:** returning a session with third-party reasoning history to
48
+ OpenAI OAuth can be rejected by the official endpoint. Use a separate official
49
+ session for now. Compaction before switching was proven in an isolated probe;
50
+ automatic compaction is not included in this release.
51
+
36
52
  ### Opt-in SDK halves
37
53
 
38
54
  Three runtimes also ship a preserved in-process SDK transport beside the primary
@@ -129,9 +129,27 @@ export class AgyAgent {
129
129
  this.closeStep();
130
130
  this.commitStepStart();
131
131
  const callId = ToolCallId(ev.id || `agy-${this.dashTurn}-${this.step}`);
132
+ const argumentsJson = ev.args || "{}";
133
+ // Advertisement-before-execution (validator invariant, 2026-10-04 dev3
134
+ // omp/hermes repair family): agy assistant frames carry only
135
+ // reasoning/text — never tool-call blocks — so every tool/call needs
136
+ // a minimal advertisement assistant frame first; arguments are copied
137
+ // verbatim into both frames (validator: lifecycle arguments ≡
138
+ // advertised, strict string equality).
139
+ const advertisement = createAssistantMessage({
140
+ content: [{ type: "tool-call", id: callId, name: ev.name, arguments: argumentsJson }],
141
+ source: {
142
+ provider: this.options.provider ?? "agy",
143
+ model: this.options.model ?? "",
144
+ },
145
+ });
146
+ this.session.append("assistant/message", {
147
+ turn: this.dashTurn, step: this.step,
148
+ message: advertisement, stream: [],
149
+ }, { surfaceOp: "append" });
132
150
  this.session.append("tool/call", {
133
151
  turn: this.dashTurn, step: this.step,
134
- callId, name: ev.name, arguments: ev.args || "{}",
152
+ callId, name: ev.name, arguments: argumentsJson,
135
153
  });
136
154
  this.unpairedCalls.push({ callId, name: ev.name });
137
155
  }
@@ -4,8 +4,23 @@
4
4
  * The browser model selector never touches the Claude agent directly: it is an
5
5
  * RPC round-trip through the apiproxy, which reads `ctx.llm.listProviders /
6
6
  * listModels / resolveModel` to build the catalog. This adapter answers those
7
- * queries from the models Claude actually reported (`models.ts`) under the
8
- * single provider route `claude`.
7
+ * queries from TWO sources, one route each (the codex twin's one-route-per-
8
+ * provider shape, 2026-10-04 F-A rework):
9
+ *
10
+ * - the fixed native route `claude` serves the OBSERVED catalog only (raw
11
+ * ids — what the CLI itself reports, `models.ts`); its group stays "Claude".
12
+ * - one route PER configured provider (route id = provider id) serves that
13
+ * provider's curated `models[]` (RAW model ids, `claude-providers.ts`).
14
+ *
15
+ * The route/model composition therefore yields selection ids identical to the
16
+ * old composite keys (`deepseek/deepseek-flash`, `glm/glm-5.3`), so route seeds
17
+ * and logged selections keep working — the route half simply moved from the
18
+ * hardcoded `claude` to the provider id itself.
19
+ *
20
+ * The curated half follows the codex twin's rule exactly: the store is read
21
+ * LIVE on every call, so a panel edit is visible to the next catalog query
22
+ * without re-registration. An absent `worldHome` means observed-only (the
23
+ * settings-UI round trip and the unit tests).
9
24
  *
10
25
  * The route id must be `claude`, NOT `claude-code`: the upstream package
11
26
  * `@deepseek-ai/dsh-subagent-claude-code` already occupies the subagent
@@ -14,47 +29,107 @@
14
29
  * `stream` is never dispatched: Claude owns generation through its own SDK
15
30
  * thread, so the adapter only serves catalog/metadata queries. It throws
16
31
  * rather than fabricating a wire route the harness would misuse.
32
+ *
33
+ * @module agent-claude/adapter
17
34
  */
18
35
  import { LlmAdapter, LlmError, ReasoningEffortId, } from "@deepseek-ai/dsh-llm";
36
+ import { readProviderStore } from "./claude-providers.js";
19
37
  import { readModelCatalog } from "./models.js";
20
- /** The one provider route every observed Claude model is served under. */
38
+ /** The fixed native route every observed Claude model is served under. */
21
39
  export const CLAUDE_PROVIDER_ID = "claude";
22
- /** Human-readable provider name for selectors and diagnostics. */
40
+ /** Human-readable provider name for the fixed native route. */
23
41
  const CLAUDE_PROVIDER_NAME = "Claude";
42
+ /** Whether a provider may be registered as a model route. */
43
+ export function routableProvider(entry) {
44
+ return entry.models.length > 0;
45
+ }
46
+ /** The route ids a store currently contributes to `ctx.llm`: the fixed `claude`
47
+ * route first, then one route per provider that has models (store order). */
48
+ export function routeIds(store) {
49
+ return [CLAUDE_PROVIDER_ID, ...store.providers.filter(routableProvider).map((entry) => entry.id)];
50
+ }
24
51
  export class ClaudeLlmAdapter extends LlmAdapter {
25
- providerInfo(_provider) {
26
- return { id: CLAUDE_PROVIDER_ID, name: CLAUDE_PROVIDER_NAME };
52
+ worldHome;
53
+ /**
54
+ * @param worldHome - the world's DSH home, holding the curated provider
55
+ * store in `<worldHome>/claude-providers.json`. Read on every call (the
56
+ * codex twin's rule), so a panel edit is visible to the next catalog query
57
+ * without re-registration. Absent = observed-only (the fixed `claude` route).
58
+ */
59
+ constructor(worldHome) {
60
+ super();
61
+ this.worldHome = worldHome;
62
+ }
63
+ /** The curated store entry a provider route id names, or `undefined`. */
64
+ curatedProvider(providerId) {
65
+ if (this.worldHome === undefined)
66
+ return undefined;
67
+ return readProviderStore(this.worldHome).providers.find((entry) => entry.id === providerId);
68
+ }
69
+ providerInfo(provider) {
70
+ if (provider === CLAUDE_PROVIDER_ID)
71
+ return { id: CLAUDE_PROVIDER_ID, name: CLAUDE_PROVIDER_NAME };
72
+ // The display name is the entry's label — for a provider folded in from the
73
+ // user's own settings that is its endpoint URL, the only name it supplies.
74
+ return { id: provider, name: this.curatedProvider(provider)?.label ?? provider };
27
75
  }
28
- async listModels(_provider) {
29
- return readModelCatalog().models.map((model) => ({
30
- provider: CLAUDE_PROVIDER_ID,
76
+ async listModels(provider) {
77
+ if (provider === CLAUDE_PROVIDER_ID) {
78
+ // Observed catalog only, raw ids exactly as the CLI reported them.
79
+ return readModelCatalog().models.map((model) => ({
80
+ provider: CLAUDE_PROVIDER_ID,
81
+ id: model.id,
82
+ name: model.label,
83
+ ...(model.description === undefined ? {} : { description: model.description }),
84
+ }));
85
+ }
86
+ const entry = this.curatedProvider(provider);
87
+ if (entry === undefined)
88
+ return [];
89
+ return entry.models.map((model) => ({
90
+ provider,
31
91
  id: model.id,
32
- name: model.label,
33
- ...(model.description === undefined ? {} : { description: model.description }),
92
+ name: model.displayName ?? model.id,
34
93
  }));
35
94
  }
36
- async resolveModel(_provider, model) {
37
- const found = readModelCatalog().models.find((candidate) => candidate.id === model);
38
- if (found === undefined) {
95
+ async resolveModel(provider, model) {
96
+ if (provider === CLAUDE_PROVIDER_ID) {
97
+ // Observed catalog (primary; unchanged behavior for the native route).
98
+ const found = readModelCatalog().models.find((candidate) => candidate.id === model);
99
+ if (found !== undefined) {
100
+ return {
101
+ provider: CLAUDE_PROVIDER_ID,
102
+ id: found.id,
103
+ name: found.label,
104
+ ...(found.description === undefined ? {} : { description: found.description }),
105
+ // Only a positive window is real metadata: an absent or zero window must
106
+ // never become `context: { contextWindow: 0 }`.
107
+ ...(found.contextWindow !== undefined && found.contextWindow > 0
108
+ ? { context: { contextWindow: found.contextWindow } }
109
+ : {}),
110
+ // The CLI's advertised effort levels become adapter-owned reasoning
111
+ // efforts (omp thinking.efforts parity); the CLI names no default, so
112
+ // none is invented.
113
+ ...(found.reasoningEfforts !== undefined && found.reasoningEfforts.length > 0
114
+ ? { reasoning: { efforts: found.reasoningEfforts.map((effort) => ({ id: ReasoningEffortId(effort), name: effort })) } }
115
+ : {}),
116
+ };
117
+ }
39
118
  throw new LlmError(`Claude provider "${CLAUDE_PROVIDER_ID}" does not serve model "${model}"`, "MODEL_NOT_FOUND");
40
119
  }
41
- return {
42
- provider: CLAUDE_PROVIDER_ID,
43
- id: found.id,
44
- name: found.label,
45
- ...(found.description === undefined ? {} : { description: found.description }),
46
- // Only a positive window is real metadata: an absent or zero window must
47
- // never become `context: { contextWindow: 0 }`.
48
- ...(found.contextWindow !== undefined && found.contextWindow > 0
49
- ? { context: { contextWindow: found.contextWindow } }
50
- : {}),
51
- // The CLI's advertised effort levels become adapter-owned reasoning
52
- // efforts (omp thinking.efforts parity); the CLI names no default, so
53
- // none is invented.
54
- ...(found.reasoningEfforts !== undefined && found.reasoningEfforts.length > 0
55
- ? { reasoning: { efforts: found.reasoningEfforts.map((effort) => ({ id: ReasoningEffortId(effort), name: effort })) } }
56
- : {}),
57
- };
120
+ const entry = this.curatedProvider(provider);
121
+ const curated = entry?.models.find((candidate) => candidate.id === model);
122
+ if (entry !== undefined && curated !== undefined) {
123
+ return {
124
+ provider,
125
+ id: curated.id,
126
+ name: curated.displayName ?? curated.id,
127
+ ...(curated.contextWindow !== undefined && curated.contextWindow > 0
128
+ ? { context: { contextWindow: curated.contextWindow } }
129
+ : {}),
130
+ };
131
+ }
132
+ throw new LlmError(`Claude provider "${provider}" does not serve model "${model}"`, "MODEL_NOT_FOUND");
58
133
  }
59
134
  stream(_options) {
60
135
  throw new LlmError("the Claude adapter does not stream: Claude owns generation through its SDK thread", "UNSUPPORTED_STREAM");
@@ -39,11 +39,14 @@
39
39
  */
40
40
  import { randomUUID } from "node:crypto";
41
41
  import { agentEvents } from "@deepseek-ai/dsh-agent";
42
+ import { canonicalHeader, headerEquals } from "@deepseek-ai/dsh-session";
42
43
  import { LlmAttemptId, ToolCallId, createAssistantMessage, createSystemMessage, createToolResultMessage, createUserMessage, } from "@deepseek-ai/dsh-llm";
43
44
  import { createScope } from "@deepseek-ai/dsh-scope";
44
45
  import { toClaudeContent } from "./content.js";
45
46
  import { Inbox } from "./inbox.js";
46
47
  import { resetProjectionState } from "./claude-events.js";
48
+ import { NATIVE_LAUNCH } from "./claude-launch.js";
49
+ import { resolveRoute, routeSeed } from "./claude-route.js";
47
50
  import { CLAUDE_PROVIDER_ID } from "./adapter.js";
48
51
  import { claudePermissionMode, presetFromEvents } from "./permission.js";
49
52
  import { catalogFromInit, modelEntryFromSdk, readModelCatalog, setModelCatalog } from "./models.js";
@@ -235,6 +238,9 @@ export class ClaudeAgent {
235
238
  inbox;
236
239
  ctx;
237
240
  client;
241
+ /** Unsubscriber for the LIVE client's wire listener — released before a swap closes the old child. */
242
+ clientUnsubscribe = undefined;
243
+ clientFactory;
238
244
  loopCtx;
239
245
  scope;
240
246
  dispatch;
@@ -262,7 +268,12 @@ export class ClaudeAgent {
262
268
  systemMessageDone = false;
263
269
  /** Last `request/context` route key (`provider/model`) — dedupes per-turn emits. */
264
270
  lastRouteKey = null;
265
- /** Live v2 assistant-stream publication for the in-flight wire message. */
271
+ /** The route the OPEN turn runs, captured when the turn opened. */
272
+ turnRoute = undefined;
273
+ /** The route the LIVE client was last told to run (`provider/model`) — a work-saver. */
274
+ routeKey = null;
275
+ /** Whether THIS agent instance has already logged a `request/header` snapshot. */
276
+ requestHeaderLogged = false;
266
277
  streamBridge;
267
278
  assistantAttemptCounter = 0;
268
279
  assistantStreamRevision = 0;
@@ -295,12 +306,13 @@ export class ClaudeAgent {
295
306
  commandsSignature = "";
296
307
  /** Releases the current mirror before it is rebuilt or the agent is disposed. */
297
308
  commandsDisposer = undefined;
298
- constructor(loopCtx, id, options, session, client, onIdleExit, runtimeInfo) {
309
+ constructor(loopCtx, id, options, session, client, onIdleExit, runtimeInfo, clientFactory) {
299
310
  this.loopCtx = loopCtx;
300
311
  this.id = id;
301
312
  this.options = options;
302
313
  this.session = withSystemHead(session, () => createSystemMessage(""));
303
314
  this.client = client;
315
+ this.clientFactory = clientFactory;
304
316
  this.onIdleExit = onIdleExit;
305
317
  this.runtimeInfo = runtimeInfo;
306
318
  this.dispatch = agentEvents(loopCtx, this);
@@ -318,7 +330,7 @@ export class ClaudeAgent {
318
330
  this.attachCommandRuntime(commandCtx.commands);
319
331
  });
320
332
  this.lastTurn = session.snapshotEvents().findLast((event) => event.type === "turn/start")?.data.turn ?? 0;
321
- client.on((event) => this.handleEvent(event));
333
+ this.clientUnsubscribe = client.on((event) => this.handleEvent(event));
322
334
  // Session-start projection reset: the module-level tool-name map must never
323
335
  // leak another session's tool into this one (Task 4 review finding).
324
336
  resetProjectionState();
@@ -420,6 +432,12 @@ export class ClaudeAgent {
420
432
  }
421
433
  this.markIdle();
422
434
  this.endActivity();
435
+ // Release the live client's wire listener BEFORE its child closes (Task 9
436
+ // ledger): without this the dispose path can still receive the superseded
437
+ // client's stale `agent_end` — the same bug class `reattach()` guards
438
+ // against on the swap path.
439
+ this.clientUnsubscribe?.();
440
+ this.clientUnsubscribe = undefined;
423
441
  this.client.close();
424
442
  resetProjectionState();
425
443
  // Release the command mirror explicitly (the agent scope would also dispose
@@ -618,8 +636,14 @@ export class ClaudeAgent {
618
636
  * than hanging, then unwinds the reservation.
619
637
  */
620
638
  async startTurn(message) {
639
+ let route;
621
640
  try {
622
- await this.client.ensureStarted();
641
+ // Resolve the route and put it in force BEFORE the client is touched: a
642
+ // provider move is a different child, and a mid-turn selection change must
643
+ // land on the NEXT turn's applyRoute — never kill the running turn.
644
+ route = this.resolveRoute();
645
+ await this.applyRoute(route);
646
+ this.turnRoute = route;
623
647
  await this.bootstrapSessionIdentity();
624
648
  }
625
649
  catch (error) {
@@ -637,9 +661,11 @@ export class ClaudeAgent {
637
661
  if (this.disposed)
638
662
  return;
639
663
  this.session.append("turn/start", { turn: this.dashTurn });
640
- // Route metadata for the next request (`request/context`) lands before
641
- // the turn's messages, exactly as the reference loop stamps it.
642
- this.emitRequestContext();
664
+ // Route metadata for the next request lands before the turn's messages,
665
+ // exactly as the reference loop stamps it.
666
+ if (route !== undefined)
667
+ this.commitRequestHeader(route);
668
+ this.emitRequestContext(route);
643
669
  this.appendUserMessage(message);
644
670
  // Flush any idle-injection that was parked before this turn.
645
671
  for (const entry of this.remoteQueue.splice(0)) {
@@ -743,25 +769,132 @@ export class ClaudeAgent {
743
769
  * effective provider/model plus the model's context window when genuinely
744
770
  * known. Turn-enclosed (no step required) and deduped by route key.
745
771
  */
746
- emitRequestContext() {
772
+ emitRequestContext(route) {
747
773
  if (!this.turnOpen)
748
774
  return;
749
- const model = this.observedMessageModel || this.observedDefaultModel || this.options.model || "";
775
+ // A resolved route names the raw provider/model pair (the route the selector
776
+ // picked); a missing route falls back to the observed model (the degraded
777
+ // single-provider shape), preserving the pre-multi-provider metadata.
778
+ const model = route !== undefined
779
+ ? route.model
780
+ : (this.observedMessageModel || this.observedDefaultModel || this.options.model || "");
750
781
  if (model === "")
751
782
  return;
752
- const key = `${CLAUDE_PROVIDER_ID}/${model}`;
783
+ const provider = route?.provider ?? CLAUDE_PROVIDER_ID;
784
+ const key = `${provider}/${model}`;
753
785
  if (key === this.lastRouteKey)
754
786
  return;
755
787
  this.lastRouteKey = key;
756
- const entry = readModelCatalog().models.find((candidate) => candidate.id === model);
757
- const contextWindow = entry?.contextWindow;
788
+ const contextWindow = route !== undefined
789
+ ? (this.runtimeInfo?.contextWindowFor?.(route.provider, route.model)
790
+ ?? readModelCatalog().models.find((candidate) => candidate.id === route.model)?.contextWindow)
791
+ : undefined;
758
792
  this.session.append("request/context", {
759
- provider: CLAUDE_PROVIDER_ID,
793
+ provider,
760
794
  model,
761
795
  ...(contextWindow !== undefined && contextWindow > 0 ? { contextWindow } : {}),
762
796
  });
763
797
  trace(`request/context ${key}${contextWindow === undefined ? "" : ` ctx=${contextWindow}`}`);
764
798
  }
799
+ /**
800
+ * Resolve the route this session must run, mirroring the harness's own
801
+ * precedence (`pending ?? logged ?? preferred ?? fallback`). Each candidate is
802
+ * already a raw provider/model pair — the provider IS the route id and the
803
+ * model IS the raw wire id (the codex twin's shape). A provider id with no
804
+ * store entry reads as "launch native, inject nothing".
805
+ */
806
+ resolveRoute() {
807
+ const projections = this.loopCtx.get("sessionProjections");
808
+ const service = this.ctx.get("agentDefaultModel");
809
+ return resolveRoute({
810
+ pending: projections?.stateOf?.(this.session, "modelSelection")?.pending,
811
+ logged: this.session.requestHeader()?.config,
812
+ preferred: service?.currentSelection?.(),
813
+ fallback: routeSeed(this.options),
814
+ });
815
+ }
816
+ /**
817
+ * Put the resolved route in force on the client, swapping the child when the
818
+ * provider moved. Three cases, in increasing cost:
819
+ * 1. nothing spawned yet — pin the launch and let the first prompt spawn
820
+ * (the lazy-spawn gate: choosing a model must not cost a process);
821
+ * 2. same provider — `setModel` rides the live query with the RAW id;
822
+ * 3. different provider — the child is replaced (`reattach`).
823
+ */
824
+ async applyRoute(route) {
825
+ const launch = route === undefined ? undefined : this.runtimeInfo?.launchFor?.(route.provider, route.model);
826
+ // An unknown provider id (a legacy session pinned before the multi-provider
827
+ // rework) maps to "no injection": the user's own settings.json decides.
828
+ const launchSpec = launch ?? NATIVE_LAUNCH;
829
+ const providerId = launch === undefined ? null : route?.provider ?? null;
830
+ const client = this.client;
831
+ const key = `${providerId ?? "native"}/${route?.model ?? ""}`;
832
+ if (!client.spawned) {
833
+ client.setLaunch(launchSpec);
834
+ await client.ensureStarted();
835
+ }
836
+ else if ((client.appliedProvider ?? null) !== providerId) {
837
+ await this.reattach(launchSpec, providerId, route);
838
+ }
839
+ else if (route !== undefined) {
840
+ await client.setModel(route.model).catch((error) => {
841
+ trace(`set_model ${key} failed: ${String(error)}`);
842
+ });
843
+ }
844
+ this.routeKey = key;
845
+ }
846
+ /**
847
+ * Replace the client at a turn boundary: close the old one and build a fresh
848
+ * child through the injected factory, resuming the SAME SDK session id and
849
+ * re-wiring the event listener and the live permission mode.
850
+ */
851
+ async reattach(launch, _providerId, _route) {
852
+ const old = this.client;
853
+ const threadId = old.threadId;
854
+ this.clientUnsubscribe?.();
855
+ old.close();
856
+ if (this.clientFactory === undefined) {
857
+ throw new Error("agent-claude: cannot reattach — no clientFactory was injected");
858
+ }
859
+ const replacement = this.clientFactory({ ...(threadId !== null ? { resumeSessionId: threadId } : {}), launch });
860
+ this.clientUnsubscribe = replacement.on((event) => this.handleEvent(event));
861
+ this.client = replacement;
862
+ // Re-apply the live permission mode onto the fresh child (the old client's
863
+ // buffered control calls died with it).
864
+ const mode = this.runtimeInfo?.claudeMode ?? claudePermissionMode(presetFromEvents(this.session.snapshotEvents()));
865
+ if (mode !== undefined)
866
+ void replacement.setPermissionMode(mode).catch(() => { });
867
+ await replacement.ensureStarted();
868
+ }
869
+ /**
870
+ * Log the request header the NEXT request runs under, so the harness's
871
+ * `modelSelection` projection retires a pending switch when it matches.
872
+ */
873
+ commitRequestHeader(route) {
874
+ const header = canonicalHeader({
875
+ config: {
876
+ provider: route.provider,
877
+ model: route.model,
878
+ },
879
+ });
880
+ const baseline = this.session.requestHeader();
881
+ if (!this.requestHeaderLogged) {
882
+ this.session.append("request/header", { header, reason: baseline === undefined ? "initial" : "resume" });
883
+ this.requestHeaderLogged = true;
884
+ return;
885
+ }
886
+ if (baseline === undefined || !headerEquals(baseline, header)) {
887
+ this.session.append("request/header", { header, reason: "change" });
888
+ }
889
+ }
890
+ /** Test-only thin shell over {@link applyRoute}. */
891
+ async applyRouteForTest(route) {
892
+ await this.applyRoute(route);
893
+ }
894
+ /** Test-only thin shell over {@link resolveRoute}. */
895
+ resolveRouteForTest() {
896
+ return this.resolveRoute();
897
+ }
765
898
  /** The open stream bridge for this message, starting one on first use. */
766
899
  ensureStreamBridge() {
767
900
  if (this.streamBridge === undefined || this.streamBridge.ended) {
@@ -836,8 +969,12 @@ export class ClaudeAgent {
836
969
  * projection carried — so the `tool_start` flush lands a same-attempt
837
970
  * sample and nothing ever stashes across turns.
838
971
  */
839
- flushAssistant(usage) {
840
- if (this.reasoningBuffer === "" && this.textBuffer === "") {
972
+ flushAssistant(usage, toolCall) {
973
+ // A toolCall rides the SAME frame (advertise+usage on one attempt): the
974
+ // token-meter fold requires every step's assistant frame to carry a usage
975
+ // sample, so a separate no-usage advertisement frame would turn the
976
+ // turn's whole usage disclosure undefined.
977
+ if (toolCall === undefined && this.reasoningBuffer === "" && this.textBuffer === "") {
841
978
  if (usage !== undefined)
842
979
  trace("flush had usage but no buffered content — sample dropped (no message carries it)");
843
980
  return;
@@ -847,6 +984,9 @@ export class ClaudeAgent {
847
984
  blocks.push({ type: "reasoning", text: this.reasoningBuffer });
848
985
  if (this.textBuffer !== "")
849
986
  blocks.push({ type: "text", text: this.textBuffer });
987
+ if (toolCall !== undefined) {
988
+ blocks.push({ type: "tool-call", id: ToolCallId(toolCall.callId), name: toolCall.name, arguments: toolCall.arguments });
989
+ }
850
990
  this.reasoningBuffer = "";
851
991
  this.textBuffer = "";
852
992
  const effectiveUsage = usage ?? this.capturedUsage;
@@ -856,7 +996,7 @@ export class ClaudeAgent {
856
996
  const assistant = createAssistantMessage({
857
997
  content: blocks,
858
998
  // The OBSERVED model (what the CLI actually ran), not the requested one.
859
- source: { provider: CLAUDE_PROVIDER_ID, model: this.observedMessageModel || this.observedDefaultModel || this.options.model || "" },
999
+ source: { provider: this.turnRoute?.provider ?? CLAUDE_PROVIDER_ID, model: this.observedMessageModel || this.observedDefaultModel || this.options.model || "" },
860
1000
  });
861
1001
  const event = this.session.append("assistant/message", {
862
1002
  turn: this.dashTurn,
@@ -873,13 +1013,23 @@ export class ClaudeAgent {
873
1013
  this.streamBridge = undefined;
874
1014
  }
875
1015
  appendToolCall(callId, name, args) {
876
- this.commitStepStart();
1016
+ // The tool-call block rides the SAME assistant/message as any pending
1017
+ // text (native advertise+usage shape): one attempt per step with the
1018
+ // usage sample AND the advertisement on one frame. A separate no-usage
1019
+ // advertisement frame would satisfy the format validator but break the
1020
+ // token-meter fold (every step's assistant frame needs a usage sample).
1021
+ const argumentsJson = JSON.stringify(args ?? {});
1022
+ this.flushAssistant(undefined, {
1023
+ callId,
1024
+ name,
1025
+ arguments: argumentsJson,
1026
+ });
877
1027
  this.session.append("tool/call", {
878
1028
  turn: this.dashTurn,
879
1029
  step: this.step,
880
1030
  callId: ToolCallId(callId),
881
1031
  name,
882
- arguments: JSON.stringify(args ?? {}),
1032
+ arguments: argumentsJson,
883
1033
  });
884
1034
  }
885
1035
  appendToolResult(callId, content, isError) {
@@ -905,13 +1055,17 @@ export class ClaudeAgent {
905
1055
  observeModelCatalog(init) {
906
1056
  if (typeof init.model === "string" && init.model !== "")
907
1057
  this.observedDefaultModel = init.model;
1058
+ // The observed catalog stays RAW: the fixed `claude` route serves exactly
1059
+ // what the CLI reported, and curated provider models live under their own
1060
+ // routes (2026-10-04 F-A) — no composite minting here.
908
1061
  const entries = catalogFromInit(init);
909
1062
  if (entries.length > 0)
910
1063
  setModelCatalog(entries, this.observedDefaultModel);
911
1064
  void this.client.supportedModels().then((models) => {
912
1065
  const full = Array.isArray(models) ? models.flatMap((m) => modelEntryFromSdk(m)) : [];
913
- if (full.length > 0)
914
- setModelCatalog(full, this.observedDefaultModel);
1066
+ if (full.length === 0)
1067
+ return;
1068
+ setModelCatalog(full, this.observedDefaultModel);
915
1069
  }).catch((error) => {
916
1070
  trace(`supportedModels() failed: ${String(error)}`);
917
1071
  });
@@ -1056,7 +1210,8 @@ export class ClaudeAgent {
1056
1210
  // RC-6: the mid-turn flush closes the attempt WITH the sample the
1057
1211
  // message itself reported (captured above / on the assistant events).
1058
1212
  this.captureProvenance(event);
1059
- this.flushAssistant();
1213
+ // flushes pending text WITH the tool-call block as one assistant
1214
+ // message (advertise+usage on one attempt — see appendToolCall).
1060
1215
  this.appendToolCall(callId, name, event.arguments);
1061
1216
  break;
1062
1217
  }
@@ -1133,7 +1288,7 @@ export class ClaudeAgent {
1133
1288
  this.commitStepStart();
1134
1289
  const assistant = createAssistantMessage({
1135
1290
  content: blocks,
1136
- source: { provider: CLAUDE_PROVIDER_ID, model: String(event.fallbackModel ?? this.options.model ?? "") },
1291
+ source: { provider: this.turnRoute?.provider ?? CLAUDE_PROVIDER_ID, model: String(event.fallbackModel ?? this.options.model ?? "") },
1137
1292
  });
1138
1293
  this.session.append("assistant/message", {
1139
1294
  turn: this.dashTurn,