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 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
@@ -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 = [...(s.messages ?? [])].reverse().find((message) => message.info.role === "assistant");
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 = [...(s.messages ?? [])].reverse().find((m) => m.info.role === "user");
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
- if (!body.noReply)
43
- nativePromptInput(text, parts);
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
- json(res, 400, errorBody("UnsupportedError", "Location eviction is not available with OmO"));
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([...credentials(directory).values()]));
181
+ json(res, 200, withLocation([]));
188
182
  return;
189
183
  }
190
- if (path === "/api/credential" && method === "POST") {
191
- const body = (await services.body(req));
192
- if (typeof body.id !== "string" ||
193
- body.id === "" ||
194
- typeof body.integrationID !== "string" ||
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
- const credentialMatch = path.match(/^\/api\/credential\/([^/]+)$/);
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
- const projectId = decodeURIComponent(projectMatch[1]);
267
- const body = (await services.body(req));
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 (path === "/api/vcs/base" && method === "GET") {
313
- json(res, 200, withLocation(null));
314
- return;
315
- }
316
- if (path === "/api/vcs/status" && method === "GET") {
317
- json(res, 200, withLocation([]));
318
- return;
319
- }
320
- if (path === "/api/vcs/branch" && method === "GET") {
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 firstText = explicit.find((part) => part.type === "text");
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 = typeof body.text === "string" ? body.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, {
@@ -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 = [...visible].reverse().find((message) => message.info.role === "user")?.info.id ?? "";
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 [...messages].reverse().find((message) => message.info.role === "assistant") ?? messages[messages.length - 1];
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;
@@ -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 = [...entries].reverse().find((entry) => entry.type !== "session" && typeof entry.id === "string");
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 = [...chain].reverse().find((entry) => entry.message?.role === "assistant");
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");
@@ -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 text = (entry.message.content ?? [])
75
- .filter((b) => b?.type === "text" && typeof b.text === "string")
76
- .map((b) => b.text)
77
- .join("\n")
78
- .trim();
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
  }
@@ -33,7 +33,7 @@ export interface TreeEntry {
33
33
  message?: {
34
34
  role: string;
35
35
  clientMessageId?: string;
36
- content?: Array<{
36
+ content?: string | Array<{
37
37
  type: string;
38
38
  text?: string;
39
39
  }>;
@@ -86,10 +86,14 @@ function pruneStaleShellTurns(s, positions) {
86
86
  });
87
87
  }
88
88
  function bindOrdinaryEntry(s, entry, messages, cursor) {
89
- const text = entry.message?.content
90
- ?.filter((part) => part.type === "text")
91
- .map((part) => part.text ?? "")
92
- .join("");
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 ?? [...(s.messages ?? [])].reverse().find((m) => m.info.role === "assistant")?.info.id, async (response) => {
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
@@ -0,0 +1,5 @@
1
+ export declare class VcsError extends Error {
2
+ name: string;
3
+ }
4
+ /** Read-only VCS operations; the router supplies the same directory as vcs.get. */
5
+ export declare function vcsRead(directory: string, operation: string, url: URL): Promise<unknown>;
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "antechamber",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "OpenCode-compatible HTTP/SSE server that lets OpenChamber drive the pi coding agent (and OmO) over RPC",
5
5
  "type": "module",
6
6
  "license": "MIT",