super-dsh 0.1.10 → 0.1.11-a
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/README.md +38 -2
- package/agent-agy/dist/agent.js +19 -1
- package/agent-claude/dist/adapter.js +106 -31
- package/agent-claude/dist/agent.js +177 -22
- package/agent-claude/dist/claude-client.js +84 -43
- package/agent-claude/dist/claude-launch.js +101 -0
- package/agent-claude/dist/claude-native-config.js +169 -0
- package/agent-claude/dist/claude-provider-gateway.js +340 -0
- package/agent-claude/dist/claude-providers.js +391 -0
- package/agent-claude/dist/claude-route.js +152 -0
- package/agent-claude/dist/index.js +214 -37
- package/agent-claude/dist/models.js +14 -0
- package/agent-claude/dist/sdk-runtime.js +320 -0
- package/agent-claude/package.json +6 -4
- package/agent-codex/dist/adapter.js +3 -2
- package/agent-codex/dist/agent.js +41 -7
- package/agent-codex/dist/app-server-client.js +9 -2
- package/agent-codex/dist/app-server-protocol.js +6 -2
- package/agent-codex/dist/app-server-wire.js +29 -24
- package/agent-codex/dist/codex-launch.js +10 -32
- package/agent-codex/dist/codex-native-config.js +9 -3
- package/agent-codex/dist/codex-oauth.js +153 -0
- package/agent-codex/dist/codex-provider-gateway.js +36 -38
- package/agent-codex/dist/codex-providers.js +20 -7
- package/agent-codex/dist/codex-runtime-fetch.js +293 -0
- package/agent-codex/dist/codex-runtime.js +120 -0
- package/agent-codex/dist/index.js +20 -12
- package/agent-codex/package.json +8 -3
- package/agent-codex-sdk/dist/adapter.js +3 -2
- package/agent-codex-sdk/dist/agent.js +35 -1
- package/agent-codex-sdk/dist/codex-native-config.js +9 -3
- package/agent-codex-sdk/dist/codex-oauth.js +153 -0
- package/agent-codex-sdk/dist/codex-provider-gateway.js +36 -38
- package/agent-codex-sdk/dist/codex-providers.js +20 -7
- package/agent-codex-sdk/dist/codex-sdk-launch.js +5 -38
- package/agent-codex-sdk/dist/index.js +18 -11
- package/agent-codex-sdk/dist/sdk-runtime.js +3 -3
- package/agent-hermes/dist/agent.js +46 -2
- package/agent-hermes/dist/hermes-client.js +49 -1
- package/agent-omp/dist/agent.js +17 -4
- package/agent-omp-sdk/dist/agent.d.ts +11 -0
- package/agent-omp-sdk/dist/agent.js +17 -4
- package/agent-pi/dist/agent.js +33 -1
- package/client/index.js +150 -58
- package/package.json +7 -6
- package/superd-hub/dist/client/agent-settings.d.ts +66 -12
- package/superd-hub/dist/client/agent-settings.js +85 -14
- package/superd-hub/dist/client/runtime-settings-api.js +5 -5
- package/superd-hub/dist/connection-trust.d.ts +8 -0
- package/superd-hub/dist/connection-trust.js +16 -0
- package/superd-hub/dist/web-trust.d.ts +14 -42
- package/superd-hub/dist/web-trust.js +18 -43
- package/superd-hub/dist/world-mount.d.ts +6 -5
- package/superd-hub/dist/world-mount.js +6 -6
- package/superd-hub/lib/client/index.js +150 -58
- package/superd-hub/package.json +5 -1
- package/agent-codex/README.md +0 -179
- package/agent-codex-sdk/README.md +0 -79
- package/agent-hermes/README.md +0 -145
- package/agent-omp/README.md +0 -94
- package/agent-omp-sdk/README.md +0 -103
- package/agent-pi/README.md +0 -67
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.
|
|
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
|
|
@@ -22,17 +22,53 @@ six primary `@pgmi-builds/agent-*` packages and three OPTIONAL SDK halves).
|
|
|
22
22
|
Restart the profile and the
|
|
23
23
|
home under `<DSH_HOME>/agents/<label>/` on first boot.
|
|
24
24
|
|
|
25
|
+
## Remote web access
|
|
26
|
+
|
|
27
|
+
To use the same authenticated UI through a reverse proxy or LAN relay, enable
|
|
28
|
+
this profile patch:
|
|
29
|
+
|
|
30
|
+
```yaml
|
|
31
|
+
- id: superd-web-trust
|
|
32
|
+
config:
|
|
33
|
+
trustAllHosts: true
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
This skips Host/Origin restrictions and enables browser ownership for every
|
|
37
|
+
hostname. Native token-to-cookie login, cookie expiry, and authority binding
|
|
38
|
+
remain in effect. The default is `false`, preserving declared-authority mode.
|
|
39
|
+
It does not change the server's listening address: keep loopback behind Caddy
|
|
40
|
+
and a LAN relay. The test launcher enables this mode without `--host` or
|
|
41
|
+
`--trusted-host` and starts the relay on every test port, including 4999.
|
|
42
|
+
|
|
25
43
|
## Runtimes
|
|
26
44
|
|
|
27
45
|
| Label | Runtime | Integration |
|
|
28
46
|
|---|---|---|
|
|
29
47
|
| OMP | OMP (`~/.omp`) | host `omp` binary over RPC (`omp --mode rpc`, NDJSON v1), lazy per session |
|
|
30
|
-
| Codex | Codex
|
|
48
|
+
| Codex | Codex app-server (`~/.codex`) | desktop core first, CLI and bundled core fallback |
|
|
31
49
|
| Claude | Claude Code (`~/.claude`) | standalone adapter, joined by the hub |
|
|
32
50
|
| Pi | pi coding agent (`~/.pi`) | in-process SDK |
|
|
33
51
|
| Hermes | Hermes (`~/.hermes`) | gateway child |
|
|
34
52
|
| Antigravity | agy SDK | Python bridge (`bridge/agy_bridge.py`) |
|
|
35
53
|
|
|
54
|
+
### Codex in 0.1.11
|
|
55
|
+
|
|
56
|
+
Codex discovers the local OpenAI OAuth login as a read-only provider in the
|
|
57
|
+
model selector. Selecting it explicitly overrides a third-party provider in the
|
|
58
|
+
native config, while credentials and native configuration stay in the native
|
|
59
|
+
home. Third-party providers remain selectable per session.
|
|
60
|
+
|
|
61
|
+
The adapter prefers the installed desktop core, then the CLI, then the bundled
|
|
62
|
+
0.160.0 core (`CODEX_BIN` overrides discovery). Desktop host browser and computer
|
|
63
|
+
use services are separate and are not attached by this adapter.
|
|
64
|
+
|
|
65
|
+
**Provider compatibility:** each provider defines its own message, reasoning,
|
|
66
|
+
and encrypted-state formats. Selecting another provider does not guarantee
|
|
67
|
+
that it can consume the existing session history. Successful continuation
|
|
68
|
+
across endpoints is an observed convenience, not an adapter guarantee.
|
|
69
|
+
The adapter surfaces provider errors; if the selected provider rejects the
|
|
70
|
+
history, start a new session with that provider.
|
|
71
|
+
|
|
36
72
|
### Opt-in SDK halves
|
|
37
73
|
|
|
38
74
|
Three runtimes also ship a preserved in-process SDK transport beside the primary
|
package/agent-agy/dist/agent.js
CHANGED
|
@@ -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:
|
|
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
|
|
8
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
26
|
-
|
|
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(
|
|
29
|
-
|
|
30
|
-
|
|
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.
|
|
33
|
-
...(model.description === undefined ? {} : { description: model.description }),
|
|
92
|
+
name: model.displayName ?? model.id,
|
|
34
93
|
}));
|
|
35
94
|
}
|
|
36
|
-
async resolveModel(
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
641
|
-
//
|
|
642
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
757
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
914
|
-
|
|
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
|
-
|
|
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,
|