antechamber 0.1.1 → 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 +28 -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 +9 -5
  18. package/dist/pi-event-translation.js +233 -79
  19. package/dist/pi-rpc.d.ts +20 -2
  20. package/dist/pi-rpc.js +96 -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 +12 -15
  30. package/dist/session-commands.js +52 -11
  31. package/dist/session-diff.d.ts +1 -1
  32. package/dist/session-diff.js +10 -2
  33. package/dist/session-history.js +108 -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 -1
  38. package/dist/session-tree.js +51 -26
  39. package/dist/sessions.d.ts +32 -5
  40. package/dist/sessions.js +330 -119
  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 +84 -71
  45. package/dist/tool-map.d.ts +3 -2
  46. package/dist/types.d.ts +152 -1
  47. package/dist/v2.d.ts +18 -4
  48. package/dist/v2.js +128 -44
  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,6 +11,7 @@ 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;
@@ -20,8 +21,42 @@ export const RPC_COMMAND_TIMEOUT_MS = 60_000;
20
21
  * long before a large session finished, leaving the run orphaned.
21
22
  */
22
23
  export const COMPACT_RPC_TIMEOUT_MS = 31 * 60_000;
23
- export const MAX_JSONL_FRAME_BYTES = 64 * 1024 * 1024;
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
+ }
24
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
+ };
25
60
  export class PiRpcClient extends EventEmitter {
26
61
  options;
27
62
  proc;
@@ -75,6 +110,9 @@ export class PiRpcClient extends EventEmitter {
75
110
  this.proc.stderr.on("data", (chunk) => {
76
111
  this.stderrTail = (this.stderrTail + chunk.toString()).slice(-8192);
77
112
  });
113
+ this.proc.stdin?.on("error", (error) => {
114
+ this.closeForTransportFailure(error);
115
+ });
78
116
  this.proc.stdout.on("data", (chunk) => {
79
117
  const text = typeof chunk === "string" ? chunk : this.decoder.write(chunk);
80
118
  this.buffer += text;
@@ -86,11 +124,10 @@ export class PiRpcClient extends EventEmitter {
86
124
  this.buffer += text;
87
125
  this.bufferBytes += Buffer.byteLength(text);
88
126
  this.drain();
89
- this.failAll(new Error("pi process closed stdout"));
127
+ this.closeForTransportFailure(new Error("pi process closed stdout"));
90
128
  });
91
129
  this.proc.on("error", (err) => {
92
- this.closed = true;
93
- this.failAll(err);
130
+ this.closeForTransportFailure(err);
94
131
  });
95
132
  this.proc.on("exit", (code, signal) => {
96
133
  this.closed = true;
@@ -132,9 +169,23 @@ export class PiRpcClient extends EventEmitter {
132
169
  this.proc.stdout?.destroy();
133
170
  if (this.proc.pid === undefined)
134
171
  this.proc.kill("SIGKILL");
135
- else
172
+ else if (!this.reaped())
136
173
  process.kill(-this.proc.pid, "SIGKILL");
137
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
+ }
138
189
  handleLine(line) {
139
190
  let value;
140
191
  try {
@@ -158,6 +209,12 @@ export class PiRpcClient extends EventEmitter {
158
209
  pending?.reject(error);
159
210
  return;
160
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
+ }
161
218
  const response = msg.success
162
219
  ? { type: "response", id: msg.id, command: msg.command, success: true, data: msg.data }
163
220
  : {
@@ -206,30 +263,31 @@ export class PiRpcClient extends EventEmitter {
206
263
  if (signal?.aborted)
207
264
  return Promise.reject(signal.reason);
208
265
  const id = randomUUID();
266
+ const expectedCommand = String(cmd.type);
209
267
  return new Promise((resolve, reject) => {
210
268
  const cleanup = () => {
211
269
  clearTimeout(timer);
212
270
  signal?.removeEventListener("abort", onAbort);
213
271
  this.pending.delete(id);
214
272
  };
215
- const onAbort = () => {
273
+ const rejectCommand = (reason) => {
216
274
  cleanup();
217
- reject(signal?.reason);
275
+ reject(reason);
276
+ };
277
+ const onAbort = () => {
278
+ rejectCommand(signal?.reason);
218
279
  };
219
280
  const timer = setTimeout(() => {
220
- cleanup();
221
- 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`));
222
282
  }, timeoutMs);
223
283
  this.pending.set(id, {
284
+ command: expectedCommand,
224
285
  resolve: (r) => {
225
286
  cleanup();
226
287
  // The envelope is checked at the transport boundary; callers own data schemas.
227
288
  resolve(r.success ? { ...r, data: r.data } : r);
228
289
  },
229
- reject: (e) => {
230
- cleanup();
231
- reject(e);
232
- },
290
+ reject: rejectCommand,
233
291
  });
234
292
  signal?.addEventListener("abort", onAbort, { once: true });
235
293
  this.write({ ...cmd, id });
@@ -246,45 +304,54 @@ export class PiRpcClient extends EventEmitter {
246
304
  this.closed = true;
247
305
  this.failAll(new Error("pi process disposed"));
248
306
  const pid = this.proc.pid;
249
- if (pid === undefined)
307
+ if (pid === undefined || this.reaped())
250
308
  return;
251
309
  const clock = this.options.clock ?? systemClock;
252
- const signalGroup = (signal) => {
310
+ const table = this.options.processTable ?? procTable;
311
+ const signal = (target, sig) => {
253
312
  try {
254
- process.kill(-pid, signal);
255
- return true;
313
+ process.kill(target, sig);
256
314
  }
257
315
  catch (error) {
258
316
  if (error instanceof Error && "code" in error && error.code === "ESRCH")
259
- return false;
317
+ return;
260
318
  throw error;
261
319
  }
262
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);
263
331
  this.disposing = new Promise((resolve) => {
264
- let exited = this.proc.exitCode !== null || this.proc.signalCode !== null;
265
332
  const finish = () => {
266
333
  clock.clearTimeout(timer);
267
334
  this.proc.off("exit", onExit);
268
335
  resolve();
269
336
  };
270
337
  const onExit = () => {
271
- exited = true;
272
- // A grandchild may outlive pi. Keep the escalation armed for its group.
273
- if (!signalGroup(0))
338
+ if (survivors().length === 0)
274
339
  finish();
275
340
  };
276
341
  this.proc.once("exit", onExit);
277
342
  const timer = clock.setTimeout(() => {
278
- signalGroup("SIGKILL");
279
- if (exited)
280
- finish();
281
- else
343
+ if (!this.reaped()) {
344
+ signal(-pid, "SIGKILL");
345
+ this.proc.off("exit", onExit);
282
346
  this.proc.once("exit", finish);
347
+ return;
348
+ }
349
+ for (const member of survivors())
350
+ signal(member, "SIGKILL");
351
+ finish();
283
352
  }, PROCESS_KILL_GRACE_MS);
284
- const alive = signalGroup("SIGTERM");
353
+ signal(-pid, "SIGTERM");
285
354
  this.proc.stdin?.end();
286
- if (!alive && exited)
287
- finish();
288
355
  });
289
356
  return this.disposing;
290
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
+ }