antechamber 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +143 -0
- package/LICENSE +22 -0
- package/README.md +316 -2
- package/bin/opencode-antechamber +18 -0
- package/bin/opencode-pi +3 -0
- package/dist/agent-dir.d.ts +6 -0
- package/dist/agent-dir.js +21 -0
- package/dist/agent-name.d.ts +24 -0
- package/dist/agent-name.js +110 -0
- package/dist/approvals.d.ts +86 -0
- package/dist/approvals.js +520 -0
- package/dist/catalog.d.ts +88 -0
- package/dist/catalog.js +118 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +86 -0
- package/dist/commands/context.d.ts +26 -0
- package/dist/commands/context.js +89 -0
- package/dist/commands/doctor.d.ts +13 -0
- package/dist/commands/doctor.js +92 -0
- package/dist/commands/help.d.ts +1 -0
- package/dist/commands/help.js +15 -0
- package/dist/commands/serve.d.ts +8 -0
- package/dist/commands/serve.js +29 -0
- package/dist/commands/setup.d.ts +14 -0
- package/dist/commands/setup.js +137 -0
- package/dist/config.d.ts +18 -0
- package/dist/config.js +40 -0
- package/dist/http.d.ts +18 -0
- package/dist/http.js +168 -0
- package/dist/lifecycle.d.ts +43 -0
- package/dist/lifecycle.js +138 -0
- package/dist/misc-routes.d.ts +8 -0
- package/dist/misc-routes.js +119 -0
- package/dist/pi-event-translation.d.ts +65 -0
- package/dist/pi-event-translation.js +545 -0
- package/dist/pi-rpc.d.ts +68 -0
- package/dist/pi-rpc.js +285 -0
- package/dist/pidfd.d.ts +2 -0
- package/dist/pidfd.js +28 -0
- package/dist/question-forms.d.ts +30 -0
- package/dist/question-forms.js +46 -0
- package/dist/route-services.d.ts +26 -0
- package/dist/route-services.js +33 -0
- package/dist/routes-v1.d.ts +4 -0
- package/dist/routes-v1.js +346 -0
- package/dist/routes-v2.d.ts +4 -0
- package/dist/routes-v2.js +477 -0
- package/dist/server.d.ts +28 -0
- package/dist/server.js +113 -0
- package/dist/session-commands.d.ts +75 -0
- package/dist/session-commands.js +323 -0
- package/dist/session-diff.d.ts +14 -0
- package/dist/session-diff.js +152 -0
- package/dist/session-history.d.ts +35 -0
- package/dist/session-history.js +416 -0
- package/dist/session-hosts.d.ts +3 -0
- package/dist/session-hosts.js +188 -0
- package/dist/session-store.d.ts +103 -0
- package/dist/session-store.js +339 -0
- package/dist/session-tree.d.ts +59 -0
- package/dist/session-tree.js +324 -0
- package/dist/sessions.d.ts +174 -0
- package/dist/sessions.js +1141 -0
- package/dist/sse.d.ts +19 -0
- package/dist/sse.js +92 -0
- package/dist/subagents.d.ts +64 -0
- package/dist/subagents.js +621 -0
- package/dist/thinking-levels.d.ts +17 -0
- package/dist/thinking-levels.js +24 -0
- package/dist/todo-bridge.d.ts +26 -0
- package/dist/todo-bridge.js +100 -0
- package/dist/tool-map.d.ts +32 -0
- package/dist/tool-map.js +349 -0
- package/dist/types.d.ts +338 -0
- package/dist/types.js +6 -0
- package/dist/v2.d.ts +190 -0
- package/dist/v2.js +553 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +13 -0
- package/extensions/omo-dag/README.md +159 -0
- package/extensions/omo-dag/docs/DESIGN.md +105 -0
- package/extensions/omo-dag/icon.svg +1 -0
- package/extensions/omo-dag/package.json +33 -0
- package/extensions/omo-dag/panel/index.html +179 -0
- package/extensions/omo-dag/panel/main.js +176 -0
- package/extensions/omo-dag/service/main.js +378 -0
- package/package.json +77 -4
package/dist/config.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
/** Environment variable prefix for all antechamber settings. */
|
|
5
|
+
export const ENV_PREFIX = "ANTECHAMBER_";
|
|
6
|
+
/** Prefix used before the project was renamed; still honoured as a fallback. */
|
|
7
|
+
const LEGACY_ENV_PREFIX = "OCPI_";
|
|
8
|
+
/**
|
|
9
|
+
* Reads `ANTECHAMBER_<name>`, falling back to the deprecated `OCPI_<name>`.
|
|
10
|
+
* Blank values count as unset.
|
|
11
|
+
*/
|
|
12
|
+
export function envSetting(name, env = process.env) {
|
|
13
|
+
for (const key of [ENV_PREFIX + name, LEGACY_ENV_PREFIX + name]) {
|
|
14
|
+
const value = env[key];
|
|
15
|
+
if (value !== undefined && value.trim() !== "")
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Default registry/state directory. Prefers `~/.config/antechamber`; when only
|
|
22
|
+
* the pre-rename directory exists it keeps using that so existing session
|
|
23
|
+
* mappings survive the upgrade.
|
|
24
|
+
*/
|
|
25
|
+
export function defaultStateDir(home = homedir(), exists = existsSync) {
|
|
26
|
+
const config = join(home, ".config");
|
|
27
|
+
const current = join(config, "antechamber");
|
|
28
|
+
const legacy = join(config, "openchamber-pi-connector");
|
|
29
|
+
if (!exists(current) && exists(legacy))
|
|
30
|
+
return legacy;
|
|
31
|
+
return current;
|
|
32
|
+
}
|
|
33
|
+
/** Agent executable spawned per session: OmO by default, plain pi when configured. */
|
|
34
|
+
export function agentBinary(env = process.env) {
|
|
35
|
+
return envSetting("PI_BINARY", env) ?? "omo";
|
|
36
|
+
}
|
|
37
|
+
/** True when the configured backend is upstream pi rather than OmO. */
|
|
38
|
+
export function isPlainPi(env = process.env) {
|
|
39
|
+
return basename(agentBinary(env)).replace(/\.(exe|cmd)$/i, "") === "pi";
|
|
40
|
+
}
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Shared HTTP body, response, authentication, and CORS plumbing. */
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
3
|
+
export declare function serverPassword(): string | undefined;
|
|
4
|
+
export declare function isLoopbackHost(host: string): boolean;
|
|
5
|
+
export declare function errorBody(name: string, message: string): {
|
|
6
|
+
name: string;
|
|
7
|
+
data: {
|
|
8
|
+
message: string;
|
|
9
|
+
};
|
|
10
|
+
};
|
|
11
|
+
export declare function json(res: ServerResponse, status: number, body: unknown): void;
|
|
12
|
+
export declare class Http {
|
|
13
|
+
private readonly credentials;
|
|
14
|
+
private readonly maxBodyBytes;
|
|
15
|
+
constructor();
|
|
16
|
+
route(req: IncomingMessage, res: ServerResponse, handle: () => Promise<void>): Promise<void>;
|
|
17
|
+
body(req: IncomingMessage): Promise<unknown>;
|
|
18
|
+
}
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/** Shared HTTP body, response, authentication, and CORS plumbing. */
|
|
2
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { ApprovalInputError } from "./approvals.js";
|
|
4
|
+
import { envSetting } from "./config.js";
|
|
5
|
+
const DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024;
|
|
6
|
+
class RequestBodyError extends Error {
|
|
7
|
+
status;
|
|
8
|
+
constructor(status, message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.status = status;
|
|
11
|
+
this.name = status === 413 ? "PayloadTooLargeError" : "InvalidRequestError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function serverPassword() {
|
|
15
|
+
return process.env.OPENCODE_SERVER_PASSWORD || envSetting("SERVER_PASSWORD");
|
|
16
|
+
}
|
|
17
|
+
export function isLoopbackHost(host) {
|
|
18
|
+
if (["localhost", "::1", "[::1]"].includes(host.toLowerCase()))
|
|
19
|
+
return true;
|
|
20
|
+
return /^127(?:\.\d{1,3}){3}$/.test(host) && host.split(".").every((part) => Number(part) <= 255);
|
|
21
|
+
}
|
|
22
|
+
function allowedOrigin(origin) {
|
|
23
|
+
try {
|
|
24
|
+
const url = new URL(origin);
|
|
25
|
+
if (!/^https?:\/\/[^/?#]+$/.test(origin) ||
|
|
26
|
+
url.pathname !== "/" ||
|
|
27
|
+
url.username !== "" ||
|
|
28
|
+
url.password !== "" ||
|
|
29
|
+
url.search !== "" ||
|
|
30
|
+
url.hash !== "")
|
|
31
|
+
return false;
|
|
32
|
+
return (["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) ||
|
|
33
|
+
(envSetting("ALLOWED_ORIGINS") ?? "").split(",").some((entry) => entry.trim() === origin));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function errorBody(name, message) {
|
|
40
|
+
return { name, data: { message } };
|
|
41
|
+
}
|
|
42
|
+
export function json(res, status, body) {
|
|
43
|
+
const payload = JSON.stringify(body);
|
|
44
|
+
res.writeHead(status, { "Content-Type": "application/json" }).end(payload);
|
|
45
|
+
}
|
|
46
|
+
export class Http {
|
|
47
|
+
credentials;
|
|
48
|
+
maxBodyBytes;
|
|
49
|
+
constructor() {
|
|
50
|
+
const maxBodyBytes = Number(envSetting("MAX_BODY_BYTES"));
|
|
51
|
+
this.maxBodyBytes = Number.isSafeInteger(maxBodyBytes) && maxBodyBytes > 0 ? maxBodyBytes : DEFAULT_MAX_BODY_BYTES;
|
|
52
|
+
const password = serverPassword();
|
|
53
|
+
this.credentials =
|
|
54
|
+
password === undefined
|
|
55
|
+
? undefined
|
|
56
|
+
: createHash("sha256")
|
|
57
|
+
.update(`${process.env.OPENCODE_SERVER_USERNAME || "opencode"}:${password}`)
|
|
58
|
+
.digest();
|
|
59
|
+
}
|
|
60
|
+
async route(req, res, handle) {
|
|
61
|
+
res.setHeader("Vary", "Origin");
|
|
62
|
+
const origin = req.headers.origin;
|
|
63
|
+
if (origin !== undefined) {
|
|
64
|
+
if (allowedOrigin(origin)) {
|
|
65
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
66
|
+
res.setHeader("Access-Control-Allow-Credentials", "true");
|
|
67
|
+
}
|
|
68
|
+
else if (this.credentials === undefined) {
|
|
69
|
+
// Without a password the Origin check is the only guard against
|
|
70
|
+
// drive-by browser requests. With one, the password is the boundary:
|
|
71
|
+
// OpenChamber's proxy forwards its own clients' Origin (LAN pages,
|
|
72
|
+
// the desktop app), which must not be rejected.
|
|
73
|
+
json(res, 403, errorBody("ForbiddenError", "Origin is not allowed"));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (this.credentials !== undefined) {
|
|
78
|
+
const match = req.headers.authorization?.match(/^Basic\s+([A-Za-z0-9+/]+=*)$/i);
|
|
79
|
+
const supplied = createHash("sha256")
|
|
80
|
+
.update(Buffer.from(match?.[1] ?? "", "base64"))
|
|
81
|
+
.digest();
|
|
82
|
+
if (!match || !timingSafeEqual(this.credentials, supplied)) {
|
|
83
|
+
res.setHeader("WWW-Authenticate", 'Basic realm="OpenCode"');
|
|
84
|
+
json(res, 401, errorBody("UnauthorizedError", "Authentication required"));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS");
|
|
89
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, x-opencode-directory");
|
|
90
|
+
if (req.method === "OPTIONS") {
|
|
91
|
+
res.writeHead(204).end();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
await handle();
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
99
|
+
if (err instanceof RequestBodyError && err.status === 413) {
|
|
100
|
+
res.setHeader("Connection", "close");
|
|
101
|
+
res.once("finish", () => req.destroy());
|
|
102
|
+
}
|
|
103
|
+
json(res, err instanceof RequestBodyError ? err.status : err instanceof ApprovalInputError ? 400 : 500, errorBody(err instanceof RequestBodyError
|
|
104
|
+
? err.name
|
|
105
|
+
: err instanceof ApprovalInputError
|
|
106
|
+
? "InvalidRequestError"
|
|
107
|
+
: "UnknownError", message));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
body(req) {
|
|
111
|
+
if (Number(req.headers["content-length"]) > this.maxBodyBytes) {
|
|
112
|
+
req.pause();
|
|
113
|
+
return Promise.reject(new RequestBodyError(413, `Request body exceeds ${this.maxBodyBytes} bytes`));
|
|
114
|
+
}
|
|
115
|
+
return new Promise((resolve, reject) => {
|
|
116
|
+
const chunks = [];
|
|
117
|
+
let bytes = 0;
|
|
118
|
+
const cleanup = () => {
|
|
119
|
+
req.off("data", onData);
|
|
120
|
+
req.off("end", onEnd);
|
|
121
|
+
req.off("error", onError);
|
|
122
|
+
req.off("aborted", onAborted);
|
|
123
|
+
};
|
|
124
|
+
const onError = (error) => {
|
|
125
|
+
cleanup();
|
|
126
|
+
reject(error);
|
|
127
|
+
};
|
|
128
|
+
const onAborted = () => onError(new RequestBodyError(400, "Request body was aborted"));
|
|
129
|
+
const onData = (chunk) => {
|
|
130
|
+
bytes += chunk.length;
|
|
131
|
+
if (bytes > this.maxBodyBytes) {
|
|
132
|
+
req.pause();
|
|
133
|
+
chunks.length = 0;
|
|
134
|
+
onError(new RequestBodyError(413, `Request body exceeds ${this.maxBodyBytes} bytes`));
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
chunks.push(chunk);
|
|
138
|
+
};
|
|
139
|
+
const onEnd = () => {
|
|
140
|
+
cleanup();
|
|
141
|
+
if (bytes === 0) {
|
|
142
|
+
resolve({});
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
let body;
|
|
146
|
+
try {
|
|
147
|
+
body = JSON.parse(Buffer.concat(chunks, bytes).toString("utf8"));
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
reject(new RequestBodyError(400, "Request body must be valid JSON"));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (body === null ||
|
|
154
|
+
typeof body !== "object" ||
|
|
155
|
+
Array.isArray(body) ||
|
|
156
|
+
Object.getPrototypeOf(body) !== Object.prototype) {
|
|
157
|
+
reject(new RequestBodyError(400, "Request body must be a JSON object"));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
resolve(body);
|
|
161
|
+
};
|
|
162
|
+
req.on("data", onData);
|
|
163
|
+
req.once("end", onEnd);
|
|
164
|
+
req.once("error", onError);
|
|
165
|
+
req.once("aborted", onAborted);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ManagedSession } from "./sessions.js";
|
|
2
|
+
/** Time and scheduling seams shared by the reaper and process shutdown tests. */
|
|
3
|
+
export interface LifecycleClock {
|
|
4
|
+
now(): number;
|
|
5
|
+
setInterval(callback: () => void, ms: number): ReturnType<typeof setInterval>;
|
|
6
|
+
clearInterval(timer: ReturnType<typeof setInterval>): void;
|
|
7
|
+
setTimeout(callback: () => void, ms: number): ReturnType<typeof setTimeout>;
|
|
8
|
+
clearTimeout(timer: ReturnType<typeof setTimeout>): void;
|
|
9
|
+
}
|
|
10
|
+
export declare const systemClock: LifecycleClock;
|
|
11
|
+
export interface LifecycleOptions {
|
|
12
|
+
clock?: LifecycleClock;
|
|
13
|
+
idleTimeoutMs?: number;
|
|
14
|
+
maxLive?: number;
|
|
15
|
+
reaperIntervalMs?: number;
|
|
16
|
+
}
|
|
17
|
+
/** Serializes admission, reaping, removal and shutdown, including in-flight spawns. */
|
|
18
|
+
export declare class SessionLifecycle {
|
|
19
|
+
private sessions;
|
|
20
|
+
readonly clock: LifecycleClock;
|
|
21
|
+
private readonly idleTimeoutMs;
|
|
22
|
+
private readonly maxLive;
|
|
23
|
+
private readonly reaperIntervalMs;
|
|
24
|
+
private readonly timer;
|
|
25
|
+
private readonly used;
|
|
26
|
+
private readonly active;
|
|
27
|
+
private readonly disposing;
|
|
28
|
+
private tail;
|
|
29
|
+
private stopped;
|
|
30
|
+
private stopping;
|
|
31
|
+
constructor(sessions: () => Iterable<ManagedSession>, options?: LifecycleOptions);
|
|
32
|
+
touch(s: ManagedSession): void;
|
|
33
|
+
private idle;
|
|
34
|
+
/** Protect the caller across awaits, including admission of a dormant session. */
|
|
35
|
+
use<T>(s: ManagedSession, action: () => Promise<T>): Promise<T>;
|
|
36
|
+
run<T>(action: () => Promise<T>): Promise<T>;
|
|
37
|
+
private enqueue;
|
|
38
|
+
/** Called inside run(), before a process is spawned. */
|
|
39
|
+
makeRoom(): Promise<void>;
|
|
40
|
+
dormant(s: ManagedSession): Promise<void>;
|
|
41
|
+
reap(): Promise<void>;
|
|
42
|
+
stop(): Promise<void>;
|
|
43
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { ENV_PREFIX, envSetting } from "./config.js";
|
|
2
|
+
const MAX_REAPER_INTERVAL_MS = 15_000;
|
|
3
|
+
export const systemClock = {
|
|
4
|
+
now: Date.now,
|
|
5
|
+
setInterval,
|
|
6
|
+
clearInterval,
|
|
7
|
+
setTimeout,
|
|
8
|
+
clearTimeout,
|
|
9
|
+
};
|
|
10
|
+
function positiveEnv(name, fallback) {
|
|
11
|
+
const value = Number(envSetting(name) ?? fallback);
|
|
12
|
+
if (!Number.isSafeInteger(value) || value <= 0)
|
|
13
|
+
throw new Error(`${ENV_PREFIX}${name} must be a positive integer`);
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
/** Serializes admission, reaping, removal and shutdown, including in-flight spawns. */
|
|
17
|
+
export class SessionLifecycle {
|
|
18
|
+
sessions;
|
|
19
|
+
clock;
|
|
20
|
+
idleTimeoutMs;
|
|
21
|
+
maxLive;
|
|
22
|
+
reaperIntervalMs;
|
|
23
|
+
timer;
|
|
24
|
+
used = new WeakMap();
|
|
25
|
+
active = new WeakMap();
|
|
26
|
+
disposing = new Set();
|
|
27
|
+
tail = Promise.resolve();
|
|
28
|
+
stopped = false;
|
|
29
|
+
stopping;
|
|
30
|
+
constructor(sessions, options = {}) {
|
|
31
|
+
this.sessions = sessions;
|
|
32
|
+
this.clock = options.clock ?? systemClock;
|
|
33
|
+
this.idleTimeoutMs = options.idleTimeoutMs ?? positiveEnv("IDLE_TIMEOUT_MS", 900_000);
|
|
34
|
+
this.maxLive = options.maxLive ?? positiveEnv("MAX_LIVE", 8);
|
|
35
|
+
this.reaperIntervalMs =
|
|
36
|
+
options.reaperIntervalMs ?? Math.min(MAX_REAPER_INTERVAL_MS, Math.max(1_000, Math.floor(this.idleTimeoutMs / 4)));
|
|
37
|
+
if (!Number.isSafeInteger(this.reaperIntervalMs) || this.reaperIntervalMs <= 0) {
|
|
38
|
+
throw new Error("reaperIntervalMs must be a positive integer");
|
|
39
|
+
}
|
|
40
|
+
this.timer = this.clock.setInterval(() => {
|
|
41
|
+
void this.reap().catch((error) => console.error("[lifecycle] reaper failed:", error));
|
|
42
|
+
}, this.reaperIntervalMs);
|
|
43
|
+
this.timer.unref();
|
|
44
|
+
}
|
|
45
|
+
touch(s) {
|
|
46
|
+
this.used.set(s, this.clock.now());
|
|
47
|
+
}
|
|
48
|
+
idle(s) {
|
|
49
|
+
return (!this.active.has(s) &&
|
|
50
|
+
s.pi !== undefined &&
|
|
51
|
+
!s.pi.isBusy &&
|
|
52
|
+
s.status.type === "idle" &&
|
|
53
|
+
!s.hasPendingInteraction() &&
|
|
54
|
+
!s.hasRunningChildren() &&
|
|
55
|
+
s.piSessionId !== undefined &&
|
|
56
|
+
s.piPath !== undefined);
|
|
57
|
+
}
|
|
58
|
+
/** Protect the caller across awaits, including admission of a dormant session. */
|
|
59
|
+
async use(s, action) {
|
|
60
|
+
if (this.stopped)
|
|
61
|
+
throw new Error("session manager is stopping");
|
|
62
|
+
this.active.set(s, (this.active.get(s) ?? 0) + 1);
|
|
63
|
+
this.touch(s);
|
|
64
|
+
try {
|
|
65
|
+
return await action();
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
const remaining = (this.active.get(s) ?? 1) - 1;
|
|
69
|
+
if (remaining === 0)
|
|
70
|
+
this.active.delete(s);
|
|
71
|
+
else
|
|
72
|
+
this.active.set(s, remaining);
|
|
73
|
+
this.touch(s);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
run(action) {
|
|
77
|
+
if (this.stopped)
|
|
78
|
+
return Promise.reject(new Error("session manager is stopping"));
|
|
79
|
+
return this.enqueue(action);
|
|
80
|
+
}
|
|
81
|
+
enqueue(action) {
|
|
82
|
+
const next = this.tail.then(action);
|
|
83
|
+
this.tail = next.catch(() => undefined);
|
|
84
|
+
return next;
|
|
85
|
+
}
|
|
86
|
+
/** Called inside run(), before a process is spawned. */
|
|
87
|
+
async makeRoom() {
|
|
88
|
+
while ([...this.sessions()].filter((s) => s.pi !== undefined).length >= this.maxLive) {
|
|
89
|
+
const candidate = [...this.sessions()]
|
|
90
|
+
.filter((s) => this.idle(s))
|
|
91
|
+
.sort((a, b) => (this.used.get(a) ?? 0) - (this.used.get(b) ?? 0))[0];
|
|
92
|
+
if (candidate === undefined) {
|
|
93
|
+
throw new Error(`${ENV_PREFIX}MAX_LIVE=${this.maxLive} reached: no disposable idle session`);
|
|
94
|
+
}
|
|
95
|
+
await this.dormant(candidate);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async dormant(s) {
|
|
99
|
+
const pi = s.pi;
|
|
100
|
+
if (pi === undefined)
|
|
101
|
+
return;
|
|
102
|
+
if (s.piSessionId !== undefined && s.piPath !== undefined) {
|
|
103
|
+
s.restored = { piSessionId: s.piSessionId, piPath: s.piPath };
|
|
104
|
+
}
|
|
105
|
+
s.unwirePi?.();
|
|
106
|
+
s.pi = undefined; // Old exit listeners must not mutate a revived session.
|
|
107
|
+
const disposing = pi.dispose();
|
|
108
|
+
this.disposing.add(disposing);
|
|
109
|
+
try {
|
|
110
|
+
await disposing;
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
this.disposing.delete(disposing);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
reap() {
|
|
117
|
+
if (this.stopped)
|
|
118
|
+
return Promise.resolve();
|
|
119
|
+
return this.run(async () => {
|
|
120
|
+
const now = this.clock.now();
|
|
121
|
+
for (const s of this.sessions()) {
|
|
122
|
+
if (this.idle(s) && now - (this.used.get(s) ?? now) >= this.idleTimeoutMs) {
|
|
123
|
+
await this.dormant(s);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
stop() {
|
|
129
|
+
if (this.stopping !== undefined)
|
|
130
|
+
return this.stopping;
|
|
131
|
+
this.stopped = true;
|
|
132
|
+
this.clock.clearInterval(this.timer);
|
|
133
|
+
this.stopping = this.enqueue(async () => {
|
|
134
|
+
await Promise.all([...[...this.sessions()].map((s) => this.dormant(s)), ...this.disposing]);
|
|
135
|
+
});
|
|
136
|
+
return this.stopping;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { errorBody } from "./http.js";
|
|
2
|
+
/** One shared failure latch for HTTP and agent-event diagnostics. */
|
|
3
|
+
export declare function debugLog(line: string): void;
|
|
4
|
+
interface RouteResult {
|
|
5
|
+
status: number;
|
|
6
|
+
body: unknown;
|
|
7
|
+
}
|
|
8
|
+
export declare function miscRoute(method: string, path: string, url: URL, directory: string): Promise<RouteResult | undefined>;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { appendFileSync } from "node:fs";
|
|
2
|
+
import { opendir, realpath } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { envSetting } from "./config.js";
|
|
6
|
+
import { errorBody } from "./http.js";
|
|
7
|
+
export { errorBody } from "./http.js";
|
|
8
|
+
let loggingDisabled = false;
|
|
9
|
+
const MAX_DIRECTORY_ENTRIES = 1000;
|
|
10
|
+
const MAX_SEARCH_DIRECTORIES = 1024;
|
|
11
|
+
const MAX_SEARCH_DEPTH = 32;
|
|
12
|
+
const MAX_SEARCH_RESULTS = 1000;
|
|
13
|
+
/** One shared failure latch for HTTP and agent-event diagnostics. */
|
|
14
|
+
export function debugLog(line) {
|
|
15
|
+
if (!envSetting("DEBUG") || loggingDisabled)
|
|
16
|
+
return;
|
|
17
|
+
try {
|
|
18
|
+
appendFileSync(envSetting("DEBUG_LOG") ?? join(tmpdir(), `antechamber-debug-${process.getuid?.() ?? "user"}.log`), line + "\n", { flag: "a", mode: 0o600 });
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
loggingDisabled = true;
|
|
22
|
+
console.error(`[debug] logging disabled: ${error instanceof Error ? error.message : String(error)}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function unsupported(message) {
|
|
26
|
+
return { status: 400, body: errorBody("UnsupportedError", message) };
|
|
27
|
+
}
|
|
28
|
+
async function confinedDirectory(root, path) {
|
|
29
|
+
const candidate = resolve(root, path);
|
|
30
|
+
const rel = relative(root, candidate);
|
|
31
|
+
if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
|
|
32
|
+
return undefined;
|
|
33
|
+
try {
|
|
34
|
+
const actual = await realpath(candidate);
|
|
35
|
+
const actualRel = relative(root, actual);
|
|
36
|
+
return actualRel === ".." || actualRel.startsWith(`..${sep}`) || isAbsolute(actualRel) ? undefined : actual;
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
|
|
40
|
+
throw error;
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export async function miscRoute(method, path, url, directory) {
|
|
45
|
+
if (method === "GET" && ["/skill", "/lsp", "/formatter", "/experimental/tool/ids"].includes(path)) {
|
|
46
|
+
return { status: 200, body: [] };
|
|
47
|
+
}
|
|
48
|
+
if (method === "GET" && ["/provider/auth", "/mcp"].includes(path))
|
|
49
|
+
return { status: 200, body: {} };
|
|
50
|
+
if ((method === "PUT" && /^\/auth\/[^/]+$/.test(path)) ||
|
|
51
|
+
(method === "POST" && /^\/provider\/[^/]+\/oauth\/(authorize|callback)$/.test(path)) ||
|
|
52
|
+
(method === "PATCH" && path === "/config")) {
|
|
53
|
+
return unsupported("Not supported with OmO; configure providers in OmO");
|
|
54
|
+
}
|
|
55
|
+
if ((["POST", "DELETE"].includes(method) &&
|
|
56
|
+
/^\/mcp\/[^/]+\/(connect|disconnect|auth(?:\/(?:callback|authenticate))?)$/.test(path)) ||
|
|
57
|
+
(method === "POST" && path === "/experimental/control-plane/move-session")) {
|
|
58
|
+
return unsupported("Not supported with OmO; configure MCP and session directories in OmO");
|
|
59
|
+
}
|
|
60
|
+
if (method !== "GET" || !["/file", "/find/file"].includes(path))
|
|
61
|
+
return undefined;
|
|
62
|
+
const root = await realpath(directory);
|
|
63
|
+
if (path === "/file") {
|
|
64
|
+
const target = await confinedDirectory(root, url.searchParams.get("path") ?? "");
|
|
65
|
+
if (target === undefined)
|
|
66
|
+
return unsupported("Path must remain inside the session directory");
|
|
67
|
+
const body = [];
|
|
68
|
+
let scanned = 0;
|
|
69
|
+
for await (const entry of await opendir(target)) {
|
|
70
|
+
if (++scanned > MAX_DIRECTORY_ENTRIES)
|
|
71
|
+
break;
|
|
72
|
+
if (entry.isSymbolicLink() || entry.name === ".git" || (!entry.isFile() && !entry.isDirectory()))
|
|
73
|
+
continue;
|
|
74
|
+
const absolute = resolve(target, entry.name);
|
|
75
|
+
body.push({
|
|
76
|
+
name: entry.name,
|
|
77
|
+
path: relative(root, absolute).split(sep).join("/"),
|
|
78
|
+
absolute,
|
|
79
|
+
type: entry.isDirectory() ? "directory" : "file",
|
|
80
|
+
ignored: false,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
body.sort((a, b) => a.path.localeCompare(b.path));
|
|
84
|
+
return { status: 200, body };
|
|
85
|
+
}
|
|
86
|
+
const query = url.searchParams.get("query") ?? "";
|
|
87
|
+
if (isAbsolute(query) ||
|
|
88
|
+
query.split(/[\\/]/).includes("..") ||
|
|
89
|
+
(url.searchParams.has("path") && (await confinedDirectory(root, url.searchParams.get("path") ?? "")) === undefined)) {
|
|
90
|
+
return unsupported("Path must remain inside the session directory");
|
|
91
|
+
}
|
|
92
|
+
const limitValue = Number(url.searchParams.get("limit") ?? "100");
|
|
93
|
+
const limit = Number.isFinite(limitValue) ? Math.min(MAX_SEARCH_RESULTS, Math.max(0, Math.floor(limitValue))) : 100;
|
|
94
|
+
const dirs = url.searchParams.get("dirs") !== "false" && url.searchParams.get("type") !== "file";
|
|
95
|
+
const onlyDirs = url.searchParams.get("type") === "directory";
|
|
96
|
+
const files = [];
|
|
97
|
+
let visited = 0;
|
|
98
|
+
const lowerQuery = query.toLowerCase();
|
|
99
|
+
async function visit(directory, depth) {
|
|
100
|
+
if (visited >= MAX_SEARCH_DIRECTORIES || depth > MAX_SEARCH_DEPTH || files.length >= limit)
|
|
101
|
+
return;
|
|
102
|
+
visited++;
|
|
103
|
+
let scanned = 0;
|
|
104
|
+
for await (const entry of await opendir(directory)) {
|
|
105
|
+
if (++scanned > MAX_DIRECTORY_ENTRIES || files.length >= limit || visited >= MAX_SEARCH_DIRECTORIES)
|
|
106
|
+
return;
|
|
107
|
+
if (entry.isSymbolicLink() || entry.name === ".git")
|
|
108
|
+
continue;
|
|
109
|
+
const absolute = resolve(directory, entry.name);
|
|
110
|
+
const path = relative(root, absolute).split(sep).join("/");
|
|
111
|
+
if (((entry.isFile() && !onlyDirs) || (entry.isDirectory() && dirs)) && path.toLowerCase().includes(lowerQuery))
|
|
112
|
+
files.push(path);
|
|
113
|
+
if (entry.isDirectory() && entry.name !== "node_modules")
|
|
114
|
+
await visit(absolute, depth + 1);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
await visit(root, 0);
|
|
118
|
+
return { status: 200, body: files.sort() };
|
|
119
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** Pi message/part translation and the live event bridge, with explicit manager services. */
|
|
2
|
+
import type { OCAssistantMessage, OCMessageWithParts, OCEvent, OCSessionStatus, OCPart } from "./types.js";
|
|
3
|
+
import type { ManagedSession } from "./sessions.js";
|
|
4
|
+
import type { SessionStore } from "./session-store.js";
|
|
5
|
+
import type { SubagentBridge } from "./subagents.js";
|
|
6
|
+
import { type TodoUpdated } from "./todo-bridge.js";
|
|
7
|
+
export interface PiUsage {
|
|
8
|
+
input?: number;
|
|
9
|
+
output?: number;
|
|
10
|
+
reasoning?: number;
|
|
11
|
+
cacheRead?: number;
|
|
12
|
+
cacheWrite?: number;
|
|
13
|
+
cost?: {
|
|
14
|
+
total?: number;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export declare function id(prefix: string): string;
|
|
18
|
+
/**
|
|
19
|
+
* pi reports the requested `model` and, once the provider has answered, the
|
|
20
|
+
* concrete `responseModel` it actually served. The latter is the truth.
|
|
21
|
+
*/
|
|
22
|
+
export declare function piModelId(responseModel: unknown, model: unknown): unknown;
|
|
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;
|
|
25
|
+
export declare function newPart(kind: "text" | "reasoning", sessionID: string, messageID: string): OCPart;
|
|
26
|
+
export declare function applyUsage(msg: OCAssistantMessage, usage: PiUsage | undefined): void;
|
|
27
|
+
interface PiEventServices {
|
|
28
|
+
readonly emit: (directory: string, event: OCEvent | TodoUpdated) => void;
|
|
29
|
+
readonly subagents: SubagentBridge;
|
|
30
|
+
readonly store: Pick<SessionStore, "update">;
|
|
31
|
+
readonly dirtyDiffs: Set<string>;
|
|
32
|
+
readonly ensureMessages: (s: ManagedSession) => OCMessageWithParts[];
|
|
33
|
+
readonly applyNativeTitle: (s: ManagedSession, value: unknown) => void;
|
|
34
|
+
readonly applyPiModel: (s: ManagedSession, provider: unknown, modelId: unknown) => boolean;
|
|
35
|
+
readonly emitDiff: (s: ManagedSession) => void;
|
|
36
|
+
readonly persistMeta: (s: ManagedSession) => void;
|
|
37
|
+
}
|
|
38
|
+
/** Translates per-session events; registry and lifecycle operations stay with SessionManager. */
|
|
39
|
+
export declare class PiEventTranslator {
|
|
40
|
+
private services;
|
|
41
|
+
constructor(services: PiEventServices);
|
|
42
|
+
setStatus(s: ManagedSession, status: OCSessionStatus): void;
|
|
43
|
+
emitPart(s: ManagedSession, part: OCPart, delta?: string): void;
|
|
44
|
+
deliverInput(s: ManagedSession, messageID: string): void;
|
|
45
|
+
private reconcileUser;
|
|
46
|
+
onPiEvent(s: ManagedSession, ev: Record<string, unknown>): void;
|
|
47
|
+
private finishStreaming;
|
|
48
|
+
private onMessageUpdate;
|
|
49
|
+
private onProviderTools;
|
|
50
|
+
/**
|
|
51
|
+
* pi toolcall events → OpenCode tool parts (M2). Live-observed shapes
|
|
52
|
+
* (probe, 2026-09-21):
|
|
53
|
+
* toolcall_start {contentIndex, id, toolName}
|
|
54
|
+
* toolcall_delta {contentIndex, delta} — JSON argument fragments
|
|
55
|
+
* toolcall_end {contentIndex, toolCall:{id, name, arguments}}
|
|
56
|
+
* Results arrive separately in turn_end.toolResults
|
|
57
|
+
* [{toolCallId, toolName, content:[{type:"text",text}], isError}].
|
|
58
|
+
*/
|
|
59
|
+
private onToolcallEvent;
|
|
60
|
+
/** turn_end.toolResults → finalize tool parts (completed / error). */
|
|
61
|
+
private applyToolResults;
|
|
62
|
+
/** Tool parts that never received a result (abort, crash) must not spin forever. */
|
|
63
|
+
private finalizeDanglingTools;
|
|
64
|
+
}
|
|
65
|
+
export {};
|