antechamber 0.2.0 → 0.2.1
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 +17 -0
- package/README.md +7 -0
- package/bin/opencode-antechamber +4 -0
- package/dist/pi-event-translation.d.ts +1 -1
- package/dist/pi-event-translation.js +5 -2
- package/dist/routes-v1.js +5 -2
- package/dist/routes-v2.js +74 -65
- package/dist/session-commands.js +2 -2
- package/dist/session-history.d.ts +1 -1
- package/dist/session-history.js +2 -2
- package/dist/session-store.js +8 -5
- package/dist/session-tree.d.ts +1 -1
- package/dist/session-tree.js +8 -4
- package/dist/sessions.js +1 -1
- package/dist/vcs.d.ts +5 -0
- package/dist/vcs.js +199 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,23 @@ and this project uses [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.1] - 2026-10-15
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- fix(session): accept OmO messages stored as plain-string content when titling, binding and restoring sessions instead of crashing
|
|
15
|
+
- fix(api): answer OpenChamber's location eviction (`DELETE /api/debug/location`) with OpenCode's empty 204
|
|
16
|
+
- fix(api): answer the v2 routes OpenChamber 2.3.2 calls instead of a generic 404/501. Plugin list and check return an empty inventory, `/api/experimental/generate` returns OpenCode's typed 503, and plugin update, credential update/activate, integration connections and session generate/background/move return `UnsupportedError`
|
|
17
|
+
- fix(api): stop faking mutations. `POST /api/credential` and `PATCH /api/project/{projectID}` return `UnsupportedError` instead of keeping an in-memory credential or echoing an unsaved project
|
|
18
|
+
- fix(api): reject missing or empty prompt and synthetic-context input with a typed 400 before anything reaches OmO
|
|
19
|
+
- fix(vcs): back `/api/vcs/status`, `/api/vcs/diff`, `/api/vcs/branch` and `/api/vcs/base` with read-only git instead of fixed empty data
|
|
20
|
+
- fix(cli): refuse `opencode-antechamber upgrade` with the antechamber upgrade command, and document OpenChamber 2.3.2's OpenCode update notice
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- refactor: use `findLast` for last-match scans
|
|
25
|
+
- docs(api): unknown routes return 404 on both APIs; only unknown subpaths of an existing v2 session return 501
|
|
26
|
+
|
|
10
27
|
## [0.2.0] - 2026-10-14
|
|
11
28
|
|
|
12
29
|
### Added
|
package/README.md
CHANGED
|
@@ -147,6 +147,13 @@ Restart OpenChamber, or `systemctl --user restart openchamber.service` for a
|
|
|
147
147
|
managed service. Run `antechamber doctor` again. If the executable moved,
|
|
148
148
|
rerun `antechamber setup`.
|
|
149
149
|
|
|
150
|
+
OpenChamber 2.3.2 may announce a newer OpenCode release. antechamber reports
|
|
151
|
+
OpenCode API 2.0.20, the minimum OpenChamber accepts, and OpenChamber offers
|
|
152
|
+
no signal that keeps that check while hiding the notice. Dismiss it or turn off
|
|
153
|
+
OpenCode update notifications in OpenChamber. Its Update action runs
|
|
154
|
+
`opencode-antechamber upgrade`, which refuses; upgrade antechamber with the
|
|
155
|
+
commands above.
|
|
156
|
+
|
|
150
157
|
To roll back wiring, copy the backup printed by setup over the settings file:
|
|
151
158
|
|
|
152
159
|
```bash
|
package/bin/opencode-antechamber
CHANGED
|
@@ -22,6 +22,10 @@ fi
|
|
|
22
22
|
case "${1:-}" in
|
|
23
23
|
serve) shift; exec node "$CLI" serve "$@" ;;
|
|
24
24
|
--version|-v) exec node "$CLI" --version ;;
|
|
25
|
+
upgrade)
|
|
26
|
+
echo "opencode-antechamber: OpenChamber cannot upgrade antechamber; run 'npm update -g antechamber' (or 'bun add -g antechamber') instead" >&2
|
|
27
|
+
exit 1
|
|
28
|
+
;;
|
|
25
29
|
esac
|
|
26
30
|
echo "opencode-antechamber: unsupported command '${1:-}' (only 'serve' and '--version' are implemented)" >&2
|
|
27
31
|
exit 1
|
|
@@ -21,7 +21,7 @@ export declare function id(prefix: string): string;
|
|
|
21
21
|
*/
|
|
22
22
|
export declare function piModelId(responseModel: unknown, model: unknown): unknown;
|
|
23
23
|
export declare function piStopReason(stopReason: string | undefined, errorMessage?: string): Pick<OCAssistantMessage, "finish" | "error">;
|
|
24
|
-
export declare function textOf(content: Array<Record<string, unknown>> | undefined): string;
|
|
24
|
+
export declare function textOf(content: string | Array<Record<string, unknown>> | undefined): string;
|
|
25
25
|
/**
|
|
26
26
|
* Recompute the session's own spend from its loaded assistant messages. Subagent
|
|
27
27
|
* spend stays on the child sessions: OpenChamber sums the parentID subtree, so
|
|
@@ -38,6 +38,9 @@ export function piStopReason(stopReason, errorMessage) {
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
export function textOf(content) {
|
|
41
|
+
// pi allows plain-string message content (extensions, append_user_message).
|
|
42
|
+
if (typeof content === "string")
|
|
43
|
+
return content;
|
|
41
44
|
if (!Array.isArray(content))
|
|
42
45
|
return "";
|
|
43
46
|
return content.map((b) => (b?.type === "text" && typeof b.text === "string" ? b.text : "")).join("");
|
|
@@ -177,7 +180,7 @@ export class PiEventTranslator {
|
|
|
177
180
|
});
|
|
178
181
|
if (status.type === "idle") {
|
|
179
182
|
s.info.time.idle = Date.now();
|
|
180
|
-
const last =
|
|
183
|
+
const last = s.messages?.findLast((message) => message.info.role === "assistant");
|
|
181
184
|
s.info.outcome =
|
|
182
185
|
s.interruptRequested || (last?.info.role === "assistant" && last.info.error?.name === "MessageAbortedError")
|
|
183
186
|
? "interrupted"
|
|
@@ -418,7 +421,7 @@ export class PiEventTranslator {
|
|
|
418
421
|
// Trust that over our snapshot, which goes stale whenever pi switches on
|
|
419
422
|
// its own side (subagent routing, an in-session /model, a resumed file).
|
|
420
423
|
this.services.applyPiModel(s, msg.provider, piModelId(msg.responseModel, msg.model));
|
|
421
|
-
const parent =
|
|
424
|
+
const parent = s.messages?.findLast((m) => m.info.role === "user");
|
|
422
425
|
const assistant = {
|
|
423
426
|
id: id("msg"),
|
|
424
427
|
sessionID: s.info.id,
|
package/dist/routes-v1.js
CHANGED
|
@@ -31,6 +31,8 @@ function handleV1GlobalRoute(res, path, method) {
|
|
|
31
31
|
return false;
|
|
32
32
|
}
|
|
33
33
|
function parseV1Prompt(body, defaultModel) {
|
|
34
|
+
if (!Array.isArray(body.parts))
|
|
35
|
+
throw new RequestBodyError(400, "parts must be an array");
|
|
34
36
|
const parts = parsePromptParts(body.parts);
|
|
35
37
|
if (body.delivery !== undefined && body.delivery !== "steer" && body.delivery !== "queue")
|
|
36
38
|
throw new RequestBodyError(400, "delivery must be steer or queue");
|
|
@@ -39,8 +41,9 @@ function parseV1Prompt(body, defaultModel) {
|
|
|
39
41
|
.map((part) => part.text ?? "")
|
|
40
42
|
.join("\n");
|
|
41
43
|
const model = modelRefFromBody(body.model ?? (body.variant !== undefined ? defaultModel : undefined), body.variant);
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+
const native = nativePromptInput(text, parts);
|
|
45
|
+
if (native.message.trim() === "" && !native.images?.length)
|
|
46
|
+
throw new RequestBodyError(400, "prompt requires non-whitespace text, file content, or an agent mention");
|
|
44
47
|
return { text, model, parts, delivery: body.delivery ?? "steer" };
|
|
45
48
|
}
|
|
46
49
|
export async function handleV1(services, req, res, url, path, method, directory) {
|
package/dist/routes-v2.js
CHANGED
|
@@ -10,6 +10,7 @@ import { OPENCODE_API_VERSION } from "./version.js";
|
|
|
10
10
|
import { toV2Permission } from "./approvals.js";
|
|
11
11
|
import { formQuestionAnswer, questionFormID, toQuestionForm } from "./question-forms.js";
|
|
12
12
|
import { getAgentName } from "./agent-name.js";
|
|
13
|
+
import { vcsRead, VcsError } from "./vcs.js";
|
|
13
14
|
import { announceSessionError, compactSession, getCommands, unsupportedShare } from "./session-commands.js";
|
|
14
15
|
import { defaultV2Model, flattenModels, toV2Message, toV2Session } from "./v2.js";
|
|
15
16
|
import { agentDescriptors, modelRefFromBody, numberParam, OPENCODE_COMPAT_VERSION, promptAsync, runSessionShell, runSessionSlash, sessionDirectory, sessionMetadata, } from "./route-services.js";
|
|
@@ -31,15 +32,6 @@ function v2Session(services, info) {
|
|
|
31
32
|
return toV2Session(info, { model: managed?.model, agent: info.agent ?? getAgentName() });
|
|
32
33
|
}
|
|
33
34
|
const execFileAsync = promisify(execFile);
|
|
34
|
-
const credentialsByDirectory = new Map();
|
|
35
|
-
function credentials(directory) {
|
|
36
|
-
let stored = credentialsByDirectory.get(directory);
|
|
37
|
-
if (stored === undefined) {
|
|
38
|
-
stored = new Map();
|
|
39
|
-
credentialsByDirectory.set(directory, stored);
|
|
40
|
-
}
|
|
41
|
-
return stored;
|
|
42
|
-
}
|
|
43
35
|
function encodeSessionCursor(session) {
|
|
44
36
|
return Buffer.from(JSON.stringify({ updated: session.time.updated, id: session.id })).toString("base64url");
|
|
45
37
|
}
|
|
@@ -179,45 +171,24 @@ export async function handleV2(services, req, res, url, path, method, directory)
|
|
|
179
171
|
if (await handleV2Bootstrap(services, res, path, method, directory))
|
|
180
172
|
return;
|
|
181
173
|
if (path === "/api/debug/location" && method === "DELETE") {
|
|
182
|
-
|
|
174
|
+
// OpenChamber evicts a location after git worktree changes so OpenCode re-reads it. antechamber
|
|
175
|
+
// keeps no per-location instance (project and VCS reads are fresh per request): nothing to evict.
|
|
176
|
+
res.writeHead(204).end();
|
|
183
177
|
return;
|
|
184
178
|
}
|
|
185
179
|
// --- project / vcs / permission / form (2.0.4 bootstrap) ---
|
|
186
180
|
if (path === "/api/credential" && method === "GET") {
|
|
187
|
-
json(res, 200, withLocation([
|
|
181
|
+
json(res, 200, withLocation([]));
|
|
188
182
|
return;
|
|
189
183
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
body.integrationID === "" ||
|
|
196
|
-
typeof body.label !== "string") {
|
|
197
|
-
json(res, 400, errorBody("BadRequestError", "credential id, integrationID, and label are required"));
|
|
198
|
-
return;
|
|
199
|
-
}
|
|
200
|
-
const stored = credentials(directory);
|
|
201
|
-
if (body.activate === true) {
|
|
202
|
-
for (const credential of stored.values()) {
|
|
203
|
-
if (credential.integrationID === body.integrationID)
|
|
204
|
-
credential.active = false;
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
const credential = {
|
|
208
|
-
id: body.id,
|
|
209
|
-
integrationID: body.integrationID,
|
|
210
|
-
label: body.label,
|
|
211
|
-
value: body.value,
|
|
212
|
-
active: body.activate === true,
|
|
213
|
-
};
|
|
214
|
-
stored.set(credential.id, credential);
|
|
215
|
-
json(res, 200, withLocation(credential));
|
|
184
|
+
const credentialMatch = path.match(/^\/api\/credential\/([^/]+)(\/activate)?$/);
|
|
185
|
+
if ((path === "/api/credential" && method === "POST") ||
|
|
186
|
+
(credentialMatch && method === (credentialMatch[2] ? "POST" : "PATCH"))) {
|
|
187
|
+
await services.body(req);
|
|
188
|
+
json(res, 400, errorBody("UnsupportedError", "Credential management is not available; configure providers in OmO"));
|
|
216
189
|
return;
|
|
217
190
|
}
|
|
218
|
-
|
|
219
|
-
if (credentialMatch && method === "DELETE") {
|
|
220
|
-
credentials(directory).delete(decodeURIComponent(credentialMatch[1]));
|
|
191
|
+
if (credentialMatch && !credentialMatch[2] && method === "DELETE") {
|
|
221
192
|
res.writeHead(204).end();
|
|
222
193
|
return;
|
|
223
194
|
}
|
|
@@ -225,6 +196,36 @@ export async function handleV2(services, req, res, url, path, method, directory)
|
|
|
225
196
|
json(res, 200, withLocation([]));
|
|
226
197
|
return;
|
|
227
198
|
}
|
|
199
|
+
const integrationMatch = path.match(/^\/api\/integration\/([^/]+)$/);
|
|
200
|
+
if (integrationMatch && method === "GET") {
|
|
201
|
+
json(res, 404, errorBody("NotFoundError", `integration not found: ${decodeURIComponent(integrationMatch[1])}`));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if ((method === "POST" && /^\/api\/integration\/[^/]+\/connect\/(key|external|oauth|command)$/.test(path)) ||
|
|
205
|
+
((method === "GET" || method === "DELETE") &&
|
|
206
|
+
/^\/api\/integration\/[^/]+\/connect\/(oauth|command)\/[^/]+$/.test(path)) ||
|
|
207
|
+
(method === "POST" && /^\/api\/integration\/[^/]+\/connect\/oauth\/[^/]+\/complete$/.test(path))) {
|
|
208
|
+
json(res, 400, errorBody("UnsupportedError", "Integration connections are not available; configure providers in OmO"));
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if ((path === "/api/plugin" && method === "GET") || (path === "/api/plugin/check" && method === "POST")) {
|
|
212
|
+
if (method === "POST")
|
|
213
|
+
await services.body(req);
|
|
214
|
+
json(res, 200, withLocation([]));
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (path === "/api/plugin/update" && method === "POST") {
|
|
218
|
+
json(res, 400, errorBody("UnsupportedError", "Plugin updates are not available with OmO"));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (path === "/api/experimental/generate" && method === "POST") {
|
|
222
|
+
json(res, 503, {
|
|
223
|
+
_tag: "ServiceUnavailableError",
|
|
224
|
+
message: "No generation model is available through antechamber/OmO",
|
|
225
|
+
service: "opencode",
|
|
226
|
+
});
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
228
229
|
// Read-only inventories OpenChamber loads at bootstrap: OmO has no background shells,
|
|
229
230
|
// web-search providers or MCP servers, so each is an honest empty list (mutations stay 400).
|
|
230
231
|
if ((path === "/api/shell" || path === "/api/websearch/provider" || path === "/api/mcp") && method === "GET") {
|
|
@@ -263,14 +264,8 @@ export async function handleV2(services, req, res, url, path, method, directory)
|
|
|
263
264
|
}
|
|
264
265
|
const projectMatch = path.match(/^\/api\/project\/([^/]+)$/);
|
|
265
266
|
if (projectMatch && method === "PATCH") {
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
json(res, 200, {
|
|
269
|
-
id: projectId,
|
|
270
|
-
canonical: body.canonical ?? projectId,
|
|
271
|
-
time: { created: 0, updated: Date.now() },
|
|
272
|
-
sandboxes: [],
|
|
273
|
-
});
|
|
267
|
+
await services.body(req);
|
|
268
|
+
json(res, 400, errorBody("UnsupportedError", "Project updates are not available with OmO"));
|
|
274
269
|
return;
|
|
275
270
|
}
|
|
276
271
|
if (path === "/api/permission/request" && method === "GET") {
|
|
@@ -309,20 +304,15 @@ export async function handleV2(services, req, res, url, path, method, directory)
|
|
|
309
304
|
json(res, 200, withLocation({ provider: "git", branch: current === undefined ? {} : { current } }));
|
|
310
305
|
return;
|
|
311
306
|
}
|
|
312
|
-
if (
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
json(res, 200, withLocation([]));
|
|
322
|
-
return;
|
|
323
|
-
}
|
|
324
|
-
if (path === "/api/vcs/diff" && method === "GET") {
|
|
325
|
-
json(res, 200, withLocation(""));
|
|
307
|
+
if (method === "GET" && ["/api/vcs/base", "/api/vcs/status", "/api/vcs/branch", "/api/vcs/diff"].includes(path)) {
|
|
308
|
+
try {
|
|
309
|
+
json(res, 200, withLocation(await vcsRead(directory, path.slice("/api/vcs/".length), url)));
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
if (!(error instanceof VcsError))
|
|
313
|
+
throw error;
|
|
314
|
+
json(res, 400, errorBody(error.name, error.message));
|
|
315
|
+
}
|
|
326
316
|
return;
|
|
327
317
|
}
|
|
328
318
|
// --- sessions ---
|
|
@@ -494,6 +484,11 @@ export async function handleV2(services, req, res, url, path, method, directory)
|
|
|
494
484
|
}
|
|
495
485
|
}
|
|
496
486
|
switch (`${method} ${sub}`) {
|
|
487
|
+
case "POST /generate":
|
|
488
|
+
case "POST /background":
|
|
489
|
+
case "POST /move":
|
|
490
|
+
json(res, 400, errorBody("UnsupportedError", `Session ${sub.slice(1)} is not available with OmO`));
|
|
491
|
+
return;
|
|
497
492
|
case "GET /todo":
|
|
498
493
|
json(res, 200, { data: services.sessions.todos(sessionId) });
|
|
499
494
|
return;
|
|
@@ -586,12 +581,22 @@ export async function handleV2(services, req, res, url, path, method, directory)
|
|
|
586
581
|
return;
|
|
587
582
|
case "POST /prompt": {
|
|
588
583
|
const body = (await services.body(req));
|
|
584
|
+
if (typeof body.text !== "string") {
|
|
585
|
+
json(res, 400, { _tag: "InvalidRequestError", message: "text must be a string" });
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
589
588
|
const explicit = parsePromptParts(body.parts);
|
|
590
|
-
const
|
|
591
|
-
const text = typeof body.text === "string" ? body.text : firstText?.type === "text" ? firstText.text : "";
|
|
589
|
+
const text = body.text;
|
|
592
590
|
const parts = explicit.length > 0
|
|
593
591
|
? [...explicit, ...v2PromptParts("", body.files, body.agents)]
|
|
594
592
|
: v2PromptParts(text, body.files, body.agents);
|
|
593
|
+
if (text.trim() === "" && !parts.some((part) => part.type === "file" || part.type === "agent")) {
|
|
594
|
+
json(res, 400, {
|
|
595
|
+
_tag: "InvalidRequestError",
|
|
596
|
+
message: "prompt requires non-whitespace text, files, or agents",
|
|
597
|
+
});
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
595
600
|
const metadata = sessionMetadata(body.metadata);
|
|
596
601
|
if (body.skills !== undefined) {
|
|
597
602
|
if (!Array.isArray(body.skills))
|
|
@@ -633,13 +638,17 @@ export async function handleV2(services, req, res, url, path, method, directory)
|
|
|
633
638
|
case "POST /synthetic":
|
|
634
639
|
case "POST /context": {
|
|
635
640
|
const body = (await services.body(req));
|
|
641
|
+
if (typeof body.text !== "string" || body.text.trim() === "") {
|
|
642
|
+
json(res, 400, { _tag: "InvalidRequestError", message: "text must be a non-whitespace string" });
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
636
645
|
if (body.delivery !== undefined && body.delivery !== "steer" && body.delivery !== "queue") {
|
|
637
646
|
json(res, 400, errorBody("BadRequestError", 'delivery must be "steer" or "queue"'));
|
|
638
647
|
return;
|
|
639
648
|
}
|
|
640
649
|
const metadata = sessionMetadata(body.metadata);
|
|
641
650
|
const messageID = typeof body.id === "string" && body.id !== "" ? body.id : `msg_${randomUUID().replaceAll("-", "")}`;
|
|
642
|
-
const text =
|
|
651
|
+
const text = body.text;
|
|
643
652
|
promptAsync(services, managed, text, undefined, true, messageID, body.delivery ?? "steer", undefined, metadata);
|
|
644
653
|
// OpenChamber 2.3.2 treats anything but 200 + the admitted record as a failed send (captured OpenCode shape).
|
|
645
654
|
json(res, 200, {
|
package/dist/session-commands.js
CHANGED
|
@@ -24,7 +24,7 @@ function assistantMessage(session) {
|
|
|
24
24
|
const messages = session.messages ?? [];
|
|
25
25
|
const boundary = messages.findIndex((message) => message.info.id === session.tree.revert?.messageID);
|
|
26
26
|
const visible = boundary < 0 ? messages : messages.slice(0, boundary);
|
|
27
|
-
const parentID =
|
|
27
|
+
const parentID = visible.findLast((message) => message.info.role === "user")?.info.id ?? "";
|
|
28
28
|
return {
|
|
29
29
|
id: id("msg"),
|
|
30
30
|
sessionID: session.info.id,
|
|
@@ -141,7 +141,7 @@ export async function runSlashCommand(sessions, sessionId, input) {
|
|
|
141
141
|
await sessions.prompt(sessionId, text, undefined, false, input.messageID, "steer", input.parts ? [{ type: "text", text }, ...input.parts] : undefined);
|
|
142
142
|
await sessions.reloadMessages(sessionId);
|
|
143
143
|
const messages = sessions.messages(sessionId) ?? [];
|
|
144
|
-
return
|
|
144
|
+
return messages.findLast((message) => message.info.role === "assistant") ?? messages[messages.length - 1];
|
|
145
145
|
}
|
|
146
146
|
export function parseShellCommand(value) {
|
|
147
147
|
if (!isRecord(value) || typeof value.command !== "string" || value.command.trim() === "")
|
|
@@ -5,7 +5,7 @@ import { type PiUsage } from "./pi-event-translation.js";
|
|
|
5
5
|
export interface PiFileMessage {
|
|
6
6
|
clientMessageId?: string;
|
|
7
7
|
role?: string;
|
|
8
|
-
content?: Array<Record<string, unknown>>;
|
|
8
|
+
content?: string | Array<Record<string, unknown>>;
|
|
9
9
|
usage?: PiUsage;
|
|
10
10
|
provider?: string;
|
|
11
11
|
model?: string;
|
package/dist/session-history.js
CHANGED
|
@@ -90,7 +90,7 @@ function activeChain(s, entries) {
|
|
|
90
90
|
for (const entry of entries)
|
|
91
91
|
if (typeof entry.id === "string")
|
|
92
92
|
byId.set(entry.id, entry);
|
|
93
|
-
let leaf =
|
|
93
|
+
let leaf = entries.findLast((entry) => entry.type !== "session" && typeof entry.id === "string");
|
|
94
94
|
let selectedLeaf = s.tree.revert ? s.tree.revert.previousLeafId : s.tree.leafId;
|
|
95
95
|
if (!s.tree.revert && s.tree.localShellTurns?.length) {
|
|
96
96
|
// Follow a newly recorded descendant, never a sibling of the selected native branch.
|
|
@@ -411,7 +411,7 @@ export function parsePiSessionText(s, agentName, raw) {
|
|
|
411
411
|
}
|
|
412
412
|
// system / custom entries are not rendered
|
|
413
413
|
}
|
|
414
|
-
const lastAssistant =
|
|
414
|
+
const lastAssistant = chain.findLast((entry) => entry.message?.role === "assistant");
|
|
415
415
|
const stopReason = lastAssistant?.message?.stopReason;
|
|
416
416
|
if (lastAssistant !== undefined && ["stop", "length", "error", "aborted"].includes(stopReason ?? "")) {
|
|
417
417
|
finishProviderToolParts([...toolParts.values()].flat(), entryTime(lastAssistant), stopReason === "aborted" ? "Cancelled" : "No result");
|
package/dist/session-store.js
CHANGED
|
@@ -71,11 +71,14 @@ function titleFromHead(head, piSessionId) {
|
|
|
71
71
|
}
|
|
72
72
|
if (entry.type !== "message" || entry.message?.role !== "user")
|
|
73
73
|
continue;
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
74
|
+
const content = entry.message.content ?? [];
|
|
75
|
+
// pi allows plain-string message content (extensions, append_user_message).
|
|
76
|
+
const text = (typeof content === "string"
|
|
77
|
+
? content
|
|
78
|
+
: content
|
|
79
|
+
.filter((b) => b?.type === "text" && typeof b.text === "string")
|
|
80
|
+
.map((b) => b.text)
|
|
81
|
+
.join("\n")).trim();
|
|
79
82
|
if (text !== "")
|
|
80
83
|
return text.slice(0, TITLE_MAX_CHARS);
|
|
81
84
|
}
|
package/dist/session-tree.d.ts
CHANGED
package/dist/session-tree.js
CHANGED
|
@@ -86,10 +86,14 @@ function pruneStaleShellTurns(s, positions) {
|
|
|
86
86
|
});
|
|
87
87
|
}
|
|
88
88
|
function bindOrdinaryEntry(s, entry, messages, cursor) {
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
89
|
+
const content = entry.message?.content;
|
|
90
|
+
// pi allows plain-string message content (extensions, append_user_message).
|
|
91
|
+
const text = typeof content === "string"
|
|
92
|
+
? content
|
|
93
|
+
: content
|
|
94
|
+
?.filter((part) => part.type === "text")
|
|
95
|
+
.map((part) => part.text ?? "")
|
|
96
|
+
.join("");
|
|
93
97
|
const index = messages.findIndex((message, position) => position >= cursor &&
|
|
94
98
|
!s.localMessageIds?.has(message.info.id) &&
|
|
95
99
|
s.tree.messageEntries[message.info.id] === undefined &&
|
package/dist/sessions.js
CHANGED
|
@@ -711,7 +711,7 @@ export class SessionManager {
|
|
|
711
711
|
}
|
|
712
712
|
approvalsFor(s) {
|
|
713
713
|
s.hasPendingInteraction = () => s.approvals?.hasPending(s.info.id) ?? false;
|
|
714
|
-
return new ApprovalBridge(s.info.id, (event) => this.emit(s.info.directory, event), (response) => s.pi?.respondExtensionUI(response), () => s.streaming?.message.id ??
|
|
714
|
+
return new ApprovalBridge(s.info.id, (event) => this.emit(s.info.directory, event), (response) => s.pi?.respondExtensionUI(response), () => s.streaming?.message.id ?? s.messages?.findLast((m) => m.info.role === "assistant")?.info.id, async (response) => {
|
|
715
715
|
if (!s.pi)
|
|
716
716
|
return false;
|
|
717
717
|
const reply = await s.pi.command({ ...response, type: "extension_ui_response" });
|
package/dist/vcs.d.ts
ADDED
package/dist/vcs.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { devNull } from "node:os";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
const exec = promisify(execFile);
|
|
5
|
+
const GIT_EXEC_TIMEOUT_MS = 5_000;
|
|
6
|
+
const GIT_EXEC_MAX_BUFFER_BYTES = 16 * 1024 * 1024;
|
|
7
|
+
export class VcsError extends Error {
|
|
8
|
+
name = "BadRequestError";
|
|
9
|
+
}
|
|
10
|
+
async function git(directory, args, difference = false) {
|
|
11
|
+
try {
|
|
12
|
+
const { stdout } = await exec("git", args, {
|
|
13
|
+
cwd: directory,
|
|
14
|
+
timeout: GIT_EXEC_TIMEOUT_MS,
|
|
15
|
+
maxBuffer: GIT_EXEC_MAX_BUFFER_BYTES,
|
|
16
|
+
env: { ...process.env, GIT_OPTIONAL_LOCKS: "0", GIT_LITERAL_PATHSPECS: "1", LC_ALL: "C" },
|
|
17
|
+
});
|
|
18
|
+
return stdout;
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
// git diff --no-index exits 1 when it successfully finds a difference.
|
|
22
|
+
if (difference &&
|
|
23
|
+
error instanceof Error &&
|
|
24
|
+
"code" in error &&
|
|
25
|
+
error.code === 1 &&
|
|
26
|
+
"stdout" in error &&
|
|
27
|
+
typeof error.stdout === "string") {
|
|
28
|
+
return error.stdout;
|
|
29
|
+
}
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async function optionalRef(directory, args) {
|
|
34
|
+
try {
|
|
35
|
+
return (await git(directory, args)).trim() || undefined;
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (error instanceof Error && "code" in error && error.code === 1)
|
|
39
|
+
return undefined;
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async function base(directory) {
|
|
44
|
+
if ((await optionalRef(directory, ["rev-parse", "--verify", "--quiet", "HEAD"])) === undefined)
|
|
45
|
+
return null;
|
|
46
|
+
const current = await optionalRef(directory, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
|
|
47
|
+
if (current !== undefined) {
|
|
48
|
+
const reflog = await git(directory, ["reflog", "show", "--format=%gs", "--max-count=100", "HEAD"]);
|
|
49
|
+
for (const line of reflog.split("\n")) {
|
|
50
|
+
const checkout = line.match(/^checkout: moving from (.+) to (.+)$/);
|
|
51
|
+
if (!checkout || checkout[2] !== current || checkout[1] === current)
|
|
52
|
+
continue;
|
|
53
|
+
const name = checkout[1];
|
|
54
|
+
const ref = await optionalRef(directory, ["show-ref", "--verify", "--hash", `refs/heads/${name}`]);
|
|
55
|
+
if (ref !== undefined)
|
|
56
|
+
return { name, ref, source: "reflog" };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const remote = await optionalRef(directory, ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]);
|
|
60
|
+
for (const name of [...(remote === undefined ? [] : [remote]), "main", "master"]) {
|
|
61
|
+
const ref = await optionalRef(directory, ["rev-parse", "--verify", "--quiet", `${name}^{commit}`]);
|
|
62
|
+
if (ref !== undefined)
|
|
63
|
+
return { name, ref, source: "default" };
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
function nonNegativeInteger(url, key, fallback) {
|
|
68
|
+
const value = url.searchParams.get(key);
|
|
69
|
+
if (value === null)
|
|
70
|
+
return fallback;
|
|
71
|
+
if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value))) {
|
|
72
|
+
throw new VcsError(`${key} must be a non-negative integer`);
|
|
73
|
+
}
|
|
74
|
+
return Number(value);
|
|
75
|
+
}
|
|
76
|
+
async function diffs(directory, revision, committed, context, patches) {
|
|
77
|
+
const args = ["diff", "--no-ext-diff", "--no-textconv", "--no-color", "--no-renames", revision];
|
|
78
|
+
if (committed)
|
|
79
|
+
args.push("HEAD");
|
|
80
|
+
const names = (await git(directory, [...args, "--name-status", "-z", "--"])).split("\0");
|
|
81
|
+
const statuses = new Map();
|
|
82
|
+
for (let i = 0; i + 1 < names.length; i += 2) {
|
|
83
|
+
const file = names[i + 1];
|
|
84
|
+
statuses.set(file, names[i] === "A" ? "added" : names[i] === "D" ? "deleted" : "modified");
|
|
85
|
+
}
|
|
86
|
+
const counts = (await git(directory, [...args, "--numstat", "-z", "--"])).split("\0").filter(Boolean);
|
|
87
|
+
const result = [];
|
|
88
|
+
let bytes = 0;
|
|
89
|
+
const append = (entry) => {
|
|
90
|
+
bytes += Buffer.byteLength(entry.patch);
|
|
91
|
+
if (bytes > GIT_EXEC_MAX_BUFFER_BYTES)
|
|
92
|
+
throw new VcsError("Git diff exceeds the output limit");
|
|
93
|
+
result.push(entry);
|
|
94
|
+
};
|
|
95
|
+
for (const entry of counts) {
|
|
96
|
+
const first = entry.indexOf("\t");
|
|
97
|
+
const second = entry.indexOf("\t", first + 1);
|
|
98
|
+
const file = entry.slice(second + 1);
|
|
99
|
+
append({
|
|
100
|
+
file,
|
|
101
|
+
additions: Number(entry.slice(0, first)) || 0,
|
|
102
|
+
deletions: Number(entry.slice(first + 1, second)) || 0,
|
|
103
|
+
status: statuses.get(file) ?? "modified",
|
|
104
|
+
patch: patches ? await git(directory, [...args, `--unified=${context}`, "--", file]) : "",
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
if (!committed) {
|
|
108
|
+
const untracked = (await git(directory, ["ls-files", "--others", "--exclude-standard", "-z"])).split("\0");
|
|
109
|
+
for (const file of untracked.filter(Boolean)) {
|
|
110
|
+
const args = ["diff", "--no-index", "--no-ext-diff", "--no-textconv", "--no-color", `--unified=${context}`];
|
|
111
|
+
const counts = await git(directory, [...args, "--numstat", "--", devNull, file], true);
|
|
112
|
+
const [additions, deletions] = counts.split("\t");
|
|
113
|
+
append({
|
|
114
|
+
file,
|
|
115
|
+
additions: Number(additions) || 0,
|
|
116
|
+
deletions: Number(deletions) || 0,
|
|
117
|
+
status: "added",
|
|
118
|
+
patch: patches ? await git(directory, [...args, "--", devNull, file], true) : "",
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return result.sort((left, right) => left.file.localeCompare(right.file));
|
|
123
|
+
}
|
|
124
|
+
/** Read-only VCS operations; the router supplies the same directory as vcs.get. */
|
|
125
|
+
export async function vcsRead(directory, operation, url) {
|
|
126
|
+
const mode = url.searchParams.get("mode");
|
|
127
|
+
if (operation === "diff" && !["working", "branch", "committed"].includes(mode ?? "")) {
|
|
128
|
+
throw new VcsError("mode must be working, branch, or committed");
|
|
129
|
+
}
|
|
130
|
+
const context = operation === "diff" ? nonNegativeInteger(url, "context", 3) : 0;
|
|
131
|
+
const limit = operation === "branch" ? nonNegativeInteger(url, "limit", 100) : 0;
|
|
132
|
+
try {
|
|
133
|
+
let root;
|
|
134
|
+
try {
|
|
135
|
+
root = (await git(directory, ["rev-parse", "--show-toplevel"])).trim();
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
if (error instanceof Error &&
|
|
139
|
+
"stderr" in error &&
|
|
140
|
+
typeof error.stderr === "string" &&
|
|
141
|
+
error.stderr.startsWith("fatal: not a git repository")) {
|
|
142
|
+
return operation === "base" ? null : [];
|
|
143
|
+
}
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
if (operation === "base")
|
|
147
|
+
return await base(root);
|
|
148
|
+
if (operation === "branch") {
|
|
149
|
+
const search = url.searchParams.get("search") ?? "";
|
|
150
|
+
return (await git(root, ["for-each-ref", "--format=%(refname:short)", "refs/heads/"]))
|
|
151
|
+
.split("\n")
|
|
152
|
+
.filter((name) => name !== "" && name.includes(search))
|
|
153
|
+
.sort()
|
|
154
|
+
.slice(0, limit);
|
|
155
|
+
}
|
|
156
|
+
let revision = await optionalRef(root, ["rev-parse", "--verify", "--quiet", "HEAD"]);
|
|
157
|
+
if (operation === "diff" && mode !== "working") {
|
|
158
|
+
const requestedBase = url.searchParams.get("base") ?? (await base(root))?.name;
|
|
159
|
+
if (requestedBase === undefined)
|
|
160
|
+
throw new VcsError("No base branch is available");
|
|
161
|
+
const ref = await optionalRef(root, [
|
|
162
|
+
"rev-parse",
|
|
163
|
+
"--verify",
|
|
164
|
+
"--quiet",
|
|
165
|
+
"--end-of-options",
|
|
166
|
+
`${requestedBase}^{commit}`,
|
|
167
|
+
]);
|
|
168
|
+
if (ref === undefined)
|
|
169
|
+
throw new VcsError("Base must identify an existing commit");
|
|
170
|
+
revision = (await git(root, ["merge-base", ref, "HEAD"])).trim();
|
|
171
|
+
}
|
|
172
|
+
// Without HEAD, git's empty tree is the baseline for staged and untracked files.
|
|
173
|
+
revision ??= (await git(root, ["hash-object", "-t", "tree", devNull])).trim();
|
|
174
|
+
const files = await diffs(root, revision, operation === "diff" && mode === "committed", context, operation === "diff");
|
|
175
|
+
if (operation !== "status")
|
|
176
|
+
return files;
|
|
177
|
+
const statuses = new Map(files.map(({ patch: _patch, ...status }) => [status.file, status]));
|
|
178
|
+
// Index and worktree changes can cancel in the HEAD diff while still dirty.
|
|
179
|
+
const porcelain = await git(root, ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--no-renames"]);
|
|
180
|
+
for (const entry of porcelain.split("\0").filter(Boolean)) {
|
|
181
|
+
const file = entry.slice(3);
|
|
182
|
+
if (statuses.has(file))
|
|
183
|
+
continue;
|
|
184
|
+
const code = entry.slice(0, 2);
|
|
185
|
+
statuses.set(file, {
|
|
186
|
+
file,
|
|
187
|
+
additions: 0,
|
|
188
|
+
deletions: 0,
|
|
189
|
+
status: code.includes("D") ? "deleted" : code.includes("A") || code === "??" ? "added" : "modified",
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
return [...statuses.values()].sort((left, right) => left.file.localeCompare(right.file));
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
if (error instanceof VcsError)
|
|
196
|
+
throw error;
|
|
197
|
+
throw new VcsError(`Git VCS operation failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
198
|
+
}
|
|
199
|
+
}
|