antechamber 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +143 -0
- package/LICENSE +22 -0
- package/README.md +316 -2
- package/bin/opencode-antechamber +18 -0
- package/bin/opencode-pi +3 -0
- package/dist/agent-dir.d.ts +6 -0
- package/dist/agent-dir.js +21 -0
- package/dist/agent-name.d.ts +24 -0
- package/dist/agent-name.js +110 -0
- package/dist/approvals.d.ts +86 -0
- package/dist/approvals.js +520 -0
- package/dist/catalog.d.ts +88 -0
- package/dist/catalog.js +118 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +86 -0
- package/dist/commands/context.d.ts +26 -0
- package/dist/commands/context.js +89 -0
- package/dist/commands/doctor.d.ts +13 -0
- package/dist/commands/doctor.js +92 -0
- package/dist/commands/help.d.ts +1 -0
- package/dist/commands/help.js +15 -0
- package/dist/commands/serve.d.ts +8 -0
- package/dist/commands/serve.js +29 -0
- package/dist/commands/setup.d.ts +14 -0
- package/dist/commands/setup.js +137 -0
- package/dist/config.d.ts +18 -0
- package/dist/config.js +40 -0
- package/dist/http.d.ts +18 -0
- package/dist/http.js +168 -0
- package/dist/lifecycle.d.ts +43 -0
- package/dist/lifecycle.js +138 -0
- package/dist/misc-routes.d.ts +8 -0
- package/dist/misc-routes.js +119 -0
- package/dist/pi-event-translation.d.ts +65 -0
- package/dist/pi-event-translation.js +545 -0
- package/dist/pi-rpc.d.ts +68 -0
- package/dist/pi-rpc.js +285 -0
- package/dist/pidfd.d.ts +2 -0
- package/dist/pidfd.js +28 -0
- package/dist/question-forms.d.ts +30 -0
- package/dist/question-forms.js +46 -0
- package/dist/route-services.d.ts +26 -0
- package/dist/route-services.js +33 -0
- package/dist/routes-v1.d.ts +4 -0
- package/dist/routes-v1.js +346 -0
- package/dist/routes-v2.d.ts +4 -0
- package/dist/routes-v2.js +477 -0
- package/dist/server.d.ts +28 -0
- package/dist/server.js +113 -0
- package/dist/session-commands.d.ts +75 -0
- package/dist/session-commands.js +323 -0
- package/dist/session-diff.d.ts +14 -0
- package/dist/session-diff.js +152 -0
- package/dist/session-history.d.ts +35 -0
- package/dist/session-history.js +416 -0
- package/dist/session-hosts.d.ts +3 -0
- package/dist/session-hosts.js +188 -0
- package/dist/session-store.d.ts +103 -0
- package/dist/session-store.js +339 -0
- package/dist/session-tree.d.ts +59 -0
- package/dist/session-tree.js +324 -0
- package/dist/sessions.d.ts +174 -0
- package/dist/sessions.js +1141 -0
- package/dist/sse.d.ts +19 -0
- package/dist/sse.js +92 -0
- package/dist/subagents.d.ts +64 -0
- package/dist/subagents.js +621 -0
- package/dist/thinking-levels.d.ts +17 -0
- package/dist/thinking-levels.js +24 -0
- package/dist/todo-bridge.d.ts +26 -0
- package/dist/todo-bridge.js +100 -0
- package/dist/tool-map.d.ts +32 -0
- package/dist/tool-map.js +349 -0
- package/dist/types.d.ts +338 -0
- package/dist/types.js +6 -0
- package/dist/v2.d.ts +190 -0
- package/dist/v2.js +553 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +13 -0
- package/extensions/omo-dag/README.md +159 -0
- package/extensions/omo-dag/docs/DESIGN.md +105 -0
- package/extensions/omo-dag/icon.svg +1 -0
- package/extensions/omo-dag/package.json +33 -0
- package/extensions/omo-dag/panel/index.html +179 -0
- package/extensions/omo-dag/panel/main.js +176 -0
- package/extensions/omo-dag/service/main.js +378 -0
- package/package.json +77 -4
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
/** Pi message/part translation and the live event bridge, with explicit manager services. */
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { applyProviderToolResults, finishProviderToolParts, mapTool, providerToolParts, resultMetadata, resultText, } from "./tool-map.js";
|
|
4
|
+
import { TodoBridge } from "./todo-bridge.js";
|
|
5
|
+
import { recordTurn } from "./session-tree.js";
|
|
6
|
+
import { touchedFiles } from "./session-diff.js";
|
|
7
|
+
import { getAgentName } from "./agent-name.js";
|
|
8
|
+
import { debugLog } from "./misc-routes.js";
|
|
9
|
+
import { envSetting } from "./config.js";
|
|
10
|
+
export function id(prefix) {
|
|
11
|
+
return `${prefix}_${randomUUID().replaceAll("-", "")}`;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* pi reports the requested `model` and, once the provider has answered, the
|
|
15
|
+
* concrete `responseModel` it actually served. The latter is the truth.
|
|
16
|
+
*/
|
|
17
|
+
export function piModelId(responseModel, model) {
|
|
18
|
+
return typeof responseModel === "string" && responseModel !== "" ? responseModel : model;
|
|
19
|
+
}
|
|
20
|
+
export function piStopReason(stopReason, errorMessage) {
|
|
21
|
+
switch (stopReason) {
|
|
22
|
+
case "stop":
|
|
23
|
+
case "length":
|
|
24
|
+
return { finish: stopReason };
|
|
25
|
+
case "toolUse":
|
|
26
|
+
return { finish: "tool-calls" };
|
|
27
|
+
case "error":
|
|
28
|
+
case "aborted":
|
|
29
|
+
return {
|
|
30
|
+
finish: "error",
|
|
31
|
+
error: {
|
|
32
|
+
name: stopReason === "aborted" ? "MessageAbortedError" : "UnknownError",
|
|
33
|
+
data: { message: errorMessage ?? (stopReason === "aborted" ? "Cancelled" : "Provider error") },
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
default:
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function textOf(content) {
|
|
41
|
+
if (!Array.isArray(content))
|
|
42
|
+
return "";
|
|
43
|
+
return content.map((b) => (b?.type === "text" && typeof b.text === "string" ? b.text : "")).join("");
|
|
44
|
+
}
|
|
45
|
+
export function newPart(kind, sessionID, messageID) {
|
|
46
|
+
const base = {
|
|
47
|
+
id: id("prt"),
|
|
48
|
+
sessionID,
|
|
49
|
+
messageID,
|
|
50
|
+
text: "",
|
|
51
|
+
time: { start: Date.now() },
|
|
52
|
+
};
|
|
53
|
+
return kind === "text"
|
|
54
|
+
? { ...base, type: "text" }
|
|
55
|
+
: { ...base, type: "reasoning" };
|
|
56
|
+
}
|
|
57
|
+
export function applyUsage(msg, usage) {
|
|
58
|
+
if (!usage)
|
|
59
|
+
return;
|
|
60
|
+
msg.tokens = {
|
|
61
|
+
input: usage.input ?? 0,
|
|
62
|
+
output: usage.output ?? 0,
|
|
63
|
+
reasoning: usage.reasoning ?? 0,
|
|
64
|
+
cache: { read: usage.cacheRead ?? 0, write: usage.cacheWrite ?? 0 },
|
|
65
|
+
};
|
|
66
|
+
if (usage.cost?.total !== undefined)
|
|
67
|
+
msg.cost = usage.cost.total;
|
|
68
|
+
}
|
|
69
|
+
/** Translates per-session events; registry and lifecycle operations stay with SessionManager. */
|
|
70
|
+
export class PiEventTranslator {
|
|
71
|
+
services;
|
|
72
|
+
constructor(services) {
|
|
73
|
+
this.services = services;
|
|
74
|
+
}
|
|
75
|
+
setStatus(s, status) {
|
|
76
|
+
s.status = status;
|
|
77
|
+
this.services.emit(s.info.directory, {
|
|
78
|
+
type: "session.status",
|
|
79
|
+
properties: { sessionID: s.info.id, status },
|
|
80
|
+
});
|
|
81
|
+
if (status.type === "idle") {
|
|
82
|
+
this.services.emit(s.info.directory, { type: "session.idle", properties: { sessionID: s.info.id } });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
emitPart(s, part, delta) {
|
|
86
|
+
this.services.emit(s.info.directory, {
|
|
87
|
+
type: "message.part.updated",
|
|
88
|
+
properties: delta === undefined ? { part } : { part, delta },
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
deliverInput(s, messageID) {
|
|
92
|
+
this.services.emit(s.info.directory, {
|
|
93
|
+
type: "session.inbox.delivered",
|
|
94
|
+
properties: { sessionID: s.info.id, inboxID: messageID },
|
|
95
|
+
});
|
|
96
|
+
s.inputs?.delete(messageID);
|
|
97
|
+
}
|
|
98
|
+
reconcileUser(s, ev) {
|
|
99
|
+
const message = ev.message;
|
|
100
|
+
if (message?.role !== "user")
|
|
101
|
+
return false;
|
|
102
|
+
const text = textOf(message.content);
|
|
103
|
+
const correlated = typeof ev.clientMessageId === "string" ? ev.clientMessageId : message.clientMessageId;
|
|
104
|
+
const pending = correlated ?? [...(s.inputs ?? [])].find(([, input]) => !input.delivered && input.text === text)?.[0];
|
|
105
|
+
const messageID = pending ?? (ev.type === "message_end" ? s.activeUserMessageID : undefined) ?? id("msg");
|
|
106
|
+
let user = this.services.ensureMessages(s).find((item) => item.info.id === messageID);
|
|
107
|
+
if (user === undefined) {
|
|
108
|
+
user = {
|
|
109
|
+
info: {
|
|
110
|
+
id: messageID,
|
|
111
|
+
sessionID: s.info.id,
|
|
112
|
+
role: "user",
|
|
113
|
+
time: { created: message.timestamp ?? Date.now() },
|
|
114
|
+
agent: getAgentName(),
|
|
115
|
+
model: s.model ?? { providerID: "unknown", modelID: "unknown" },
|
|
116
|
+
},
|
|
117
|
+
parts: [{ id: id("prt"), sessionID: s.info.id, messageID, type: "text", text }],
|
|
118
|
+
};
|
|
119
|
+
s.messages?.push(user);
|
|
120
|
+
this.services.emit(s.info.directory, { type: "message.updated", properties: { info: user.info } });
|
|
121
|
+
for (const part of user.parts)
|
|
122
|
+
this.emitPart(s, part);
|
|
123
|
+
if (pending === undefined) {
|
|
124
|
+
this.services.emit(s.info.directory, {
|
|
125
|
+
type: "session.inbox.enqueued",
|
|
126
|
+
properties: {
|
|
127
|
+
sessionID: s.info.id,
|
|
128
|
+
inboxID: messageID,
|
|
129
|
+
item: { type: "user", delivery: "steer", payload: { text } },
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
this.deliverInput(s, messageID);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
s.activeUserMessageID = messageID;
|
|
136
|
+
const input = s.inputs?.get(messageID);
|
|
137
|
+
if (input && !input.delivered) {
|
|
138
|
+
input.delivered = true;
|
|
139
|
+
if (input.accepted)
|
|
140
|
+
this.deliverInput(s, messageID);
|
|
141
|
+
}
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
onPiEvent(s, ev) {
|
|
145
|
+
this.services.subagents.wake(s, ev);
|
|
146
|
+
const dir = s.info.directory;
|
|
147
|
+
if (envSetting("DEBUG")) {
|
|
148
|
+
const ame = ev.assistantMessageEvent?.type;
|
|
149
|
+
debugLog(`[pi-event] ${String(ev.type)}${ame ? `/${ame}` : ""} ${JSON.stringify(ev).slice(0, 300)}`);
|
|
150
|
+
}
|
|
151
|
+
switch (ev.type) {
|
|
152
|
+
case "entry_appended": {
|
|
153
|
+
s.todoBridge ??= new TodoBridge(s.info.id);
|
|
154
|
+
const event = s.todoBridge.entry(ev.entry, (part) => this.emitPart(s, part));
|
|
155
|
+
if (event !== undefined)
|
|
156
|
+
this.services.emit(dir, event);
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
case "session_info_changed":
|
|
160
|
+
this.services.applyNativeTitle(s, ev.name);
|
|
161
|
+
break;
|
|
162
|
+
case "agent_start":
|
|
163
|
+
this.setStatus(s, { type: "busy" });
|
|
164
|
+
break;
|
|
165
|
+
case "agent_end": {
|
|
166
|
+
if (ev.willRetry === true)
|
|
167
|
+
break; // auto-retry keeps the session busy
|
|
168
|
+
s.approvals?.finishTurn();
|
|
169
|
+
this.finalizeDanglingTools(s);
|
|
170
|
+
this.services.subagents.finishCalls(s.info.id);
|
|
171
|
+
this.finishStreaming(s);
|
|
172
|
+
this.services.emitDiff(s);
|
|
173
|
+
s.info.time.updated = Date.now();
|
|
174
|
+
this.services.persistMeta(s);
|
|
175
|
+
this.services.emit(dir, { type: "session.updated", properties: { info: s.info } });
|
|
176
|
+
if (s.piPath !== undefined)
|
|
177
|
+
this.services.store.update(s.info.id, { updated: Date.now() });
|
|
178
|
+
recordTurn(s);
|
|
179
|
+
this.services.persistMeta(s);
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
case "agent_idle":
|
|
183
|
+
// agent_end closes one run, not the session: OmO may still retry or
|
|
184
|
+
// start a deferred continuation. Only agent_idle settles that work.
|
|
185
|
+
this.setStatus(s, { type: "idle" });
|
|
186
|
+
break;
|
|
187
|
+
case "turn_end": {
|
|
188
|
+
s.approvals?.finishTurn();
|
|
189
|
+
// pi delivers tool results here, matched back to parts by toolCallId.
|
|
190
|
+
const results = ev.toolResults;
|
|
191
|
+
if (Array.isArray(results))
|
|
192
|
+
this.applyToolResults(s, results);
|
|
193
|
+
this.services.emitDiff(s);
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
case "tool_execution_start": {
|
|
197
|
+
if (typeof ev.toolCallId === "string" && typeof ev.toolName === "string") {
|
|
198
|
+
s.approvals?.noteToolStart(ev.toolCallId, ev.toolName, ev.args);
|
|
199
|
+
}
|
|
200
|
+
if (ev.toolName === "task")
|
|
201
|
+
break; // Expanded when the argument stream closes.
|
|
202
|
+
const callID = typeof ev.toolCallId === "string" ? ev.toolCallId : undefined;
|
|
203
|
+
const part = callID !== undefined ? s.toolByCallId.get(callID) : undefined;
|
|
204
|
+
if (part === undefined)
|
|
205
|
+
break;
|
|
206
|
+
const name = typeof ev.toolName === "string" ? ev.toolName : part.tool;
|
|
207
|
+
const mapped = mapTool(name, name === "todo" ? part.state.input : ev.args);
|
|
208
|
+
part.tool = mapped.tool;
|
|
209
|
+
part.state = {
|
|
210
|
+
status: "running",
|
|
211
|
+
input: mapped.input,
|
|
212
|
+
title: mapped.title,
|
|
213
|
+
...(mapped.metadata !== undefined ? { metadata: mapped.metadata } : {}),
|
|
214
|
+
time: { start: part.state.status !== "pending" ? part.state.time.start : Date.now() },
|
|
215
|
+
};
|
|
216
|
+
this.emitPart(s, part);
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
case "tool_execution_update": {
|
|
220
|
+
if (typeof ev.toolCallId === "string" &&
|
|
221
|
+
this.services.subagents.result(s, ev.toolCallId, ev.partialResult !== null && typeof ev.partialResult === "object" ? ev.partialResult : {}, false))
|
|
222
|
+
break;
|
|
223
|
+
const part = typeof ev.toolCallId === "string" ? s.toolByCallId.get(ev.toolCallId) : undefined;
|
|
224
|
+
if (part === undefined || part.state.status !== "running")
|
|
225
|
+
break;
|
|
226
|
+
this.emitPart(s, part);
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
case "tool_execution_end": {
|
|
230
|
+
if (typeof ev.toolCallId === "string")
|
|
231
|
+
s.approvals?.noteToolEnd(ev.toolCallId);
|
|
232
|
+
const result = ev.result !== null && typeof ev.result === "object" ? ev.result : {};
|
|
233
|
+
this.applyToolResults(s, [{ ...result, toolCallId: ev.toolCallId, isError: ev.isError }]);
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
case "message_start": {
|
|
237
|
+
if (this.reconcileUser(s, ev))
|
|
238
|
+
break;
|
|
239
|
+
const msg = ev.message;
|
|
240
|
+
if (msg?.role !== "assistant")
|
|
241
|
+
break; // user message already recorded at prompt()
|
|
242
|
+
// pi stamps the assistant message with the model really serving it.
|
|
243
|
+
// Trust that over our snapshot, which goes stale whenever pi switches on
|
|
244
|
+
// its own side (subagent routing, an in-session /model, a resumed file).
|
|
245
|
+
this.services.applyPiModel(s, msg.provider, piModelId(msg.responseModel, msg.model));
|
|
246
|
+
const parent = [...(s.messages ?? [])].reverse().find((m) => m.info.role === "user");
|
|
247
|
+
const assistant = {
|
|
248
|
+
id: id("msg"),
|
|
249
|
+
sessionID: s.info.id,
|
|
250
|
+
role: "assistant",
|
|
251
|
+
time: { created: Date.now() },
|
|
252
|
+
parentID: typeof ev.clientMessageId === "string"
|
|
253
|
+
? ev.clientMessageId
|
|
254
|
+
: (s.activeUserMessageID ?? parent?.info.id ?? ""),
|
|
255
|
+
modelID: s.model?.modelID ?? "unknown",
|
|
256
|
+
providerID: s.model?.providerID ?? "unknown",
|
|
257
|
+
mode: getAgentName(),
|
|
258
|
+
path: { cwd: s.info.directory, root: s.info.directory },
|
|
259
|
+
cost: 0,
|
|
260
|
+
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
261
|
+
};
|
|
262
|
+
s.streaming = { message: assistant, parts: new Map() };
|
|
263
|
+
s.messages?.push({ info: assistant, parts: [] });
|
|
264
|
+
this.services.emit(dir, { type: "message.updated", properties: { info: assistant } });
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
case "message_update":
|
|
268
|
+
this.onMessageUpdate(s, ev);
|
|
269
|
+
break;
|
|
270
|
+
case "message_end": {
|
|
271
|
+
if (this.reconcileUser(s, ev))
|
|
272
|
+
break;
|
|
273
|
+
const ended = ev.message;
|
|
274
|
+
if (ended?.role === "assistant" && s.streaming) {
|
|
275
|
+
this.onProviderTools(s, ev.message);
|
|
276
|
+
if (ended.stopReason === "aborted" || ended.stopReason === "error") {
|
|
277
|
+
for (const part of finishProviderToolParts(s.toolByCallId.values(), Date.now(), ended.stopReason === "aborted" ? "Cancelled" : "No result"))
|
|
278
|
+
this.emitPart(s, part);
|
|
279
|
+
}
|
|
280
|
+
// Last chance to correct attribution: some providers only reveal the
|
|
281
|
+
// concrete model once the stream has closed.
|
|
282
|
+
if (this.services.applyPiModel(s, ended.provider, piModelId(ended.responseModel, ended.model)) &&
|
|
283
|
+
s.model !== undefined) {
|
|
284
|
+
s.streaming.message.providerID = s.model.providerID;
|
|
285
|
+
s.streaming.message.modelID = s.model.modelID;
|
|
286
|
+
}
|
|
287
|
+
applyUsage(s.streaming.message, ended.usage);
|
|
288
|
+
Object.assign(s.streaming.message, piStopReason(ended.stopReason, ended.errorMessage));
|
|
289
|
+
s.streaming.message.time.completed = Date.now();
|
|
290
|
+
this.services.emit(dir, { type: "message.updated", properties: { info: s.streaming.message } });
|
|
291
|
+
this.finishStreaming(s);
|
|
292
|
+
}
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
case "auto_retry_start":
|
|
296
|
+
this.setStatus(s, {
|
|
297
|
+
type: "retry",
|
|
298
|
+
attempt: Number(ev.attempt ?? 1),
|
|
299
|
+
message: String(ev.errorMessage ?? "retrying"),
|
|
300
|
+
next: Date.now() + Number(ev.delayMs ?? 0),
|
|
301
|
+
});
|
|
302
|
+
break;
|
|
303
|
+
case "extension_error":
|
|
304
|
+
this.services.emit(dir, {
|
|
305
|
+
type: "session.error",
|
|
306
|
+
properties: {
|
|
307
|
+
sessionID: s.info.id,
|
|
308
|
+
error: { name: "UnknownError", data: { message: String(ev.error ?? "pi extension error") } },
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
break;
|
|
312
|
+
default:
|
|
313
|
+
break; // turn_start, queue_update, compaction_* — future milestones
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
finishStreaming(s) {
|
|
317
|
+
if (s.streaming)
|
|
318
|
+
s.streaming = undefined;
|
|
319
|
+
}
|
|
320
|
+
onMessageUpdate(s, ev) {
|
|
321
|
+
const streaming = s.streaming;
|
|
322
|
+
if (!streaming)
|
|
323
|
+
return;
|
|
324
|
+
const ame = ev.assistantMessageEvent;
|
|
325
|
+
this.onProviderTools(s, ev.message ?? ame?.partial);
|
|
326
|
+
if (!ame || ame.contentIndex === undefined)
|
|
327
|
+
return;
|
|
328
|
+
if (ame.type.startsWith("toolcall_")) {
|
|
329
|
+
this.onToolcallEvent(s, ame);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const dir = s.info.directory;
|
|
333
|
+
// `wire` is pi's content-block name (thinking_* / text_*); `kind` is the
|
|
334
|
+
// OpenCode part type we translate it into. Keep the two separate: deriving
|
|
335
|
+
// the delta/end event names from `kind` produced "reasoning_delta"/"reasoning_end",
|
|
336
|
+
// which pi never emits, so thinking content was silently dropped.
|
|
337
|
+
const wire = ame.type.startsWith("thinking") ? "thinking" : ame.type.startsWith("text") ? "text" : null;
|
|
338
|
+
if (!wire)
|
|
339
|
+
return; // start / done / error wrappers — no part content
|
|
340
|
+
const kind = wire === "thinking" ? "reasoning" : "text";
|
|
341
|
+
let part = streaming.parts.get(ame.contentIndex);
|
|
342
|
+
if (part !== undefined && part.type === "tool")
|
|
343
|
+
return; // defensive: slot holds a tool part
|
|
344
|
+
if (!part) {
|
|
345
|
+
part = newPart(kind, s.info.id, streaming.message.id);
|
|
346
|
+
streaming.parts.set(ame.contentIndex, part);
|
|
347
|
+
const record = s.messages?.find((m) => m.info.id === streaming.message.id);
|
|
348
|
+
record?.parts.push(part);
|
|
349
|
+
this.emitPart(s, part);
|
|
350
|
+
}
|
|
351
|
+
const textPart = part;
|
|
352
|
+
if (ame.type === `${wire}_delta` && typeof ame.delta === "string") {
|
|
353
|
+
textPart.text += ame.delta;
|
|
354
|
+
this.emitPart(s, textPart, ame.delta);
|
|
355
|
+
}
|
|
356
|
+
else if (ame.type === `${wire}_end`) {
|
|
357
|
+
if (typeof ame.content === "string")
|
|
358
|
+
textPart.text = ame.content;
|
|
359
|
+
if (textPart.time)
|
|
360
|
+
textPart.time.end = Date.now();
|
|
361
|
+
this.emitPart(s, textPart);
|
|
362
|
+
}
|
|
363
|
+
this.services.emit(dir, { type: "message.updated", properties: { info: streaming.message } });
|
|
364
|
+
}
|
|
365
|
+
onProviderTools(s, message) {
|
|
366
|
+
const streaming = s.streaming;
|
|
367
|
+
if (!streaming || message === null || typeof message !== "object")
|
|
368
|
+
return;
|
|
369
|
+
const snapshot = message;
|
|
370
|
+
const timestamp = typeof snapshot.timestamp === "number" ? snapshot.timestamp : streaming.message.time.created;
|
|
371
|
+
const mapped = providerToolParts(snapshot.content, s.info.id, streaming.message.id, timestamp);
|
|
372
|
+
for (const [index, part] of mapped) {
|
|
373
|
+
streaming.parts.set(index, part);
|
|
374
|
+
s.toolByCallId.set(part.callID, part);
|
|
375
|
+
this.emitPart(s, part);
|
|
376
|
+
}
|
|
377
|
+
for (const part of applyProviderToolResults(snapshot.content, s.toolByCallId.values(), timestamp)) {
|
|
378
|
+
this.emitPart(s, part);
|
|
379
|
+
}
|
|
380
|
+
const owner = s.messages?.find((m) => m.info.id === streaming.message.id);
|
|
381
|
+
if (owner && mapped.size > 0) {
|
|
382
|
+
for (const part of mapped.values()) {
|
|
383
|
+
const index = owner.parts.findIndex((existing) => existing.id === part.id);
|
|
384
|
+
if (index < 0)
|
|
385
|
+
owner.parts.push(part);
|
|
386
|
+
else
|
|
387
|
+
owner.parts[index] = part;
|
|
388
|
+
}
|
|
389
|
+
// Task expansion replaces the streaming placeholder with one or more
|
|
390
|
+
// cards sharing its callID. Order those cards without reconstructing it.
|
|
391
|
+
const indices = new Map([...streaming.parts].map(([index, part]) => [part.type === "tool" ? part.callID : part.id, index]));
|
|
392
|
+
owner.parts.sort((a, b) => (indices.get(a.type === "tool" ? a.callID : a.id) ?? Infinity) -
|
|
393
|
+
(indices.get(b.type === "tool" ? b.callID : b.id) ?? Infinity));
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* pi toolcall events → OpenCode tool parts (M2). Live-observed shapes
|
|
398
|
+
* (probe, 2026-09-21):
|
|
399
|
+
* toolcall_start {contentIndex, id, toolName}
|
|
400
|
+
* toolcall_delta {contentIndex, delta} — JSON argument fragments
|
|
401
|
+
* toolcall_end {contentIndex, toolCall:{id, name, arguments}}
|
|
402
|
+
* Results arrive separately in turn_end.toolResults
|
|
403
|
+
* [{toolCallId, toolName, content:[{type:"text",text}], isError}].
|
|
404
|
+
*/
|
|
405
|
+
onToolcallEvent(s, ame) {
|
|
406
|
+
const streaming = s.streaming;
|
|
407
|
+
if (!streaming || ame.contentIndex === undefined)
|
|
408
|
+
return;
|
|
409
|
+
const contentIndex = ame.contentIndex;
|
|
410
|
+
const dir = s.info.directory;
|
|
411
|
+
let part = streaming.parts.get(contentIndex);
|
|
412
|
+
if (!part) {
|
|
413
|
+
const callID = ame.toolCall?.id ?? ame.id ?? `call_${contentIndex}`;
|
|
414
|
+
part = {
|
|
415
|
+
id: id("prt"),
|
|
416
|
+
sessionID: s.info.id,
|
|
417
|
+
messageID: streaming.message.id,
|
|
418
|
+
type: "tool",
|
|
419
|
+
callID,
|
|
420
|
+
tool: ame.toolCall?.name ?? ame.toolName ?? "unknown",
|
|
421
|
+
state: { status: "pending", input: {}, raw: "" },
|
|
422
|
+
};
|
|
423
|
+
streaming.parts.set(contentIndex, part);
|
|
424
|
+
const record = s.messages?.find((m) => m.info.id === streaming.message.id);
|
|
425
|
+
record?.parts.push(part);
|
|
426
|
+
}
|
|
427
|
+
if (ame.type === "toolcall_start") {
|
|
428
|
+
if (part.tool === "task")
|
|
429
|
+
return;
|
|
430
|
+
this.emitPart(s, part);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (ame.type === "toolcall_delta") {
|
|
434
|
+
// Arguments stream as JSON fragments — accumulate in state.raw (the
|
|
435
|
+
// OpenCode pending state is designed exactly for this).
|
|
436
|
+
if (part.state.status === "pending" && typeof ame.delta === "string") {
|
|
437
|
+
part.state.raw += ame.delta;
|
|
438
|
+
if (part.tool !== "task")
|
|
439
|
+
this.emitPart(s, part);
|
|
440
|
+
}
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (ame.type === "toolcall_end") {
|
|
444
|
+
const args = ame.toolCall?.arguments ?? {};
|
|
445
|
+
const callID = ame.toolCall?.id ?? part.callID;
|
|
446
|
+
const name = ame.toolCall?.name ?? part.tool;
|
|
447
|
+
if (name === "task") {
|
|
448
|
+
part.callID = callID;
|
|
449
|
+
this.services.subagents.start(s, part, args);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
const mapped = mapTool(name, args);
|
|
453
|
+
part.callID = callID;
|
|
454
|
+
part.tool = mapped.tool;
|
|
455
|
+
part.state = {
|
|
456
|
+
status: "running",
|
|
457
|
+
input: mapped.input,
|
|
458
|
+
title: mapped.title,
|
|
459
|
+
...(mapped.metadata !== undefined ? { metadata: mapped.metadata } : {}),
|
|
460
|
+
time: { start: Date.now() },
|
|
461
|
+
};
|
|
462
|
+
s.toolByCallId.set(callID, part);
|
|
463
|
+
s.todoBridge ??= new TodoBridge(s.info.id);
|
|
464
|
+
s.todoBridge.track(part, args);
|
|
465
|
+
this.emitPart(s, part);
|
|
466
|
+
this.services.emit(dir, { type: "message.updated", properties: { info: streaming.message } });
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
if (ame.type === "toolcall_error") {
|
|
470
|
+
part.state = {
|
|
471
|
+
status: "error",
|
|
472
|
+
input: part.state.input ?? {},
|
|
473
|
+
error: ame.delta ?? "tool call errored",
|
|
474
|
+
time: { start: Date.now(), end: Date.now() },
|
|
475
|
+
};
|
|
476
|
+
this.emitPart(s, part);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
/** turn_end.toolResults → finalize tool parts (completed / error). */
|
|
480
|
+
applyToolResults(s, results) {
|
|
481
|
+
for (const tr of results) {
|
|
482
|
+
const callID = typeof tr.toolCallId === "string" ? tr.toolCallId : undefined;
|
|
483
|
+
if (callID !== undefined && this.services.subagents.result(s, callID, tr, true))
|
|
484
|
+
continue;
|
|
485
|
+
const part = callID !== undefined ? s.toolByCallId.get(callID) : undefined;
|
|
486
|
+
if (!part || callID === undefined)
|
|
487
|
+
continue;
|
|
488
|
+
s.toolByCallId.delete(callID);
|
|
489
|
+
s.todoBridge?.finish(part, tr.isError === true);
|
|
490
|
+
const text = resultText(tr.content);
|
|
491
|
+
const startedAt = part.state.status !== "pending" ? part.state.time.start : Date.now();
|
|
492
|
+
const input = part.state.input ?? {};
|
|
493
|
+
const metadata = {
|
|
494
|
+
...(part.state.status !== "pending" ? part.state.metadata : {}),
|
|
495
|
+
...resultMetadata(part.tool, tr),
|
|
496
|
+
};
|
|
497
|
+
const title = part.state.status === "running" || part.state.status === "completed"
|
|
498
|
+
? (part.state.title ?? part.tool)
|
|
499
|
+
: part.tool;
|
|
500
|
+
if (tr.isError === true) {
|
|
501
|
+
part.state = {
|
|
502
|
+
status: "error",
|
|
503
|
+
input,
|
|
504
|
+
error: text || `tool ${part.tool} failed`,
|
|
505
|
+
metadata,
|
|
506
|
+
time: { start: startedAt, end: Date.now() },
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
else {
|
|
510
|
+
part.state = {
|
|
511
|
+
status: "completed",
|
|
512
|
+
input,
|
|
513
|
+
output: text,
|
|
514
|
+
title,
|
|
515
|
+
metadata,
|
|
516
|
+
time: { start: startedAt, end: Date.now() },
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
if (touchedFiles([part]).length > 0)
|
|
520
|
+
this.services.dirtyDiffs.add(s.info.id);
|
|
521
|
+
this.emitPart(s, part);
|
|
522
|
+
const owner = s.messages?.find((m) => m.info.id === part.messageID);
|
|
523
|
+
if (owner)
|
|
524
|
+
this.services.emit(s.info.directory, { type: "message.updated", properties: { info: owner.info } });
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
/** Tool parts that never received a result (abort, crash) must not spin forever. */
|
|
528
|
+
finalizeDanglingTools(s) {
|
|
529
|
+
for (const part of finishProviderToolParts(s.toolByCallId.values(), Date.now()))
|
|
530
|
+
this.emitPart(s, part);
|
|
531
|
+
for (const part of s.toolByCallId.values()) {
|
|
532
|
+
if (part.state.status === "completed" || part.state.status === "error")
|
|
533
|
+
continue;
|
|
534
|
+
const startedAt = part.state.status !== "pending" ? part.state.time.start : Date.now();
|
|
535
|
+
part.state = {
|
|
536
|
+
status: "error",
|
|
537
|
+
input: part.state.input ?? {},
|
|
538
|
+
error: "run ended without a tool result",
|
|
539
|
+
time: { start: startedAt, end: Date.now() },
|
|
540
|
+
};
|
|
541
|
+
this.emitPart(s, part);
|
|
542
|
+
}
|
|
543
|
+
s.toolByCallId.clear();
|
|
544
|
+
}
|
|
545
|
+
}
|
package/dist/pi-rpc.d.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi RPC subprocess client.
|
|
3
|
+
*
|
|
4
|
+
* Speaks pi's headless JSONL protocol (`pi --mode rpc`): commands go to stdin,
|
|
5
|
+
* responses and events come back on stdout. Framing is strict LF-only JSONL —
|
|
6
|
+
* pi's docs warn that generic line readers (Node `readline`) also split on
|
|
7
|
+
* U+2028/U+2029, which are legal inside JSON strings, so we split on "\n"
|
|
8
|
+
* ourselves and only strip a single trailing "\r".
|
|
9
|
+
*/
|
|
10
|
+
import { EventEmitter } from "node:events";
|
|
11
|
+
import { type LifecycleClock } from "./lifecycle.js";
|
|
12
|
+
export interface PiRpcCommand {
|
|
13
|
+
id?: string;
|
|
14
|
+
type: string;
|
|
15
|
+
[key: string]: unknown;
|
|
16
|
+
}
|
|
17
|
+
export declare const RPC_COMMAND_TIMEOUT_MS = 60000;
|
|
18
|
+
export declare const MAX_JSONL_FRAME_BYTES: number;
|
|
19
|
+
export type PiRpcResponse<T = unknown> = {
|
|
20
|
+
readonly id: string;
|
|
21
|
+
readonly type: "response";
|
|
22
|
+
readonly command: string;
|
|
23
|
+
} & ({
|
|
24
|
+
readonly success: true;
|
|
25
|
+
readonly data?: T;
|
|
26
|
+
readonly error?: never;
|
|
27
|
+
} | {
|
|
28
|
+
readonly success: false;
|
|
29
|
+
readonly error?: string;
|
|
30
|
+
readonly data?: never;
|
|
31
|
+
});
|
|
32
|
+
export interface PiRpcClientOptions {
|
|
33
|
+
cwd: string;
|
|
34
|
+
/** Path/name of the pi binary. Defaults to ANTECHAMBER_PI_BINARY env or "omo" from PATH. */
|
|
35
|
+
piBinary?: string;
|
|
36
|
+
/** Extra CLI flags, e.g. ["--no-session"] or ["--provider", "x"]. */
|
|
37
|
+
args?: string[];
|
|
38
|
+
clock?: LifecycleClock;
|
|
39
|
+
}
|
|
40
|
+
export declare class PiRpcClient extends EventEmitter {
|
|
41
|
+
private options;
|
|
42
|
+
private proc;
|
|
43
|
+
private buffer;
|
|
44
|
+
private bufferBytes;
|
|
45
|
+
private decoder;
|
|
46
|
+
private pending;
|
|
47
|
+
private closed;
|
|
48
|
+
private stderrTail;
|
|
49
|
+
private disposing;
|
|
50
|
+
get pid(): number | undefined;
|
|
51
|
+
get isBusy(): boolean;
|
|
52
|
+
onEvent(listener: (payload: Record<string, unknown>) => void): this;
|
|
53
|
+
onExit(listener: (code: number | null, signal: string | null) => void): this;
|
|
54
|
+
onExtensionUI(listener: (request: Record<string, unknown>) => void): this;
|
|
55
|
+
constructor(options: PiRpcClientOptions);
|
|
56
|
+
private drain;
|
|
57
|
+
private frameOverflow;
|
|
58
|
+
private handleLine;
|
|
59
|
+
private handleExtensionUI;
|
|
60
|
+
respondExtensionUI(response: Record<string, unknown>): void;
|
|
61
|
+
private write;
|
|
62
|
+
private failAll;
|
|
63
|
+
/** Send a command and wait for its correlated response. */
|
|
64
|
+
command<T = unknown>(cmd: Omit<PiRpcCommand, "id">, timeoutMs?: number, signal?: AbortSignal, onDispatch?: () => void): Promise<PiRpcResponse<T>>;
|
|
65
|
+
/** Fire-and-forget command (no response correlation). */
|
|
66
|
+
notify(cmd: Omit<PiRpcCommand, "id">): void;
|
|
67
|
+
dispose(): Promise<void>;
|
|
68
|
+
}
|