super-dsh 0.1.0 → 0.1.2
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/agent-agy/bridge/agy_bridge.py +250 -0
- package/agent-agy/bridge/gemini_openai_server.mjs +98 -0
- package/agent-agy/cordis.patch.yml +49 -0
- package/agent-agy/dist/adapter.d.ts +15 -0
- package/agent-agy/dist/adapter.js +42 -0
- package/agent-agy/dist/agent-preset-agy.d.ts +47 -0
- package/agent-agy/dist/agent-preset-agy.js +169 -0
- package/agent-agy/dist/agent-preset-projection.d.ts +31 -0
- package/agent-agy/dist/agent-preset-projection.js +33 -0
- package/agent-agy/dist/agent.d.ts +137 -0
- package/agent-agy/dist/agent.js +493 -0
- package/agent-agy/dist/agy-cli-client.d.ts +137 -0
- package/agent-agy/dist/agy-cli-client.js +301 -0
- package/agent-agy/dist/agy-client.d.ts +160 -0
- package/agent-agy/dist/agy-client.js +252 -0
- package/agent-agy/dist/agy-sessions.d.ts +24 -0
- package/agent-agy/dist/agy-sessions.js +116 -0
- package/agent-agy/dist/agy-store.d.ts +39 -0
- package/agent-agy/dist/agy-store.js +83 -0
- package/agent-agy/dist/inbox.d.ts +26 -0
- package/agent-agy/dist/inbox.js +53 -0
- package/agent-agy/dist/index.d.ts +80 -0
- package/agent-agy/dist/index.js +505 -0
- package/agent-agy/dist/models.d.ts +41 -0
- package/agent-agy/dist/models.js +74 -0
- package/agent-agy/dist/world-plugin.d.ts +9 -0
- package/agent-agy/dist/world-plugin.js +97 -0
- package/agent-agy/package.json +64 -0
- package/agent-claude/cordis.patch.yml +42 -0
- package/agent-claude/dist/adapter.js +62 -0
- package/agent-claude/dist/agent-preset-claude.js +169 -0
- package/agent-claude/dist/agent-preset-projection.js +30 -0
- package/agent-claude/dist/agent.js +1271 -0
- package/agent-claude/dist/claude-client.js +357 -0
- package/agent-claude/dist/claude-events.js +223 -0
- package/agent-claude/dist/claude-home.js +29 -0
- package/agent-claude/dist/commands.js +182 -0
- package/agent-claude/dist/content.js +134 -0
- package/agent-claude/dist/inbox.js +53 -0
- package/agent-claude/dist/index.js +555 -0
- package/agent-claude/dist/input-queue.js +64 -0
- package/agent-claude/dist/interaction.js +271 -0
- package/agent-claude/dist/models.js +158 -0
- package/agent-claude/dist/permission.js +120 -0
- package/agent-claude/dist/session-id.js +20 -0
- package/agent-claude/dist/session-map.js +165 -0
- package/agent-claude/dist/world-plugin.js +14 -0
- package/agent-claude/package.json +74 -0
- package/agent-codex/README.md +74 -0
- package/agent-codex/cordis.patch.yml +77 -0
- package/agent-codex/dist/adapter.js +55 -0
- package/agent-codex/dist/agent-preset-codex.js +171 -0
- package/agent-codex/dist/agent-preset-projection.js +37 -0
- package/agent-codex/dist/agent.js +1056 -0
- package/agent-codex/dist/app-home.js +65 -0
- package/agent-codex/dist/codex-client.js +469 -0
- package/agent-codex/dist/codex-events.js +297 -0
- package/agent-codex/dist/codex-store.js +190 -0
- package/agent-codex/dist/inbox.js +53 -0
- package/agent-codex/dist/index.js +605 -0
- package/agent-codex/dist/models.js +152 -0
- package/agent-codex/dist/permission.js +71 -0
- package/agent-codex/dist/session-map.js +170 -0
- package/agent-codex/dist/spike.js +46 -0
- package/agent-codex/dist/world-plugin.js +96 -0
- package/agent-codex/package.json +78 -0
- package/agent-hermes/README.md +144 -0
- package/agent-hermes/cordis.patch.yml +83 -0
- package/agent-hermes/dist/adapter.js +83 -0
- package/agent-hermes/dist/agent-preset-hermes.js +171 -0
- package/agent-hermes/dist/agent-preset-projection.js +34 -0
- package/agent-hermes/dist/agent.js +1068 -0
- package/agent-hermes/dist/hermes-client.js +953 -0
- package/agent-hermes/dist/hermes-events.js +289 -0
- package/agent-hermes/dist/hermes-store.js +158 -0
- package/agent-hermes/dist/inbox.js +53 -0
- package/agent-hermes/dist/index.js +541 -0
- package/agent-hermes/dist/models.js +324 -0
- package/agent-hermes/dist/permission.js +60 -0
- package/agent-hermes/dist/world-plugin.js +101 -0
- package/agent-hermes/package.json +76 -0
- package/agent-hub/cordis.patch.yml +12 -0
- package/agent-hub/dist/agent-roster.d.ts +34 -0
- package/agent-hub/dist/agent-roster.js +27 -0
- package/agent-hub/dist/carrier.d.ts +43 -0
- package/agent-hub/dist/carrier.js +241 -0
- package/agent-hub/dist/client-shim.d.ts +28 -0
- package/agent-hub/dist/client-shim.js +251 -0
- package/agent-hub/dist/envelope.d.ts +54 -0
- package/agent-hub/dist/envelope.js +92 -0
- package/agent-hub/dist/gateway.d.ts +28 -0
- package/agent-hub/dist/gateway.js +18 -0
- package/agent-hub/dist/index-pass.d.ts +22 -0
- package/agent-hub/dist/index-pass.js +47 -0
- package/agent-hub/dist/index.d.ts +29 -0
- package/agent-hub/dist/index.js +29 -0
- package/agent-hub/dist/labels.d.ts +45 -0
- package/agent-hub/dist/labels.js +83 -0
- package/agent-hub/dist/ownership.d.ts +37 -0
- package/agent-hub/dist/ownership.js +54 -0
- package/agent-hub/dist/roster.d.ts +28 -0
- package/agent-hub/dist/roster.js +38 -0
- package/agent-hub/dist/rpc.d.ts +38 -0
- package/agent-hub/dist/rpc.js +52 -0
- package/agent-hub/dist/spawn-world.d.ts +46 -0
- package/agent-hub/dist/spawn-world.js +72 -0
- package/agent-hub/dist/targets.d.ts +33 -0
- package/agent-hub/dist/targets.js +30 -0
- package/agent-hub/dist/world-entry.d.ts +31 -0
- package/agent-hub/dist/world-entry.js +51 -0
- package/agent-hub/dist/world-host.d.ts +69 -0
- package/agent-hub/dist/world-host.js +44 -0
- package/agent-hub/dist/world-join.d.ts +16 -0
- package/agent-hub/dist/world-join.js +122 -0
- package/agent-hub/dist/world-mount.d.ts +52 -0
- package/agent-hub/dist/world-mount.js +52 -0
- package/agent-hub/dist/world-mux.d.ts +72 -0
- package/agent-hub/dist/world-mux.js +224 -0
- package/agent-hub/dist/world-provision.d.ts +20 -0
- package/agent-hub/dist/world-provision.js +122 -0
- package/agent-hub/dist/world-web-server.d.ts +162 -0
- package/agent-hub/dist/world-web-server.js +248 -0
- package/agent-hub/lib/client/index.js +242 -0
- package/agent-hub/package.json +65 -0
- package/agent-omp/README.md +77 -0
- package/agent-omp/cordis.patch.yml +92 -0
- package/agent-omp/dist/adapter.js +36 -0
- package/agent-omp/dist/agent-preset-omp.js +171 -0
- package/agent-omp/dist/agent-preset-projection.js +36 -0
- package/agent-omp/dist/agent.js +1145 -0
- package/agent-omp/dist/app-home.js +86 -0
- package/agent-omp/dist/discovery.js +116 -0
- package/agent-omp/dist/inbox.js +53 -0
- package/agent-omp/dist/index.js +574 -0
- package/agent-omp/dist/knobs.js +50 -0
- package/agent-omp/dist/lazy-rpc.js +161 -0
- package/agent-omp/dist/mobile/gesture.js +148 -0
- package/agent-omp/dist/mobile/zoom-guard.js +294 -0
- package/agent-omp/dist/mobile-boot.js +38 -0
- package/agent-omp/dist/models.js +299 -0
- package/agent-omp/dist/omp-cli.js +147 -0
- package/agent-omp/dist/omp-disk-discovery.js +178 -0
- package/agent-omp/dist/omp-store.js +518 -0
- package/agent-omp/dist/pairing.js +45 -0
- package/agent-omp/dist/permission.js +96 -0
- package/agent-omp/dist/protocol.js +13 -0
- package/agent-omp/dist/rpc-types.js +1 -0
- package/agent-omp/dist/sdk-client.js +378 -0
- package/agent-omp/dist/sidecar-client.js +195 -0
- package/agent-omp/dist/store/adapter.js +47 -0
- package/agent-omp/dist/store/db.js +210 -0
- package/agent-omp/dist/store/index.js +59 -0
- package/agent-omp/dist/store/reconcile.js +51 -0
- package/agent-omp/dist/store/schema.js +40 -0
- package/agent-omp/dist/world-plugin.js +96 -0
- package/agent-omp/package.json +97 -0
- package/agent-omp/sidecar/main.ts +481 -0
- package/agent-pi/README.md +67 -0
- package/agent-pi/cordis.patch.yml +80 -0
- package/agent-pi/dist/adapter.js +53 -0
- package/agent-pi/dist/agent-preset-pi.js +169 -0
- package/agent-pi/dist/agent-preset-projection.js +29 -0
- package/agent-pi/dist/agent.js +949 -0
- package/agent-pi/dist/inbox.js +53 -0
- package/agent-pi/dist/index.js +595 -0
- package/agent-pi/dist/knobs.js +24 -0
- package/agent-pi/dist/models.js +200 -0
- package/agent-pi/dist/permission.js +66 -0
- package/agent-pi/dist/pi-client.js +224 -0
- package/agent-pi/dist/pi-events.js +155 -0
- package/agent-pi/dist/pi-home.js +55 -0
- package/agent-pi/dist/safe-json.js +25 -0
- package/agent-pi/dist/session-map.js +119 -0
- package/agent-pi/dist/world-plugin.js +97 -0
- package/agent-pi/package.json +77 -0
- package/cordis.patch.yml +22 -19
- package/package.json +88 -25
- package/client.js +0 -4
|
@@ -0,0 +1,949 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { agentEvents } from "@deepseek-ai/dsh-agent";
|
|
3
|
+
import { Inbox } from "./inbox.js";
|
|
4
|
+
import { LlmAttemptId, ToolCallId, QUOTA_EXCEEDED_CODE, createAssistantMessage, createSystemMessage, createToolResultMessage, createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
5
|
+
import { createScope } from "@deepseek-ai/dsh-scope";
|
|
6
|
+
import { PI_IDLE_EXIT_MS } from "./knobs.js";
|
|
7
|
+
import { trace } from "./knobs.js";
|
|
8
|
+
import { PI_PROVIDER_ID, splitCatalogId } from "./models.js";
|
|
9
|
+
/**
|
|
10
|
+
* v2 live assistant-stream publication for the pi bridge — the same frame
|
|
11
|
+
* protocol the reference loop's AssistantStreamAttempt speaks (start marker,
|
|
12
|
+
* dense zero-based chunks, terminal settlement) emitted over this agent's own
|
|
13
|
+
* dispatch, so the session-controller folds pi's stream into its reconnect
|
|
14
|
+
* baseline. Chunks also accumulate as raw durable records embedded in the
|
|
15
|
+
* final assistant/message.
|
|
16
|
+
*/
|
|
17
|
+
class AssistantStreamBridge {
|
|
18
|
+
attemptId;
|
|
19
|
+
records = [];
|
|
20
|
+
#nextRevision;
|
|
21
|
+
#emit;
|
|
22
|
+
#index = 0;
|
|
23
|
+
#terminal = false;
|
|
24
|
+
constructor(sessionId, attempt, nextRevision, emit) {
|
|
25
|
+
this.attemptId = LlmAttemptId(`${sessionId}:${attempt}`);
|
|
26
|
+
this.#nextRevision = nextRevision;
|
|
27
|
+
this.#emit = emit;
|
|
28
|
+
}
|
|
29
|
+
/** Whether the terminal frame already fired. */
|
|
30
|
+
get ended() {
|
|
31
|
+
return this.#terminal;
|
|
32
|
+
}
|
|
33
|
+
/** Opening marker before the first delivered chunk. */
|
|
34
|
+
start(turn, step) {
|
|
35
|
+
this.#emit({ type: "start", attemptId: this.attemptId, revision: this.#nextRevision(), turn, step });
|
|
36
|
+
}
|
|
37
|
+
/** One live chunk: durable record plus dense process-local frame. */
|
|
38
|
+
push(chunk) {
|
|
39
|
+
const time = Date.now();
|
|
40
|
+
this.records.push({ type: "chunk", time, chunk });
|
|
41
|
+
this.#emit({ type: "chunk", attemptId: this.attemptId, revision: this.#nextRevision(), index: this.#index++, time, chunk });
|
|
42
|
+
}
|
|
43
|
+
/** Terminal settlement after the durable assistant/message committed. */
|
|
44
|
+
settle(seq) {
|
|
45
|
+
this.#terminal = true;
|
|
46
|
+
this.#emit({ type: "end", attemptId: this.attemptId, revision: this.#nextRevision(), index: this.#index, outcome: { kind: "committed", eventType: "assistant/message", seq } });
|
|
47
|
+
}
|
|
48
|
+
/** No durable attempt event will commit. */
|
|
49
|
+
abandon() {
|
|
50
|
+
this.#terminal = true;
|
|
51
|
+
this.#emit({ type: "end", attemptId: this.attemptId, revision: this.#nextRevision(), index: this.#index, outcome: { kind: "abandoned" } });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export function convertContent(blocks) {
|
|
55
|
+
if (blocks === undefined)
|
|
56
|
+
return [];
|
|
57
|
+
const result = [];
|
|
58
|
+
for (const block of blocks) {
|
|
59
|
+
switch (block.type) {
|
|
60
|
+
case "text":
|
|
61
|
+
if (typeof block.text === "string")
|
|
62
|
+
result.push({ type: "text", text: block.text });
|
|
63
|
+
break;
|
|
64
|
+
case "thinking":
|
|
65
|
+
if (typeof block.thinking === "string")
|
|
66
|
+
result.push({ type: "reasoning", text: block.thinking });
|
|
67
|
+
break;
|
|
68
|
+
case "toolCall": {
|
|
69
|
+
const id = typeof block.id === "string" ? block.id : "";
|
|
70
|
+
const name = typeof block.name === "string" ? block.name : "";
|
|
71
|
+
const args = typeof block.arguments === "string" ? block.arguments : JSON.stringify(block.arguments ?? {});
|
|
72
|
+
result.push({ type: "tool-call", id: ToolCallId(id), name, arguments: args });
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
default:
|
|
76
|
+
// images / unknown blocks are not bridged in V1.
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Classify the wire's `stopReason: "error"` assistant message into a Dash
|
|
84
|
+
* `failure` (`{ message, code }`). Same taxonomy as the codex bridge — quota
|
|
85
|
+
* wording is checked before the generic 403→AUTH because some providers
|
|
86
|
+
* report an exhausted billing quota as HTTP 403.
|
|
87
|
+
*/
|
|
88
|
+
export function wireFailure(message) {
|
|
89
|
+
if (message.stopReason !== "error")
|
|
90
|
+
return undefined;
|
|
91
|
+
const status = typeof message.errorStatus === "number" ? message.errorStatus : undefined;
|
|
92
|
+
const text = typeof message.errorMessage === "string" && message.errorMessage.trim() !== ""
|
|
93
|
+
? message.errorMessage.trim()
|
|
94
|
+
: undefined;
|
|
95
|
+
const detail = text ?? "";
|
|
96
|
+
let code;
|
|
97
|
+
if (/\b(?:usage|rate)\s*limit\b/i.test(detail) || /\bquota\b/i.test(detail) || /\bbilling\b/i.test(detail) || /\binsufficient\b/i.test(detail)) {
|
|
98
|
+
code = QUOTA_EXCEEDED_CODE;
|
|
99
|
+
}
|
|
100
|
+
else if (status === 401 || status === 403) {
|
|
101
|
+
code = "AUTH";
|
|
102
|
+
}
|
|
103
|
+
else if (status === 429) {
|
|
104
|
+
code = "RATE_LIMIT";
|
|
105
|
+
}
|
|
106
|
+
else if (status !== undefined && status >= 500) {
|
|
107
|
+
code = "SERVER";
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
code = "UNKNOWN";
|
|
111
|
+
}
|
|
112
|
+
return { message: text ?? (status !== undefined ? `HTTP ${status}` : "model request failed"), code };
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Map pi's camelCase `Usage` record into Dash `TokenUsage`.
|
|
116
|
+
*/
|
|
117
|
+
export function convertUsage(usage) {
|
|
118
|
+
if (usage === null || typeof usage !== "object")
|
|
119
|
+
return undefined;
|
|
120
|
+
const u = usage;
|
|
121
|
+
const inputTokens = typeof u.input === "number" ? u.input : 0;
|
|
122
|
+
const outputTokens = typeof u.output === "number" ? u.output : 0;
|
|
123
|
+
if (inputTokens === 0 && outputTokens === 0)
|
|
124
|
+
return undefined;
|
|
125
|
+
const result = { inputTokens, outputTokens };
|
|
126
|
+
if (typeof u.cacheRead === "number")
|
|
127
|
+
result.cacheReadTokens = u.cacheRead;
|
|
128
|
+
if (typeof u.cacheWrite === "number")
|
|
129
|
+
result.cacheWriteTokens = u.cacheWrite;
|
|
130
|
+
if (typeof u.reasoning === "number")
|
|
131
|
+
result.reasoningTokens = u.reasoning;
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
/** Join the visible text blocks of a Dash user message into the pi prompt string. */
|
|
135
|
+
function userMessageText(message) {
|
|
136
|
+
const parts = [];
|
|
137
|
+
for (const block of message.content) {
|
|
138
|
+
if (block.type === "text")
|
|
139
|
+
parts.push(block.text);
|
|
140
|
+
}
|
|
141
|
+
return parts.join("\n");
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Dash-host plugin narration from the approval service: the policy-change
|
|
145
|
+
* notice `agent.inject` carries when `/permission` switches a live session's
|
|
146
|
+
* approval policy. pi has no runtime approval control either (the preset is a
|
|
147
|
+
* launch-only toolset), so forwarding the text would steer it into an
|
|
148
|
+
* in-flight turn and echo it into history as a fake user prompt. Detect and
|
|
149
|
+
* drop it.
|
|
150
|
+
*/
|
|
151
|
+
function isApprovalNarration(message) {
|
|
152
|
+
const source = message.source;
|
|
153
|
+
return source.kind === "plugin" && source.plugin === "user-approval";
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Reconstruct a Dash user message from a wire `message_start(role=user)`
|
|
157
|
+
* payload. Defensive fallback when a queued delivery's retained message is
|
|
158
|
+
* missing (the client echoes text, so the visible content is preserved).
|
|
159
|
+
*/
|
|
160
|
+
function userMessageFromWire(message) {
|
|
161
|
+
return createUserMessage({
|
|
162
|
+
content: convertContent(message.content).filter((block) => block.type === "text"),
|
|
163
|
+
source: { kind: "user" },
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
export class PiAgent {
|
|
167
|
+
id;
|
|
168
|
+
options;
|
|
169
|
+
session;
|
|
170
|
+
inbox;
|
|
171
|
+
ctx;
|
|
172
|
+
#client;
|
|
173
|
+
#loopCtx;
|
|
174
|
+
#scope;
|
|
175
|
+
#dispatch;
|
|
176
|
+
#streaming = false;
|
|
177
|
+
#activityDone = Promise.resolve();
|
|
178
|
+
#resolveActivityDone = () => { };
|
|
179
|
+
#dashTurn = 0;
|
|
180
|
+
#step = 0;
|
|
181
|
+
#lastTurn = 0;
|
|
182
|
+
#turnOpen = false;
|
|
183
|
+
#cancelCause = null;
|
|
184
|
+
/** Terminal model failure of the open turn (`message_end` stopReason "error"); consumed by `agent_end`. */
|
|
185
|
+
#pendingFailure = null;
|
|
186
|
+
/** Live v2 assistant-stream publication for the in-flight wire message. */
|
|
187
|
+
#streamBridge;
|
|
188
|
+
#assistantAttemptCounter = 0;
|
|
189
|
+
#assistantStreamRevision = 0;
|
|
190
|
+
/** The current turn's user/message was appended locally and the echo is still awaited. */
|
|
191
|
+
#localUserPending = false;
|
|
192
|
+
/** A `turn_start` arrived; `step/start` is deferred until the step's owning turn is known. */
|
|
193
|
+
#stepStartPending = false;
|
|
194
|
+
/**
|
|
195
|
+
* Deliveries forwarded through the client, in submission order. The client
|
|
196
|
+
* echoes each as `message_start(role=user)`; the bridge maps it by
|
|
197
|
+
* transport: a `followUp` echo closes the current Dash turn and opens a
|
|
198
|
+
* fresh one, a `steer` echo appends a `user/message` step to the current
|
|
199
|
+
* turn. `sent` marks entries already dispatched to the client.
|
|
200
|
+
*/
|
|
201
|
+
#remoteQueue = [];
|
|
202
|
+
#bridgedToolResults = new Set();
|
|
203
|
+
/** The last title already mirrored into the log (dedupe). */
|
|
204
|
+
#mirroredTitle = null;
|
|
205
|
+
/** The open compaction lifecycle's id (`compaction/start` … `compaction/end`). */
|
|
206
|
+
#compactionId = null;
|
|
207
|
+
/** The last selection already synced to the client via set_model, as `provider/model`. */
|
|
208
|
+
#lastSyncedModel = null;
|
|
209
|
+
/** Fires when the agent has been idle past PI_IDLE_EXIT_MS (tears down the client). */
|
|
210
|
+
#onIdleExit;
|
|
211
|
+
#idleExitTimer = undefined;
|
|
212
|
+
#disposed = false;
|
|
213
|
+
/** One-shot guard for the first-turn session-identity commit (#bootstrapSessionIdentity). */
|
|
214
|
+
#sessionIdentityCommitted = false;
|
|
215
|
+
/** Adapter-owned runtime facts (system prompt / route metadata), when supplied. */
|
|
216
|
+
#runtimeInfo;
|
|
217
|
+
/** One-shot guard for the `system/message` surface node (never re-emitted). */
|
|
218
|
+
#systemMessageDone = false;
|
|
219
|
+
/** Last `request/context` route key (`provider/model`) — dedupes per-turn emits. */
|
|
220
|
+
#lastRouteKey = null;
|
|
221
|
+
constructor(loopCtx, id, options, session, client, onIdleExit, runtimeInfo) {
|
|
222
|
+
this.#loopCtx = loopCtx;
|
|
223
|
+
this.id = id;
|
|
224
|
+
this.options = options;
|
|
225
|
+
this.session = session;
|
|
226
|
+
this.#client = client;
|
|
227
|
+
this.#onIdleExit = onIdleExit;
|
|
228
|
+
this.#runtimeInfo = runtimeInfo;
|
|
229
|
+
this.#dispatch = agentEvents(loopCtx, this);
|
|
230
|
+
this.inbox = new Inbox();
|
|
231
|
+
this.#scope = createScope(loopCtx, this);
|
|
232
|
+
this.ctx = this.#scope.ctx.extend({ agent: this });
|
|
233
|
+
this.#lastTurn = session.snapshotEvents().findLast((event) => event.type === "turn/start")?.data.turn ?? 0;
|
|
234
|
+
client.on((event) => this.#handleEvent(event));
|
|
235
|
+
client.onFailure((error) => this.#fail(error));
|
|
236
|
+
// Session-identity events are committed on the FIRST turn (only once the
|
|
237
|
+
// client actually exists), so a freshly created session announces
|
|
238
|
+
// completely blank — the backend takes zero part in the UI's new-session
|
|
239
|
+
// draft.
|
|
240
|
+
//
|
|
241
|
+
// `/permission` cannot work here: pi has no runtime approval control (the
|
|
242
|
+
// preset is realized as a launch-only toolset). Registering the SAME name
|
|
243
|
+
// on this agent's own scope SHADOWS the global permission command for
|
|
244
|
+
// this agent only — the switch fails cleanly instead of appending
|
|
245
|
+
// approval/policy events pi cannot honor. (The `inject` narration drop
|
|
246
|
+
// below stays as the backstop.)
|
|
247
|
+
this.ctx.inject(["commands"], (cmdCtx) => cmdCtx.commands.register({
|
|
248
|
+
name: "permission",
|
|
249
|
+
description: "Unavailable for pi sessions (the toolset is fixed at session start)",
|
|
250
|
+
input: { hint: "<preset>" },
|
|
251
|
+
handler: () => ({
|
|
252
|
+
kind: "error",
|
|
253
|
+
text: "The pi toolset is fixed at session start; runtime switching is not supported for pi sessions.",
|
|
254
|
+
}),
|
|
255
|
+
}));
|
|
256
|
+
// `/compact` parity: pi compaction is REAL and wired (compaction_start/
|
|
257
|
+
// compaction_end bridge into the DSH log). Shadows the (disabled) native
|
|
258
|
+
// pipeline so the command drives the pi session's own compaction.
|
|
259
|
+
this.ctx.inject(["commands"], (cmdCtx) => cmdCtx.commands.register({
|
|
260
|
+
name: "compact",
|
|
261
|
+
description: "Compact this pi session's context",
|
|
262
|
+
input: { hint: "[instructions]" },
|
|
263
|
+
handler: (invocation) => {
|
|
264
|
+
void this.#client.compact(invocation.rawInput.trim() === "" ? undefined : invocation.rawInput).catch(() => { });
|
|
265
|
+
return { kind: "success", text: "Compaction started." };
|
|
266
|
+
},
|
|
267
|
+
}));
|
|
268
|
+
}
|
|
269
|
+
get status() {
|
|
270
|
+
return this.#streaming ? "running" : "idle";
|
|
271
|
+
}
|
|
272
|
+
send(message, target, wakeup) {
|
|
273
|
+
this.#deliver(message, target, wakeup);
|
|
274
|
+
}
|
|
275
|
+
followup(message) {
|
|
276
|
+
// The Web UI's `session.prompt` (mode "queue") routes through followup.
|
|
277
|
+
// Idle → `prompt` (opens the Dash turn locally); busy → the client's own
|
|
278
|
+
// queue (pi delivers it as the next turn's user message). Mirrors
|
|
279
|
+
// dsh-agent-loop's `followup = send(input, "next-turn", true)`.
|
|
280
|
+
this.#deliver(message, "next-turn", true);
|
|
281
|
+
}
|
|
282
|
+
steer(message) {
|
|
283
|
+
// Mirrors dsh-agent-loop's `steer = send(input, "next-step", true)`: an
|
|
284
|
+
// idle driver starts a turn; a running driver consumes it at the next
|
|
285
|
+
// step (pi steers natively — delivered after the in-flight turn's tool
|
|
286
|
+
// calls, before the next LLM call).
|
|
287
|
+
this.#deliver(message, "next-step", true);
|
|
288
|
+
}
|
|
289
|
+
inject(message) {
|
|
290
|
+
// Dash-host approval-policy narrations have no pi runtime equivalent.
|
|
291
|
+
// Forwarding the text would steer it into an in-flight turn and echo it
|
|
292
|
+
// into history as a fake user prompt. Drop it; the accompanying
|
|
293
|
+
// `approval/policy` session event stays recorded so the UI's permission
|
|
294
|
+
// state remains truthful.
|
|
295
|
+
if (isApprovalNarration(message)) {
|
|
296
|
+
trace("inject: dropped approval-policy narration (pi lacks runtime approval control)");
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
// Mirrors dsh-agent-loop's `inject = send(input, "next-step", false)`:
|
|
300
|
+
// queued without waking; idle drivers leave it pending until a later
|
|
301
|
+
// follow-up/steer wakes them (flushed at the next agent_start).
|
|
302
|
+
this.#deliver(message, "next-step", false);
|
|
303
|
+
}
|
|
304
|
+
cancel(cause, _options) {
|
|
305
|
+
// No active run → no-op: arming `#cancelCause` here would misreport a
|
|
306
|
+
// later, unrelated completed turn as aborted. First cause wins.
|
|
307
|
+
if (!this.#streaming)
|
|
308
|
+
return;
|
|
309
|
+
if (this.#cancelCause === null)
|
|
310
|
+
this.#cancelCause = cause;
|
|
311
|
+
void this.#client.abort().catch(() => { });
|
|
312
|
+
}
|
|
313
|
+
async whenIdle() {
|
|
314
|
+
// Event-driven: wait for the current activity to settle (agent_end), then
|
|
315
|
+
// confirm the client reports quiescence.
|
|
316
|
+
let activity;
|
|
317
|
+
do {
|
|
318
|
+
activity = this.#activityDone;
|
|
319
|
+
await activity;
|
|
320
|
+
} while (activity !== this.#activityDone);
|
|
321
|
+
for (;;) {
|
|
322
|
+
const state = this.#client.getState();
|
|
323
|
+
if (!state.isStreaming)
|
|
324
|
+
return;
|
|
325
|
+
await delay(50);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
runMaintenance(task) {
|
|
329
|
+
// pi has no maintenance concept; run the task directly.
|
|
330
|
+
return task(new AbortController().signal);
|
|
331
|
+
}
|
|
332
|
+
/** Stop the pi session and unwind the scoped world. Idempotent. */
|
|
333
|
+
async dispose() {
|
|
334
|
+
if (this.#disposed)
|
|
335
|
+
return;
|
|
336
|
+
this.#disposed = true;
|
|
337
|
+
clearTimeout(this.#idleExitTimer);
|
|
338
|
+
this.#idleExitTimer = undefined;
|
|
339
|
+
this.#client.close();
|
|
340
|
+
await this.#scope.dispose();
|
|
341
|
+
}
|
|
342
|
+
// ── internals ────────────────────────────────────────────────────────────
|
|
343
|
+
/**
|
|
344
|
+
* Route one message by the reference loop's (target, wakeup) semantics:
|
|
345
|
+
* an idle waking delivery starts a fresh local turn (`prompt`); a follow-up
|
|
346
|
+
* sent while a turn is open is forwarded to the client's queue
|
|
347
|
+
* (`followUp`); steering/injection goes through `steer` (consumed at the
|
|
348
|
+
* next step, or parked until agent_start when not yet streaming).
|
|
349
|
+
*/
|
|
350
|
+
#deliver(message, target, wakeup) {
|
|
351
|
+
if (this.#disposed)
|
|
352
|
+
return;
|
|
353
|
+
const busy = this.#turnOpen || this.#streaming;
|
|
354
|
+
trace(`#deliver target=${target} wakeup=${wakeup} busy=${busy} (turnOpen=${this.#turnOpen} streaming=${this.#streaming})`);
|
|
355
|
+
if (busy && target === "next-turn") {
|
|
356
|
+
this.#queueRemote(message, "followUp");
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (busy || !wakeup) {
|
|
360
|
+
this.#queueRemote(message, "steer");
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
this.#deliverPrompt(message);
|
|
364
|
+
}
|
|
365
|
+
#deliverPrompt(message) {
|
|
366
|
+
if (this.#disposed)
|
|
367
|
+
return;
|
|
368
|
+
trace(`#deliverPrompt turn=${this.#dashTurn + 1} text="${userMessageText(message).slice(0, 40)}"`);
|
|
369
|
+
// Reserve the turn synchronously, but NOTHING is appended yet — the log
|
|
370
|
+
// stays empty until the client exists, so a failed cold start leaves no
|
|
371
|
+
// orphan turn.
|
|
372
|
+
this.#reserveTurn();
|
|
373
|
+
clearTimeout(this.#idleExitTimer);
|
|
374
|
+
this.#idleExitTimer = undefined;
|
|
375
|
+
this.#beginActivity();
|
|
376
|
+
void this.#startTurn(message);
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Cold-start path for one reserved turn: start the session (lazy boundary —
|
|
380
|
+
* pi's native session file materializes HERE), commit the session-identity
|
|
381
|
+
* events ahead of the turn, sync the model, then append turn/start +
|
|
382
|
+
* user/message and dispatch. A failed cold start synthesizes the failed
|
|
383
|
+
* turn so the UI's error path renders it, then unwinds the reservation.
|
|
384
|
+
*/
|
|
385
|
+
async #startTurn(message) {
|
|
386
|
+
try {
|
|
387
|
+
await this.#client.ensureStarted();
|
|
388
|
+
await this.#bootstrapSessionIdentity();
|
|
389
|
+
await this.#syncModelSelection();
|
|
390
|
+
}
|
|
391
|
+
catch (error) {
|
|
392
|
+
trace(`#startTurn cold start failed: ${String(error)}`);
|
|
393
|
+
if (this.#turnOpen) {
|
|
394
|
+
this.session.append("turn/start", { turn: this.#dashTurn });
|
|
395
|
+
this.session.append("user/message", message, { surfaceOp: "append" });
|
|
396
|
+
this.#closeTurn({ kind: "error", error: { message: String(error), code: "UNKNOWN" } });
|
|
397
|
+
}
|
|
398
|
+
this.#endActivity();
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
if (this.#disposed)
|
|
402
|
+
return;
|
|
403
|
+
this.session.append("turn/start", { turn: this.#dashTurn });
|
|
404
|
+
this.#emitRequestContext();
|
|
405
|
+
this.session.append("user/message", message, { surfaceOp: "append" });
|
|
406
|
+
void this.#client.prompt(userMessageText(message)).catch((error) => this.#fail(error));
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Stamp pi's system prompt once per session as the step's surface node.
|
|
410
|
+
* `system/message` is a step-scoped surface event, so this runs only after
|
|
411
|
+
* `step/start` committed. Skips when the log already carries one (resume),
|
|
412
|
+
* when there is no runtime bridge, and while the session has not started —
|
|
413
|
+
* retried on later steps until the text is available.
|
|
414
|
+
*/
|
|
415
|
+
#emitSystemMessage() {
|
|
416
|
+
if (this.#systemMessageDone)
|
|
417
|
+
return;
|
|
418
|
+
if (this.#runtimeInfo === undefined || !this.#turnOpen)
|
|
419
|
+
return;
|
|
420
|
+
this.#commitStepStart();
|
|
421
|
+
if (this.#step === 0)
|
|
422
|
+
return;
|
|
423
|
+
if (this.session.snapshotEvents().some((event) => event.type === "system/message")) {
|
|
424
|
+
this.#systemMessageDone = true;
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
const text = this.#runtimeInfo.systemPrompt();
|
|
428
|
+
if (text === undefined || text.trim() === "")
|
|
429
|
+
return;
|
|
430
|
+
this.session.append("system/message", {
|
|
431
|
+
turn: this.#dashTurn,
|
|
432
|
+
step: this.#step,
|
|
433
|
+
message: createSystemMessage(text, "aw.agent-adapter-pi"),
|
|
434
|
+
}, { surfaceOp: "append" });
|
|
435
|
+
this.#systemMessageDone = true;
|
|
436
|
+
trace(`system/message stamped (${text.length} chars)`);
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Stamp route metadata for the next request (`request/context`): the
|
|
440
|
+
* effective provider/model plus the model's context window when known.
|
|
441
|
+
* Turn-enclosed (no step required) and deduped by route key.
|
|
442
|
+
*/
|
|
443
|
+
#emitRequestContext() {
|
|
444
|
+
if (this.#runtimeInfo === undefined || !this.#turnOpen)
|
|
445
|
+
return;
|
|
446
|
+
const route = this.#runtimeInfo.routeContext(this.options);
|
|
447
|
+
if (route === undefined)
|
|
448
|
+
return;
|
|
449
|
+
const key = `${route.provider}/${route.model}`;
|
|
450
|
+
if (key === this.#lastRouteKey)
|
|
451
|
+
return;
|
|
452
|
+
this.#lastRouteKey = key;
|
|
453
|
+
this.session.append("request/context", {
|
|
454
|
+
provider: route.provider,
|
|
455
|
+
model: route.model,
|
|
456
|
+
...(route.contextWindow === undefined ? {} : { contextWindow: route.contextWindow }),
|
|
457
|
+
});
|
|
458
|
+
trace(`request/context ${key}${route.contextWindow === undefined ? "" : ` ctx=${route.contextWindow}`}`);
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* One-shot first-turn identity commit: the agent-preset stamp (idempotent).
|
|
462
|
+
*/
|
|
463
|
+
async #bootstrapSessionIdentity() {
|
|
464
|
+
if (this.#sessionIdentityCommitted)
|
|
465
|
+
return;
|
|
466
|
+
this.#sessionIdentityCommitted = true;
|
|
467
|
+
if (!this.session.snapshotEvents().some((event) => event.type === "agent-preset/selected" && event.data?.agentPreset === "pi")) {
|
|
468
|
+
this.session.append("agent-preset/selected", { agentPreset: "pi" });
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Queue a message for client-side delivery. `followUp` dispatches
|
|
473
|
+
* immediately (state-safe whether or not the client is streaming); `steer`
|
|
474
|
+
* dispatches now when already streaming, otherwise parks until
|
|
475
|
+
* agent_start.
|
|
476
|
+
*/
|
|
477
|
+
#queueRemote(message, transport) {
|
|
478
|
+
if (this.#disposed)
|
|
479
|
+
return;
|
|
480
|
+
clearTimeout(this.#idleExitTimer);
|
|
481
|
+
this.#idleExitTimer = undefined;
|
|
482
|
+
this.#remoteQueue.push({ message, sent: false, transport });
|
|
483
|
+
if (this.#streaming || transport === "followUp")
|
|
484
|
+
this.#flushRemote();
|
|
485
|
+
}
|
|
486
|
+
#flushRemote() {
|
|
487
|
+
void this.#syncModelSelection().finally(() => {
|
|
488
|
+
for (const entry of this.#remoteQueue) {
|
|
489
|
+
if (entry.sent)
|
|
490
|
+
continue;
|
|
491
|
+
entry.sent = true;
|
|
492
|
+
const text = userMessageText(entry.message);
|
|
493
|
+
const sent = entry.transport === "followUp" ? this.#client.followUp(text) : this.#client.steer(text);
|
|
494
|
+
trace(`-> ${entry.transport} "${text.slice(0, 40)}"`);
|
|
495
|
+
void sent.catch((error) => this.#fail(error));
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Push the effective model selection into the client before a turn is
|
|
501
|
+
* dispatched. Waterfall: the session's logged `request/header` route, then
|
|
502
|
+
* the global `agentDefaultModel` selection, then the agent options. The
|
|
503
|
+
* chosen pair threads to the client as (provider slug, bare model id) —
|
|
504
|
+
* exactly what the shared runtime's `getModel` takes. A LEGACY stored
|
|
505
|
+
* selection still arrives as the umbrella `pi` route with a composite
|
|
506
|
+
* `<provider>/<modelId>` model; splitCatalogId (its only remaining job)
|
|
507
|
+
* resolves that shape here, before the runtime lookup. pi's setModel is
|
|
508
|
+
* LIVE (an upgrade over the codex SDK line's next-turn switch).
|
|
509
|
+
*/
|
|
510
|
+
async #syncModelSelection() {
|
|
511
|
+
const persisted = this.session.requestHeader()?.config;
|
|
512
|
+
const service = this.ctx.get("agentDefaultModel");
|
|
513
|
+
const selection = service?.currentSelection();
|
|
514
|
+
const target = persisted !== undefined && persisted.provider !== "" && persisted.model !== ""
|
|
515
|
+
? { provider: persisted.provider, model: persisted.model }
|
|
516
|
+
: selection !== undefined && selection.provider !== "" && selection.model !== ""
|
|
517
|
+
? selection
|
|
518
|
+
: { provider: this.options.provider ?? "", model: this.options.model ?? "" };
|
|
519
|
+
// Legacy composites (`<provider>/<modelId>` under the umbrella route)
|
|
520
|
+
// split into the real pair; real-slug pairs pass through untouched (a
|
|
521
|
+
// bare id may itself contain slashes — the FIRST-slash split of the
|
|
522
|
+
// composite `<provider>/<bare>` reproduces exactly that pair).
|
|
523
|
+
const legacy = target.provider === "" || target.provider === PI_PROVIDER_ID ? splitCatalogId(target.model) : undefined;
|
|
524
|
+
const route = legacy ?? { provider: target.provider, modelId: target.model };
|
|
525
|
+
if (route.provider === "" || route.modelId === "")
|
|
526
|
+
return;
|
|
527
|
+
const key = `${route.provider}/${route.modelId}`;
|
|
528
|
+
if (key === this.#lastSyncedModel)
|
|
529
|
+
return;
|
|
530
|
+
await this.#client.setModel(route.provider, route.modelId).catch((error) => {
|
|
531
|
+
trace(`set_model ${key} failed: ${String(error)}`);
|
|
532
|
+
});
|
|
533
|
+
this.#lastSyncedModel = key;
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Reserve a turn WITHOUT appending anything: the prompt path reserves
|
|
537
|
+
* synchronously, then commits turn/start + user/message only after the
|
|
538
|
+
* cold start (#startTurn).
|
|
539
|
+
*/
|
|
540
|
+
#reserveTurn() {
|
|
541
|
+
if (this.#turnOpen)
|
|
542
|
+
return;
|
|
543
|
+
this.#dashTurn = ++this.#lastTurn;
|
|
544
|
+
this.#step = 0;
|
|
545
|
+
this.#turnOpen = true;
|
|
546
|
+
this.#localUserPending = true;
|
|
547
|
+
}
|
|
548
|
+
#openTurn(message, localUser = true) {
|
|
549
|
+
trace(`#openTurn turnOpen=${this.#turnOpen} -> turn=${this.#dashTurn + 1} localUser=${localUser}`);
|
|
550
|
+
if (this.#turnOpen)
|
|
551
|
+
return;
|
|
552
|
+
this.#reserveTurn();
|
|
553
|
+
this.#localUserPending = localUser;
|
|
554
|
+
this.session.append("turn/start", { turn: this.#dashTurn });
|
|
555
|
+
this.#emitRequestContext();
|
|
556
|
+
this.session.append("user/message", message, { surfaceOp: "append" });
|
|
557
|
+
}
|
|
558
|
+
#closeTurn(reason) {
|
|
559
|
+
if (!this.#turnOpen)
|
|
560
|
+
return;
|
|
561
|
+
this.#turnOpen = false;
|
|
562
|
+
this.#localUserPending = false;
|
|
563
|
+
this.session.append("turn/end", { turn: this.#dashTurn, reason });
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Append the deferred `step/start` now that the step's first message has
|
|
567
|
+
* revealed which Dash turn owns it (a queued follow-up opens a fresh turn).
|
|
568
|
+
*/
|
|
569
|
+
#commitStepStart() {
|
|
570
|
+
if (!this.#turnOpen)
|
|
571
|
+
return;
|
|
572
|
+
if (!this.#stepStartPending && this.#step > 0)
|
|
573
|
+
return;
|
|
574
|
+
this.#stepStartPending = false;
|
|
575
|
+
this.#step += 1;
|
|
576
|
+
this.session.append("step/start", { turn: this.#dashTurn, step: this.#step });
|
|
577
|
+
trace(`step/start turn=${this.#dashTurn} step=${this.#step}`);
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Bridge a wire `message_start(role=user)` that was NOT appended locally.
|
|
581
|
+
* A `followUp` echo closes the current Dash turn and opens a fresh one with
|
|
582
|
+
* the retained message; a steering/injection echo adds a `user/message`
|
|
583
|
+
* step to the current turn. Falls back to the wire's own content if no
|
|
584
|
+
* retained message matches.
|
|
585
|
+
*/
|
|
586
|
+
#bridgeRemoteUser(wireMessage) {
|
|
587
|
+
const entry = this.#remoteQueue.shift();
|
|
588
|
+
if (entry === undefined) {
|
|
589
|
+
this.#closeTurn({ kind: "completed" });
|
|
590
|
+
this.#openTurn(userMessageFromWire(wireMessage), false);
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
if (entry.transport === "followUp") {
|
|
594
|
+
this.#closeTurn({ kind: "completed" });
|
|
595
|
+
this.#openTurn(entry.message, false);
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
this.session.append("user/message", entry.message, { surfaceOp: "append" });
|
|
599
|
+
}
|
|
600
|
+
#beginActivity() {
|
|
601
|
+
// Settle any prior in-flight activity first: `send()` can inject a prompt
|
|
602
|
+
// mid-stream, superseding the current activity promise. Without settling
|
|
603
|
+
// the old one, whenIdle()'s do-while would await an orphaned promise.
|
|
604
|
+
this.#resolveActivityDone();
|
|
605
|
+
this.#activityDone = new Promise((resolve) => {
|
|
606
|
+
this.#resolveActivityDone = resolve;
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
#endActivity() {
|
|
610
|
+
this.#resolveActivityDone();
|
|
611
|
+
}
|
|
612
|
+
#markRunning() {
|
|
613
|
+
clearTimeout(this.#idleExitTimer);
|
|
614
|
+
this.#idleExitTimer = undefined;
|
|
615
|
+
if (this.#streaming)
|
|
616
|
+
return;
|
|
617
|
+
this.#streaming = true;
|
|
618
|
+
this.#dispatch.emit("agent/status", { status: "running" });
|
|
619
|
+
}
|
|
620
|
+
#markIdle() {
|
|
621
|
+
if (!this.#streaming)
|
|
622
|
+
return;
|
|
623
|
+
this.#streaming = false;
|
|
624
|
+
this.#dispatch.emit("agent/status", { status: "idle" });
|
|
625
|
+
this.#armIdleExit();
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Schedule the idle exit. Skipped while deliveries are still queued or
|
|
629
|
+
* when the exit is disabled. Any later activity restarts or cancels it.
|
|
630
|
+
*/
|
|
631
|
+
#armIdleExit() {
|
|
632
|
+
if (this.#disposed || this.#onIdleExit === undefined || PI_IDLE_EXIT_MS === 0)
|
|
633
|
+
return;
|
|
634
|
+
if (this.#remoteQueue.some((entry) => !entry.sent))
|
|
635
|
+
return;
|
|
636
|
+
clearTimeout(this.#idleExitTimer);
|
|
637
|
+
this.#idleExitTimer = setTimeout(() => {
|
|
638
|
+
this.#idleExitTimer = undefined;
|
|
639
|
+
void this.#revalidateIdleExit();
|
|
640
|
+
}, PI_IDLE_EXIT_MS);
|
|
641
|
+
this.#idleExitTimer.unref?.();
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Quiescence re-validation at fire time: local signals plus the client's
|
|
645
|
+
* own attestation. Any busy signal re-arms instead of killing.
|
|
646
|
+
*/
|
|
647
|
+
async #revalidateIdleExit() {
|
|
648
|
+
if (this.#disposed || this.#streaming)
|
|
649
|
+
return this.#armIdleExit();
|
|
650
|
+
if (this.#remoteQueue.some((entry) => !entry.sent))
|
|
651
|
+
return this.#armIdleExit();
|
|
652
|
+
try {
|
|
653
|
+
const state = this.#client.getState();
|
|
654
|
+
if (this.#streaming)
|
|
655
|
+
return this.#armIdleExit();
|
|
656
|
+
if (state.isStreaming)
|
|
657
|
+
return this.#armIdleExit();
|
|
658
|
+
}
|
|
659
|
+
catch {
|
|
660
|
+
// Closed client: local signals remain authoritative.
|
|
661
|
+
}
|
|
662
|
+
trace(`idle exit after ${PI_IDLE_EXIT_MS}ms — disposing agent ${this.id}`);
|
|
663
|
+
this.#onIdleExit?.();
|
|
664
|
+
}
|
|
665
|
+
#fail(error) {
|
|
666
|
+
this.#abandonStreamBridge();
|
|
667
|
+
if (this.#disposed)
|
|
668
|
+
return;
|
|
669
|
+
trace(`#fail ${String(error).slice(0, 200)}`);
|
|
670
|
+
this.#closeTurn({ kind: "error", error: { message: String(error), code: "UNKNOWN" } });
|
|
671
|
+
this.#markIdle();
|
|
672
|
+
this.#endActivity();
|
|
673
|
+
}
|
|
674
|
+
#handleEvent(event) {
|
|
675
|
+
switch (event.type) {
|
|
676
|
+
case "agent_start":
|
|
677
|
+
trace("event agent_start");
|
|
678
|
+
this.#markRunning();
|
|
679
|
+
this.#flushRemote();
|
|
680
|
+
break;
|
|
681
|
+
case "turn_start":
|
|
682
|
+
// Defer `step/start`: the step's owning turn is only known once its
|
|
683
|
+
// first message arrives (a queued follow-up opens a fresh turn).
|
|
684
|
+
trace("event turn_start");
|
|
685
|
+
this.#stepStartPending = true;
|
|
686
|
+
this.#pendingFailure = null;
|
|
687
|
+
// Open the step immediately (pi may never send message_start)
|
|
688
|
+
// and try the system prompt now that the session exists.
|
|
689
|
+
this.#commitStepStart();
|
|
690
|
+
this.#emitSystemMessage();
|
|
691
|
+
break;
|
|
692
|
+
case "message_start": {
|
|
693
|
+
const message = event.message;
|
|
694
|
+
if (message?.role === "user") {
|
|
695
|
+
if (this.#localUserPending) {
|
|
696
|
+
// The client is echoing the user message #openTurn already appended.
|
|
697
|
+
this.#localUserPending = false;
|
|
698
|
+
}
|
|
699
|
+
else {
|
|
700
|
+
// A queued follow-up / steering message delivered by the client.
|
|
701
|
+
this.#bridgeRemoteUser(message);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
else if (message?.role === "assistant") {
|
|
705
|
+
// Reset per-message streaming accumulators.
|
|
706
|
+
this.#abandonStreamBridge();
|
|
707
|
+
}
|
|
708
|
+
// role === "toolResult" is bridged in message_end (deduped vs tool_execution_end).
|
|
709
|
+
this.#commitStepStart();
|
|
710
|
+
// pi's system prompt is stamped inside the first OPEN step of the
|
|
711
|
+
// first turn (the SDK session only exposes it once started).
|
|
712
|
+
this.#emitSystemMessage();
|
|
713
|
+
break;
|
|
714
|
+
}
|
|
715
|
+
case "message_update": {
|
|
716
|
+
const delta = event.assistantMessageEvent;
|
|
717
|
+
if (delta !== undefined)
|
|
718
|
+
this.#handleUpdate(delta);
|
|
719
|
+
break;
|
|
720
|
+
}
|
|
721
|
+
case "message_end": {
|
|
722
|
+
const message = event.message;
|
|
723
|
+
if (message === undefined)
|
|
724
|
+
break;
|
|
725
|
+
const role = message.role;
|
|
726
|
+
if (role === "assistant") {
|
|
727
|
+
const failure = wireFailure(message);
|
|
728
|
+
if (failure !== undefined) {
|
|
729
|
+
this.#pendingFailure = failure;
|
|
730
|
+
this.#abandonStreamBridge();
|
|
731
|
+
}
|
|
732
|
+
else {
|
|
733
|
+
this.#appendAssistantMessage(message);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
else if (role === "toolResult") {
|
|
737
|
+
this.#appendToolResultMessage(message);
|
|
738
|
+
}
|
|
739
|
+
else if (role !== "user") {
|
|
740
|
+
process.stderr.write(`pi wire: unhandled message_end role "${role}"\n`);
|
|
741
|
+
}
|
|
742
|
+
break;
|
|
743
|
+
}
|
|
744
|
+
case "tool_execution_start": {
|
|
745
|
+
const callId = String(event.toolCallId ?? "");
|
|
746
|
+
const name = String(event.toolName ?? "");
|
|
747
|
+
this.#commitStepStart();
|
|
748
|
+
this.session.append("tool/call", {
|
|
749
|
+
turn: this.#dashTurn,
|
|
750
|
+
step: this.#step,
|
|
751
|
+
callId: ToolCallId(callId),
|
|
752
|
+
name,
|
|
753
|
+
// The DSH payload requires the RAW JSON string the model produced;
|
|
754
|
+
// the projection stringified pi's parsed args object.
|
|
755
|
+
arguments: typeof event.args === "string" ? event.args : JSON.stringify(event.args ?? {}),
|
|
756
|
+
});
|
|
757
|
+
break;
|
|
758
|
+
}
|
|
759
|
+
case "tool_execution_end":
|
|
760
|
+
this.#appendToolResult(event);
|
|
761
|
+
break;
|
|
762
|
+
case "turn_end":
|
|
763
|
+
trace("event turn_end");
|
|
764
|
+
// Last chance for the system prompt. Appended inside the still-open
|
|
765
|
+
// step (before step/end) so the step-scope invariant holds.
|
|
766
|
+
this.#emitSystemMessage();
|
|
767
|
+
if (this.#turnOpen && this.#step > 0) {
|
|
768
|
+
this.session.append("step/end", { turn: this.#dashTurn, step: this.#step });
|
|
769
|
+
}
|
|
770
|
+
break;
|
|
771
|
+
case "agent_end": {
|
|
772
|
+
trace(`event agent_end cancelCause=${String(this.#cancelCause)}`);
|
|
773
|
+
const pendingFailure = this.#pendingFailure;
|
|
774
|
+
this.#pendingFailure = null;
|
|
775
|
+
this.#closeTurn(this.#cancelCause !== null
|
|
776
|
+
? { kind: "aborted", reason: this.#cancelCause }
|
|
777
|
+
: pendingFailure !== null
|
|
778
|
+
? { kind: "error", error: pendingFailure }
|
|
779
|
+
: { kind: "completed" });
|
|
780
|
+
this.#cancelCause = null;
|
|
781
|
+
this.#markIdle();
|
|
782
|
+
this.#endActivity();
|
|
783
|
+
break;
|
|
784
|
+
}
|
|
785
|
+
case "session_title":
|
|
786
|
+
this.#mirrorTitle(event.name);
|
|
787
|
+
break;
|
|
788
|
+
case "compaction_start":
|
|
789
|
+
this.#openCompaction();
|
|
790
|
+
break;
|
|
791
|
+
case "compaction_end":
|
|
792
|
+
this.#closeCompaction();
|
|
793
|
+
break;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* Mirror pi's session title into a `session/title` event. Dash does not
|
|
798
|
+
* generate titles for pi sessions (the native title rows are disabled in
|
|
799
|
+
* the bundle patch); pi owns the title and reports it through
|
|
800
|
+
* `session_info_changed`. Empty or unchanged titles are ignored, and a
|
|
801
|
+
* title is never allowed to affect the turn.
|
|
802
|
+
*/
|
|
803
|
+
#mirrorTitle(name) {
|
|
804
|
+
if (name === undefined || name.trim() === "" || name === this.#mirroredTitle)
|
|
805
|
+
return;
|
|
806
|
+
this.#mirroredTitle = name;
|
|
807
|
+
try {
|
|
808
|
+
this.session.append("session/title", {
|
|
809
|
+
title: name,
|
|
810
|
+
messageSeqs: [],
|
|
811
|
+
source: { kind: "provider", provider: "pi" },
|
|
812
|
+
});
|
|
813
|
+
trace(`session/title ← pi: "${name}"`);
|
|
814
|
+
}
|
|
815
|
+
catch {
|
|
816
|
+
// fail-soft: a title never affects the turn
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
/** Open the compaction lifecycle bracket in the DSH log. */
|
|
820
|
+
#openCompaction() {
|
|
821
|
+
if (this.#compactionId !== null)
|
|
822
|
+
return;
|
|
823
|
+
const compactionId = randomUUID();
|
|
824
|
+
this.#compactionId = compactionId;
|
|
825
|
+
try {
|
|
826
|
+
this.session.append("compaction/start", { compactionId });
|
|
827
|
+
trace(`compaction/start ${compactionId}`);
|
|
828
|
+
}
|
|
829
|
+
catch {
|
|
830
|
+
this.#compactionId = null;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
/** Close the compaction lifecycle bracket (fail-soft, never throws). */
|
|
834
|
+
#closeCompaction() {
|
|
835
|
+
const compactionId = this.#compactionId;
|
|
836
|
+
if (compactionId === null)
|
|
837
|
+
return;
|
|
838
|
+
this.#compactionId = null;
|
|
839
|
+
try {
|
|
840
|
+
this.session.append("compaction/end", { compactionId });
|
|
841
|
+
trace(`compaction/end ${compactionId}`);
|
|
842
|
+
}
|
|
843
|
+
catch {
|
|
844
|
+
// fail-soft
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
/** The open stream bridge for this message, starting one on first use. */
|
|
848
|
+
#ensureStreamBridge() {
|
|
849
|
+
if (this.#streamBridge === undefined || this.#streamBridge.ended) {
|
|
850
|
+
this.#streamBridge = new AssistantStreamBridge(this.session.id, ++this.#assistantAttemptCounter, () => ++this.#assistantStreamRevision, (frame) => this.#dispatch.emit("agent/assistant-stream", { frame }));
|
|
851
|
+
this.#streamBridge.start(this.#dashTurn, this.#step);
|
|
852
|
+
}
|
|
853
|
+
return this.#streamBridge;
|
|
854
|
+
}
|
|
855
|
+
/** Abandon any open stream bridge — failure paths never settle. */
|
|
856
|
+
#abandonStreamBridge() {
|
|
857
|
+
if (this.#streamBridge !== undefined && !this.#streamBridge.ended)
|
|
858
|
+
this.#streamBridge.abandon();
|
|
859
|
+
this.#streamBridge = undefined;
|
|
860
|
+
}
|
|
861
|
+
#handleUpdate(delta) {
|
|
862
|
+
switch (delta.type) {
|
|
863
|
+
case "text_delta":
|
|
864
|
+
if (typeof delta.delta === "string" && delta.delta !== "") {
|
|
865
|
+
const chunk = { type: "text-delta", index: delta.contentIndex ?? 0, text: delta.delta };
|
|
866
|
+
// v2: live text no longer appends `assistant/chunk` log events — the
|
|
867
|
+
// stream publishes as dense agent frames and embeds durably in the
|
|
868
|
+
// final assistant/message.
|
|
869
|
+
this.#ensureStreamBridge().push(chunk);
|
|
870
|
+
}
|
|
871
|
+
break;
|
|
872
|
+
default:
|
|
873
|
+
// thinking_*/toolcall_* are folded into the final message.
|
|
874
|
+
break;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
#appendAssistantMessage(message) {
|
|
878
|
+
const content = convertContent(message.content);
|
|
879
|
+
// pi reports usage on every assistant message — same-event attachment,
|
|
880
|
+
// no lag.
|
|
881
|
+
const usage = convertUsage(message.usage);
|
|
882
|
+
const assistant = createAssistantMessage({
|
|
883
|
+
content,
|
|
884
|
+
source: {
|
|
885
|
+
// Wire messages may carry no attribution: fall back to this agent's
|
|
886
|
+
// options (the omp bridge did the same).
|
|
887
|
+
provider: String(message.provider ?? this.options.provider ?? ""),
|
|
888
|
+
model: String(message.model ?? this.options.model ?? ""),
|
|
889
|
+
},
|
|
890
|
+
});
|
|
891
|
+
const bridge = this.#streamBridge;
|
|
892
|
+
this.#commitStepStart();
|
|
893
|
+
// v2: the attempt's timed chunks embed in the event itself;
|
|
894
|
+
// `sourceEventSeqs` is forbidden on assistant/message in v2.
|
|
895
|
+
const event = this.session.append("assistant/message", {
|
|
896
|
+
turn: this.#dashTurn,
|
|
897
|
+
step: this.#step,
|
|
898
|
+
message: assistant,
|
|
899
|
+
stream: bridge?.records ?? [],
|
|
900
|
+
...(usage === undefined ? {} : { usage }),
|
|
901
|
+
}, {
|
|
902
|
+
surfaceOp: "append",
|
|
903
|
+
});
|
|
904
|
+
if (bridge !== undefined && !bridge.ended)
|
|
905
|
+
bridge.settle(event.seq);
|
|
906
|
+
this.#streamBridge = undefined;
|
|
907
|
+
}
|
|
908
|
+
#appendToolResult(event) {
|
|
909
|
+
const callId = String(event.toolCallId ?? "");
|
|
910
|
+
this.#bridgedToolResults.add(callId);
|
|
911
|
+
const isError = event.isError === true;
|
|
912
|
+
const message = createToolResultMessage({
|
|
913
|
+
callId: ToolCallId(callId),
|
|
914
|
+
content: convertContent(event.result?.content),
|
|
915
|
+
isError,
|
|
916
|
+
});
|
|
917
|
+
this.session.append("tool/result", {
|
|
918
|
+
turn: this.#dashTurn,
|
|
919
|
+
step: this.#step,
|
|
920
|
+
message,
|
|
921
|
+
...(isError ? { error: { name: "ToolExecutionError", code: "TOOL_ERROR" } } : {}),
|
|
922
|
+
}, {
|
|
923
|
+
surfaceOp: "append",
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
#appendToolResultMessage(message) {
|
|
927
|
+
const callId = String(message.toolCallId ?? "");
|
|
928
|
+
// The normal path bridges via `tool_execution_end` first; skip the
|
|
929
|
+
// `message_end(toolResult)` duplicate the wire always also emits.
|
|
930
|
+
if (this.#bridgedToolResults.has(callId))
|
|
931
|
+
return;
|
|
932
|
+
this.#bridgedToolResults.add(callId);
|
|
933
|
+
const isError = Boolean(message.isError ?? false);
|
|
934
|
+
const result = createToolResultMessage({
|
|
935
|
+
callId: ToolCallId(callId),
|
|
936
|
+
content: convertContent(message.content),
|
|
937
|
+
isError,
|
|
938
|
+
});
|
|
939
|
+
this.session.append("tool/result", {
|
|
940
|
+
turn: this.#dashTurn,
|
|
941
|
+
step: this.#step,
|
|
942
|
+
message: result,
|
|
943
|
+
...(isError ? { error: { name: "ToolExecutionError", code: "TOOL_ERROR" } } : {}),
|
|
944
|
+
}, {
|
|
945
|
+
surfaceOp: "append",
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|