antechamber 0.1.0 → 0.2.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +17 -14
  3. package/bin/opencode-antechamber +10 -1
  4. package/bin/opencode-pi +11 -1
  5. package/dist/approvals.d.ts +4 -1
  6. package/dist/approvals.js +51 -33
  7. package/dist/catalog.d.ts +1 -1
  8. package/dist/catalog.js +1 -1
  9. package/dist/commands/context.d.ts +0 -1
  10. package/dist/commands/context.js +1 -1
  11. package/dist/commands/doctor.d.ts +3 -2
  12. package/dist/commands/serve.d.ts +2 -1
  13. package/dist/commands/setup.d.ts +2 -3
  14. package/dist/commands/setup.js +16 -4
  15. package/dist/http.d.ts +8 -1
  16. package/dist/http.js +42 -19
  17. package/dist/pi-event-translation.d.ts +21 -3
  18. package/dist/pi-event-translation.js +317 -65
  19. package/dist/pi-rpc.d.ts +26 -2
  20. package/dist/pi-rpc.js +102 -29
  21. package/dist/prompt-parts.d.ts +40 -0
  22. package/dist/prompt-parts.js +157 -0
  23. package/dist/route-services.d.ts +45 -2
  24. package/dist/route-services.js +70 -3
  25. package/dist/routes-v1.js +110 -65
  26. package/dist/routes-v2.js +376 -73
  27. package/dist/server.d.ts +9 -1
  28. package/dist/server.js +109 -9
  29. package/dist/session-commands.d.ts +20 -15
  30. package/dist/session-commands.js +61 -11
  31. package/dist/session-diff.d.ts +1 -1
  32. package/dist/session-diff.js +10 -2
  33. package/dist/session-history.js +113 -42
  34. package/dist/session-hosts.js +2 -2
  35. package/dist/session-store.d.ts +8 -4
  36. package/dist/session-store.js +183 -33
  37. package/dist/session-tree.d.ts +9 -2
  38. package/dist/session-tree.js +51 -63
  39. package/dist/sessions.d.ts +34 -6
  40. package/dist/sessions.js +340 -146
  41. package/dist/sse.d.ts +16 -6
  42. package/dist/sse.js +77 -21
  43. package/dist/subagents.d.ts +5 -0
  44. package/dist/subagents.js +86 -69
  45. package/dist/tool-map.d.ts +3 -2
  46. package/dist/types.d.ts +164 -1
  47. package/dist/v2.d.ts +18 -4
  48. package/dist/v2.js +141 -50
  49. package/dist/version.d.ts +1 -1
  50. package/dist/version.js +1 -1
  51. package/extensions/omo-dag/README.md +2 -0
  52. package/extensions/omo-dag/docs/DESIGN.md +10 -1
  53. package/extensions/omo-dag/panel/index.html +1 -1
  54. package/extensions/omo-dag/panel/main.js +60 -60
  55. package/extensions/omo-dag/service/main.js +72 -8
  56. package/package.json +1 -1
package/dist/pi-rpc.js CHANGED
@@ -11,11 +11,52 @@ import { spawn } from "node:child_process";
11
11
  import { StringDecoder } from "node:string_decoder";
12
12
  import { EventEmitter } from "node:events";
13
13
  import { randomUUID } from "node:crypto";
14
+ import { readdirSync, readFileSync } from "node:fs";
14
15
  import { systemClock } from "./lifecycle.js";
15
16
  import { agentBinary, envSetting } from "./config.js";
16
17
  export const RPC_COMMAND_TIMEOUT_MS = 60_000;
17
- export const MAX_JSONL_FRAME_BYTES = 64 * 1024 * 1024;
18
+ /**
19
+ * `compact` answers only when the summary is written. OmO bounds one compaction by
20
+ * its SUMMARIZATION_MAX_DURATION_CAP_MS (30 min); the generic 60 s budget gave up
21
+ * long before a large session finished, leaving the run orphaned.
22
+ */
23
+ export const COMPACT_RPC_TIMEOUT_MS = 31 * 60_000;
24
+ const MAX_JSONL_FRAME_BYTES = 64 * 1024 * 1024;
25
+ export class UpstreamProtocolError extends Error {
26
+ constructor(message) {
27
+ super(message);
28
+ this.name = "UpstreamProtocolError";
29
+ }
30
+ }
18
31
  const PROCESS_KILL_GRACE_MS = 5_000;
32
+ // Linux /proc. Any read failure (gone, no permission, no /proc) means the identity cannot be
33
+ // verified, and an unverifiable process is never signalled: fail closed.
34
+ const procTable = {
35
+ identity(pid) {
36
+ let stat;
37
+ try {
38
+ stat = readFileSync(`/proc/${pid}/stat`, "utf8");
39
+ }
40
+ catch {
41
+ return undefined;
42
+ }
43
+ const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
44
+ return { pid, group: Number(fields[2]), start: fields[19] ?? "" };
45
+ },
46
+ groupMembers(group) {
47
+ let names;
48
+ try {
49
+ names = readdirSync("/proc");
50
+ }
51
+ catch {
52
+ return [];
53
+ }
54
+ return names
55
+ .filter((name) => /^\d+$/.test(name))
56
+ .flatMap((name) => procTable.identity(Number(name)) ?? [])
57
+ .filter((member) => member.group === group);
58
+ },
59
+ };
19
60
  export class PiRpcClient extends EventEmitter {
20
61
  options;
21
62
  proc;
@@ -69,6 +110,9 @@ export class PiRpcClient extends EventEmitter {
69
110
  this.proc.stderr.on("data", (chunk) => {
70
111
  this.stderrTail = (this.stderrTail + chunk.toString()).slice(-8192);
71
112
  });
113
+ this.proc.stdin?.on("error", (error) => {
114
+ this.closeForTransportFailure(error);
115
+ });
72
116
  this.proc.stdout.on("data", (chunk) => {
73
117
  const text = typeof chunk === "string" ? chunk : this.decoder.write(chunk);
74
118
  this.buffer += text;
@@ -80,11 +124,10 @@ export class PiRpcClient extends EventEmitter {
80
124
  this.buffer += text;
81
125
  this.bufferBytes += Buffer.byteLength(text);
82
126
  this.drain();
83
- this.failAll(new Error("pi process closed stdout"));
127
+ this.closeForTransportFailure(new Error("pi process closed stdout"));
84
128
  });
85
129
  this.proc.on("error", (err) => {
86
- this.closed = true;
87
- this.failAll(err);
130
+ this.closeForTransportFailure(err);
88
131
  });
89
132
  this.proc.on("exit", (code, signal) => {
90
133
  this.closed = true;
@@ -126,9 +169,23 @@ export class PiRpcClient extends EventEmitter {
126
169
  this.proc.stdout?.destroy();
127
170
  if (this.proc.pid === undefined)
128
171
  this.proc.kill("SIGKILL");
129
- else
172
+ else if (!this.reaped())
130
173
  process.kill(-this.proc.pid, "SIGKILL");
131
174
  }
175
+ /** Node has reaped the leader, so its PID (and group ID) may already belong to another process. */
176
+ reaped() {
177
+ return this.proc.exitCode !== null || this.proc.signalCode !== null;
178
+ }
179
+ closeForTransportFailure(error) {
180
+ if (this.closed)
181
+ return;
182
+ this.closed = true;
183
+ this.failAll(error);
184
+ void this.dispose().catch((disposeError) => {
185
+ const message = disposeError instanceof Error ? disposeError.message : String(disposeError);
186
+ console.error(`[pi-rpc] failed to dispose after transport failure: ${message}`);
187
+ });
188
+ }
132
189
  handleLine(line) {
133
190
  let value;
134
191
  try {
@@ -152,6 +209,12 @@ export class PiRpcClient extends EventEmitter {
152
209
  pending?.reject(error);
153
210
  return;
154
211
  }
212
+ if (pending !== undefined && pending.command !== msg.command) {
213
+ const error = new Error(`pi returned mismatched RPC response command "${msg.command}" for pending command "${pending.command}"`);
214
+ console.error(`[pi-rpc] ${error.message}`);
215
+ pending.reject(error);
216
+ return;
217
+ }
155
218
  const response = msg.success
156
219
  ? { type: "response", id: msg.id, command: msg.command, success: true, data: msg.data }
157
220
  : {
@@ -200,30 +263,31 @@ export class PiRpcClient extends EventEmitter {
200
263
  if (signal?.aborted)
201
264
  return Promise.reject(signal.reason);
202
265
  const id = randomUUID();
266
+ const expectedCommand = String(cmd.type);
203
267
  return new Promise((resolve, reject) => {
204
268
  const cleanup = () => {
205
269
  clearTimeout(timer);
206
270
  signal?.removeEventListener("abort", onAbort);
207
271
  this.pending.delete(id);
208
272
  };
209
- const onAbort = () => {
273
+ const rejectCommand = (reason) => {
210
274
  cleanup();
211
- reject(signal?.reason);
275
+ reject(reason);
276
+ };
277
+ const onAbort = () => {
278
+ rejectCommand(signal?.reason);
212
279
  };
213
280
  const timer = setTimeout(() => {
214
- cleanup();
215
- reject(new Error(`pi command "${cmd.type}" timed out after ${timeoutMs}ms`));
281
+ this.closeForTransportFailure(new Error(`pi command "${expectedCommand}" timed out after ${timeoutMs}ms`));
216
282
  }, timeoutMs);
217
283
  this.pending.set(id, {
284
+ command: expectedCommand,
218
285
  resolve: (r) => {
219
286
  cleanup();
220
287
  // The envelope is checked at the transport boundary; callers own data schemas.
221
288
  resolve(r.success ? { ...r, data: r.data } : r);
222
289
  },
223
- reject: (e) => {
224
- cleanup();
225
- reject(e);
226
- },
290
+ reject: rejectCommand,
227
291
  });
228
292
  signal?.addEventListener("abort", onAbort, { once: true });
229
293
  this.write({ ...cmd, id });
@@ -240,45 +304,54 @@ export class PiRpcClient extends EventEmitter {
240
304
  this.closed = true;
241
305
  this.failAll(new Error("pi process disposed"));
242
306
  const pid = this.proc.pid;
243
- if (pid === undefined)
307
+ if (pid === undefined || this.reaped())
244
308
  return;
245
309
  const clock = this.options.clock ?? systemClock;
246
- const signalGroup = (signal) => {
310
+ const table = this.options.processTable ?? procTable;
311
+ const signal = (target, sig) => {
247
312
  try {
248
- process.kill(-pid, signal);
249
- return true;
313
+ process.kill(target, sig);
250
314
  }
251
315
  catch (error) {
252
316
  if (error instanceof Error && "code" in error && error.code === "ESRCH")
253
- return false;
317
+ return;
254
318
  throw error;
255
319
  }
256
320
  };
321
+ // Until Node reaps the leader its PID, and therefore the group ID, cannot be reused, so group
322
+ // signals are safe only before "exit". Record the members now; after the leader exits, a
323
+ // grandchild is killed only when its PID still has the same group and start time.
324
+ const owned = table.groupMembers(pid).filter((member) => member.pid !== pid);
325
+ const survivors = () => owned
326
+ .filter((member) => {
327
+ const now = table.identity(member.pid);
328
+ return now !== undefined && now.group === member.group && now.start === member.start;
329
+ })
330
+ .map((member) => member.pid);
257
331
  this.disposing = new Promise((resolve) => {
258
- let exited = this.proc.exitCode !== null || this.proc.signalCode !== null;
259
332
  const finish = () => {
260
333
  clock.clearTimeout(timer);
261
334
  this.proc.off("exit", onExit);
262
335
  resolve();
263
336
  };
264
337
  const onExit = () => {
265
- exited = true;
266
- // A grandchild may outlive pi. Keep the escalation armed for its group.
267
- if (!signalGroup(0))
338
+ if (survivors().length === 0)
268
339
  finish();
269
340
  };
270
341
  this.proc.once("exit", onExit);
271
342
  const timer = clock.setTimeout(() => {
272
- signalGroup("SIGKILL");
273
- if (exited)
274
- finish();
275
- else
343
+ if (!this.reaped()) {
344
+ signal(-pid, "SIGKILL");
345
+ this.proc.off("exit", onExit);
276
346
  this.proc.once("exit", finish);
347
+ return;
348
+ }
349
+ for (const member of survivors())
350
+ signal(member, "SIGKILL");
351
+ finish();
277
352
  }, PROCESS_KILL_GRACE_MS);
278
- const alive = signalGroup("SIGTERM");
353
+ signal(-pid, "SIGTERM");
279
354
  this.proc.stdin?.end();
280
- if (!alive && exited)
281
- finish();
282
355
  });
283
356
  return this.disposing;
284
357
  }
@@ -0,0 +1,40 @@
1
+ import type { OCPromptPart } from "./types.js";
2
+ export declare function isPromptPart(value: unknown): value is OCPromptPart;
3
+ export declare function parsePromptParts(value: unknown): OCPromptPart[];
4
+ /** OpenChamber 2.x attachments (`files: [{uri, name?}]`, `agents: [{name, mention?}]`) as prompt parts. */
5
+ export declare function v2PromptParts(text: string, files: unknown, agents: unknown): OCPromptPart[];
6
+ /** The v2 wire form of a prompt's attachments, as OpenChamber 2.x projects them back into parts. */
7
+ export declare function v2PromptAttachments(parts: readonly OCPromptPart[]): {
8
+ files?: {
9
+ mime: string;
10
+ name?: string;
11
+ source: {
12
+ type: string;
13
+ uri: string;
14
+ };
15
+ }[];
16
+ agents?: {
17
+ name: string;
18
+ mention?: {
19
+ start: number;
20
+ end: number;
21
+ text: string;
22
+ };
23
+ }[];
24
+ };
25
+ /** Native custom-message type for no-reply context (v2 synthetic, v1 noReply) admitted without a turn. */
26
+ export declare const CONTEXT_MESSAGE_TYPE = "antechamber.context";
27
+ /** The OpenChamber message id of a native context entry, if the entry is one. */
28
+ export declare function contextMessageID(entry: {
29
+ type?: string;
30
+ customType?: string;
31
+ details?: unknown;
32
+ }): string | undefined;
33
+ export declare function nativePromptInput(text: string, parts: readonly OCPromptPart[]): {
34
+ message: string;
35
+ images?: {
36
+ type: "image";
37
+ data: string;
38
+ mimeType: string;
39
+ }[];
40
+ };
@@ -0,0 +1,157 @@
1
+ /** Lossless user parts on the wire, adapted to the native text/images RPC. */
2
+ import { RequestBodyError } from "./http.js";
3
+ export function isPromptPart(value) {
4
+ if (typeof value !== "object" || value === null || !("type" in value))
5
+ return false;
6
+ if ("id" in value && typeof value.id !== "string")
7
+ return false;
8
+ switch (value.type) {
9
+ case "text":
10
+ return ("text" in value &&
11
+ typeof value.text === "string" &&
12
+ (!("synthetic" in value) || typeof value.synthetic === "boolean"));
13
+ case "file":
14
+ return ("mime" in value &&
15
+ typeof value.mime === "string" &&
16
+ "url" in value &&
17
+ typeof value.url === "string" &&
18
+ (!("filename" in value) || typeof value.filename === "string"));
19
+ case "agent":
20
+ return ("name" in value &&
21
+ typeof value.name === "string" &&
22
+ (!("source" in value) ||
23
+ (typeof value.source === "object" &&
24
+ value.source !== null &&
25
+ "value" in value.source &&
26
+ typeof value.source.value === "string" &&
27
+ "start" in value.source &&
28
+ typeof value.source.start === "number" &&
29
+ "end" in value.source &&
30
+ typeof value.source.end === "number")));
31
+ default:
32
+ return false;
33
+ }
34
+ }
35
+ export function parsePromptParts(value) {
36
+ if (value === undefined)
37
+ return [];
38
+ if (!Array.isArray(value) || !value.every(isPromptPart))
39
+ throw new RequestBodyError(400, "parts must contain supported text, file, or agent inputs");
40
+ return value;
41
+ }
42
+ function plainRecord(value) {
43
+ return typeof value === "object" && value !== null && !Array.isArray(value);
44
+ }
45
+ /** OpenChamber 2.x attachments (`files: [{uri, name?}]`, `agents: [{name, mention?}]`) as prompt parts. */
46
+ export function v2PromptParts(text, files, agents) {
47
+ const parts = text === "" ? [] : [{ type: "text", text }];
48
+ if (files !== undefined) {
49
+ if (!Array.isArray(files))
50
+ throw new RequestBodyError(400, "files must be an array");
51
+ for (const file of files) {
52
+ if (!plainRecord(file) ||
53
+ typeof file.uri !== "string" ||
54
+ (file.name !== undefined && typeof file.name !== "string"))
55
+ throw new RequestBodyError(400, "files must contain {uri, name?} attachments");
56
+ // v2 carries no MIME type; an inline data URI still names its own.
57
+ const mime = /^data:([^;,]+)/.exec(file.uri)?.[1] ?? "application/octet-stream";
58
+ parts.push({
59
+ type: "file",
60
+ mime,
61
+ url: file.uri,
62
+ ...(typeof file.name === "string" ? { filename: file.name } : {}),
63
+ });
64
+ }
65
+ }
66
+ if (agents !== undefined) {
67
+ if (!Array.isArray(agents))
68
+ throw new RequestBodyError(400, "agents must be an array");
69
+ for (const agent of agents) {
70
+ const mention = plainRecord(agent) ? agent.mention : undefined;
71
+ if (!plainRecord(agent) ||
72
+ typeof agent.name !== "string" ||
73
+ (mention !== undefined &&
74
+ (!plainRecord(mention) ||
75
+ typeof mention.start !== "number" ||
76
+ typeof mention.end !== "number" ||
77
+ typeof mention.text !== "string")))
78
+ throw new RequestBodyError(400, "agents must contain {name, mention?} references");
79
+ parts.push({
80
+ type: "agent",
81
+ name: agent.name,
82
+ ...(plainRecord(mention) &&
83
+ typeof mention.text === "string" &&
84
+ typeof mention.start === "number" &&
85
+ typeof mention.end === "number"
86
+ ? { source: { value: mention.text, start: mention.start, end: mention.end } }
87
+ : {}),
88
+ });
89
+ }
90
+ }
91
+ return parts;
92
+ }
93
+ /** The v2 wire form of a prompt's attachments, as OpenChamber 2.x projects them back into parts. */
94
+ export function v2PromptAttachments(parts) {
95
+ const files = parts.flatMap((part) => part.type === "file"
96
+ ? [
97
+ {
98
+ mime: part.mime,
99
+ ...(part.filename !== undefined ? { name: part.filename } : {}),
100
+ source: { type: "uri", uri: part.url },
101
+ },
102
+ ]
103
+ : []);
104
+ const agents = parts.flatMap((part) => part.type === "agent"
105
+ ? [
106
+ {
107
+ name: part.name,
108
+ ...(part.source !== undefined
109
+ ? { mention: { start: part.source.start, end: part.source.end, text: part.source.value } }
110
+ : {}),
111
+ },
112
+ ]
113
+ : []);
114
+ return { ...(files.length > 0 ? { files } : {}), ...(agents.length > 0 ? { agents } : {}) };
115
+ }
116
+ /** Native custom-message type for no-reply context (v2 synthetic, v1 noReply) admitted without a turn. */
117
+ export const CONTEXT_MESSAGE_TYPE = "antechamber.context";
118
+ /** The OpenChamber message id of a native context entry, if the entry is one. */
119
+ export function contextMessageID(entry) {
120
+ if (entry.type !== "custom_message" || entry.customType !== CONTEXT_MESSAGE_TYPE || !plainRecord(entry.details))
121
+ return undefined;
122
+ return typeof entry.details.messageID === "string" ? entry.details.messageID : undefined;
123
+ }
124
+ export function nativePromptInput(text, parts) {
125
+ const chunks = [text];
126
+ const images = [];
127
+ for (const part of parts) {
128
+ switch (part.type) {
129
+ case "text":
130
+ break;
131
+ case "agent":
132
+ chunks.push(`@${part.name}`);
133
+ break;
134
+ case "file": {
135
+ const data = /^data:([^;,]*)(;base64)?,([\s\S]*)$/.exec(part.url);
136
+ if (data !== null) {
137
+ const content = data[2]
138
+ ? Buffer.from(data[3] ?? "", "base64")
139
+ : Buffer.from(decodeURIComponent(data[3] ?? ""));
140
+ if (part.mime.startsWith("image/"))
141
+ images.push({ type: "image", data: content.toString("base64"), mimeType: part.mime });
142
+ else if (part.mime.startsWith("text/"))
143
+ chunks.push(`${part.filename ?? "attachment"} (${part.mime}):\n${content.toString("utf8")}`);
144
+ else
145
+ throw new RequestBodyError(400, `Unsupported inline attachment MIME type: ${part.mime}`);
146
+ }
147
+ else {
148
+ // Native RPC has no generic FilePart; retain the reference in model
149
+ // context so the agent can read the selected workspace file.
150
+ chunks.push(JSON.stringify({ filename: part.filename, mime: part.mime, url: part.url }));
151
+ }
152
+ break;
153
+ }
154
+ }
155
+ }
156
+ return { message: chunks.join("\n\n"), ...(images.length > 0 ? { images } : {}) };
157
+ }
@@ -3,7 +3,7 @@ import type { IncomingMessage } from "node:http";
3
3
  import type { SessionManager, ManagedSession } from "./sessions.js";
4
4
  import type { Catalog } from "./catalog.js";
5
5
  import type { ModelSelection } from "./thinking-levels.js";
6
- import type { OCEvent } from "./types.js";
6
+ import type { OCEvent, OCMessageWithParts, OCPromptPart } from "./types.js";
7
7
  import type { TodoUpdated } from "./todo-bridge.js";
8
8
  export interface RouteServices {
9
9
  readonly sessions: SessionManager;
@@ -15,6 +15,8 @@ export interface RouteServices {
15
15
  }
16
16
  export declare const OPENCODE_COMPAT_VERSION: string | null;
17
17
  export declare function numberParam(value: string | null): number | undefined;
18
+ export declare function sessionDirectory(value: unknown): string;
19
+ export declare function sessionMetadata(value: unknown): Record<string, unknown> | undefined;
18
20
  /** v2 prompt bodies carry `model:{providerID, id|modelID}`; adapt to OCModelRef. */
19
21
  export declare function modelRefFromBody(model: {
20
22
  providerID?: string;
@@ -23,4 +25,45 @@ export declare function modelRefFromBody(model: {
23
25
  variant?: string;
24
26
  } | undefined, variant?: string): ModelSelection | undefined;
25
27
  /** Both async APIs acknowledge admission; failures must reach SSE and history. */
26
- export declare function promptAsync(services: RouteServices, session: ManagedSession, text: string, model?: ModelSelection, noReply?: boolean, messageID?: string, delivery?: "steer" | "queue"): void;
28
+ export declare function promptAsync(services: RouteServices, session: ManagedSession, text: string, model?: ModelSelection, noReply?: boolean, messageID?: string, delivery?: "steer" | "queue", parts?: readonly OCPromptPart[], metadata?: Record<string, unknown>): void;
29
+ export type ShellRouteResult = {
30
+ kind: "input";
31
+ } | {
32
+ kind: "busy";
33
+ } | {
34
+ kind: "error";
35
+ message: string;
36
+ } | {
37
+ kind: "success";
38
+ message: OCMessageWithParts;
39
+ };
40
+ /** Run a shell command consistently while leaving each API version to shape its response envelope. */
41
+ export declare function runSessionShell(services: RouteServices, session: ManagedSession, body: unknown): Promise<ShellRouteResult>;
42
+ export type SlashRouteResult = {
43
+ kind: "input";
44
+ } | {
45
+ kind: "success";
46
+ message: OCMessageWithParts | undefined;
47
+ };
48
+ /** Parse and dispatch slash commands once for both protocol versions. */
49
+ export declare function runSessionSlash(services: RouteServices, sessionID: string, body: unknown): Promise<SlashRouteResult>;
50
+ export declare function agentDescriptors(services: RouteServices): Promise<{
51
+ model?: {
52
+ providerID: string;
53
+ modelID: string;
54
+ };
55
+ variant?: string;
56
+ name: string;
57
+ description: string;
58
+ mode: string;
59
+ hidden: boolean;
60
+ options: {};
61
+ native: boolean;
62
+ permission: {
63
+ permission: string;
64
+ pattern: string;
65
+ action: string;
66
+ }[];
67
+ prompt: string;
68
+ temperature: number;
69
+ }[]>;
@@ -1,5 +1,9 @@
1
1
  /** Shared services and prompt/model adaptation for both API versions. */
2
- import { announceSessionError } from "./session-commands.js";
2
+ import { statSync } from "node:fs";
3
+ import { RequestBodyError } from "./http.js";
4
+ import { getAgentName, getExposedAgentNames } from "./agent-name.js";
5
+ import { agentDefaults } from "./catalog.js";
6
+ import { announceSessionError, parseShellCommand, parseSlashCommand, runShellCommand, runSlashCommand, } from "./session-commands.js";
3
7
  import { envSetting } from "./config.js";
4
8
  // This connector is not opencode — it only implements an OpenCode API subset.
5
9
  // OpenChamber reads /global/health's `version` as the installed opencode version
@@ -15,6 +19,27 @@ export function numberParam(value) {
15
19
  const n = Number(value);
16
20
  return Number.isFinite(n) && n >= 0 ? n : undefined;
17
21
  }
22
+ export function sessionDirectory(value) {
23
+ if (typeof value !== "string" || value.trim() === "" || value.includes("\0"))
24
+ throw new RequestBodyError(400, "directory must be a nonempty path");
25
+ try {
26
+ if (!statSync(value).isDirectory())
27
+ throw new RequestBodyError(400, "directory does not exist or is not a directory");
28
+ }
29
+ catch (error) {
30
+ if (error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR"))
31
+ throw new RequestBodyError(400, "directory does not exist or is not a directory");
32
+ throw error;
33
+ }
34
+ return value;
35
+ }
36
+ export function sessionMetadata(value) {
37
+ if (value === undefined)
38
+ return undefined;
39
+ if (typeof value !== "object" || value === null || Array.isArray(value))
40
+ throw new RequestBodyError(400, "metadata must be an object");
41
+ return Object.fromEntries(Object.entries(value));
42
+ }
18
43
  /** v2 prompt bodies carry `model:{providerID, id|modelID}`; adapt to OCModelRef. */
19
44
  export function modelRefFromBody(model, variant) {
20
45
  if (!model || typeof model.providerID !== "string")
@@ -26,8 +51,50 @@ export function modelRefFromBody(model, variant) {
26
51
  return { providerID: model.providerID, modelID, ...(typeof level === "string" ? { variant: level } : {}) };
27
52
  }
28
53
  /** Both async APIs acknowledge admission; failures must reach SSE and history. */
29
- export function promptAsync(services, session, text, model, noReply = false, messageID, delivery = "steer") {
30
- void services.sessions.prompt(session.info.id, text, model, noReply, messageID, delivery).catch((error) => {
54
+ export function promptAsync(services, session, text, model, noReply = false, messageID, delivery = "steer", parts, metadata) {
55
+ void services.sessions
56
+ .prompt(session.info.id, text, model, noReply, messageID, delivery, parts, metadata)
57
+ .catch((error) => {
31
58
  announceSessionError({ sessions: services.sessions, session, emit: services.broadcast }, error instanceof Error ? error.name : "UnknownError", error instanceof Error ? error.message : String(error));
32
59
  });
33
60
  }
61
+ /** Run a shell command consistently while leaving each API version to shape its response envelope. */
62
+ export async function runSessionShell(services, session, body) {
63
+ const command = parseShellCommand(body);
64
+ if (command === undefined)
65
+ return { kind: "input" };
66
+ if (session.status.type !== "idle")
67
+ return { kind: "busy" };
68
+ const result = await runShellCommand({
69
+ sessions: services.sessions,
70
+ session,
71
+ command,
72
+ emit: services.broadcast,
73
+ });
74
+ return result.kind === "error" ? result : { kind: "success", message: result.message };
75
+ }
76
+ /** Parse and dispatch slash commands once for both protocol versions. */
77
+ export async function runSessionSlash(services, sessionID, body) {
78
+ const input = parseSlashCommand(body);
79
+ if (input === undefined)
80
+ return { kind: "input" };
81
+ return { kind: "success", message: await runSlashCommand(services.sessions, sessionID, input) };
82
+ }
83
+ export async function agentDescriptors(services) {
84
+ const piName = getAgentName();
85
+ const defaults = agentDefaults(await services.catalog());
86
+ return getExposedAgentNames().map((name) => ({
87
+ name,
88
+ description: name === piName
89
+ ? `${piName} (pi coding agent via antechamber)`
90
+ : `${name} — ${piName} (pi coding agent via antechamber)`,
91
+ mode: "primary",
92
+ hidden: name === "build" && piName !== "build",
93
+ options: {},
94
+ native: true,
95
+ permission: [{ permission: "*", pattern: "*", action: "allow" }],
96
+ prompt: "",
97
+ temperature: 0,
98
+ ...defaults,
99
+ }));
100
+ }