@myk-org/pi-sidecar 1.0.0-dev.1 → 1.0.0-dev.2
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/dist/index.d.ts +13 -0
- package/dist/index.js +177 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +2 -0
- package/dist/sessions.d.ts +39 -0
- package/dist/sessions.js +312 -0
- package/dist/watchdog.d.ts +1 -0
- package/dist/watchdog.js +52 -0
- package/package.json +1 -1
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { SessionStore, type CreateSessionOptions } from "./sessions.js";
|
|
2
|
+
export { startWatchdog } from "./watchdog.js";
|
|
3
|
+
import { type IncomingMessage } from "node:http";
|
|
4
|
+
export declare function parseBody(req: IncomingMessage): Promise<any>;
|
|
5
|
+
export declare function routeMatch(url: string, pattern: string): Record<string, string> | null;
|
|
6
|
+
export interface SidecarHandle {
|
|
7
|
+
close(): Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
export declare function startSidecar(options?: {
|
|
10
|
+
port?: number;
|
|
11
|
+
host?: string;
|
|
12
|
+
watchdogUrl?: string;
|
|
13
|
+
}): SidecarHandle;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
export { SessionStore } from "./sessions.js";
|
|
2
|
+
export { startWatchdog } from "./watchdog.js";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { SessionStore } from "./sessions.js";
|
|
5
|
+
import { startWatchdog } from "./watchdog.js";
|
|
6
|
+
const MAX_BODY_SIZE = 1_048_576;
|
|
7
|
+
// Simple JSON body parser
|
|
8
|
+
export async function parseBody(req) {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
let body = "";
|
|
11
|
+
let bodySizeBytes = 0;
|
|
12
|
+
let rejected = false;
|
|
13
|
+
req.on("data", (chunk) => {
|
|
14
|
+
if (rejected)
|
|
15
|
+
return;
|
|
16
|
+
bodySizeBytes += chunk.length;
|
|
17
|
+
if (bodySizeBytes > MAX_BODY_SIZE) {
|
|
18
|
+
rejected = true;
|
|
19
|
+
req.resume();
|
|
20
|
+
reject(new Error("Payload too large"));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
body += chunk.toString();
|
|
24
|
+
});
|
|
25
|
+
req.on("end", () => {
|
|
26
|
+
try {
|
|
27
|
+
resolve(body ? JSON.parse(body) : {});
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
reject(new Error("Invalid JSON body"));
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
req.on("error", reject);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
function sendJson(res, status, data) {
|
|
37
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
38
|
+
res.end(JSON.stringify(data));
|
|
39
|
+
}
|
|
40
|
+
export function routeMatch(url, pattern) {
|
|
41
|
+
const patternParts = pattern.split("/");
|
|
42
|
+
const urlParts = url.split("?")[0].split("/");
|
|
43
|
+
if (patternParts.length !== urlParts.length)
|
|
44
|
+
return null;
|
|
45
|
+
const params = {};
|
|
46
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
47
|
+
if (patternParts[i].startsWith(":")) {
|
|
48
|
+
params[patternParts[i].slice(1)] = urlParts[i];
|
|
49
|
+
}
|
|
50
|
+
else if (patternParts[i] !== urlParts[i]) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return params;
|
|
55
|
+
}
|
|
56
|
+
export function startSidecar(options) {
|
|
57
|
+
const PORT = options?.port ?? parseInt(process.env.SIDECAR_PORT || "9100", 10);
|
|
58
|
+
const HOST = options?.host ?? (process.env.DEV_MODE === "true" ? "0.0.0.0" : "127.0.0.1");
|
|
59
|
+
const store = new SessionStore();
|
|
60
|
+
const server = createServer(async (req, res) => {
|
|
61
|
+
const method = req.method || "GET";
|
|
62
|
+
const url = req.url || "/";
|
|
63
|
+
try {
|
|
64
|
+
// GET /health
|
|
65
|
+
if (method === "GET" && url === "/health") {
|
|
66
|
+
if (!store.ready) {
|
|
67
|
+
sendJson(res, 503, { status: "starting", message: "Model discovery in progress" });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
sendJson(res, 200, { status: "ok", sessions: store.count() });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
// GET /models
|
|
74
|
+
if (method === "GET" && url === "/models") {
|
|
75
|
+
sendJson(res, 200, { models: store.getModels() });
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
// POST /models/refresh
|
|
79
|
+
if (method === "POST" && url === "/models/refresh") {
|
|
80
|
+
const models = await store.refreshModels();
|
|
81
|
+
sendJson(res, 200, { models });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
// POST /sessions
|
|
85
|
+
if (method === "POST" && url === "/sessions") {
|
|
86
|
+
const body = await parseBody(req);
|
|
87
|
+
const { provider, model, system_prompt, cwd, custom_tools } = body;
|
|
88
|
+
if (!provider || !system_prompt) {
|
|
89
|
+
sendJson(res, 400, { error: "provider and system_prompt are required" });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (!model) {
|
|
93
|
+
sendJson(res, 400, { error: "model is required. Use GET /models to list available models." });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const sessionId = await store.create({
|
|
97
|
+
provider,
|
|
98
|
+
model: model || "",
|
|
99
|
+
systemPrompt: system_prompt,
|
|
100
|
+
cwd: cwd || process.cwd(),
|
|
101
|
+
customTools: custom_tools,
|
|
102
|
+
});
|
|
103
|
+
sendJson(res, 201, { session_id: sessionId });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// POST /sessions/:id/prompt
|
|
107
|
+
let params = routeMatch(url, "/sessions/:id/prompt");
|
|
108
|
+
if (method === "POST" && params) {
|
|
109
|
+
const body = await parseBody(req);
|
|
110
|
+
if (!body.message) {
|
|
111
|
+
sendJson(res, 400, { error: "message is required" });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const result = await store.prompt(params.id, body.message);
|
|
115
|
+
sendJson(res, 200, result);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
// POST /sessions/:id/abort
|
|
119
|
+
params = routeMatch(url, "/sessions/:id/abort");
|
|
120
|
+
if (method === "POST" && params) {
|
|
121
|
+
await store.abort(params.id);
|
|
122
|
+
sendJson(res, 200, { aborted: true });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
// DELETE /sessions/:id
|
|
126
|
+
params = routeMatch(url, "/sessions/:id");
|
|
127
|
+
if (method === "DELETE" && params) {
|
|
128
|
+
store.delete(params.id);
|
|
129
|
+
sendJson(res, 200, { deleted: true });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
sendJson(res, 404, { error: "Not found" });
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
const message = err?.message || "Internal server error";
|
|
136
|
+
const status = message.includes("not found") ? 404 : message.includes("Payload too large") ? 413 : message.includes("Invalid JSON") ? 400 : message.includes("is busy") ? 409 : 500;
|
|
137
|
+
console.error(`[sidecar] ${method} ${url} error:`, message);
|
|
138
|
+
sendJson(res, status, { error: message });
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
// Stale session cleanup every 10 minutes
|
|
142
|
+
const cleanupInterval = setInterval(() => {
|
|
143
|
+
store.cleanupStale(60 * 60 * 1000); // 1 hour
|
|
144
|
+
}, 10 * 60 * 1000);
|
|
145
|
+
let stopWatchdog;
|
|
146
|
+
server.listen(PORT, HOST, () => {
|
|
147
|
+
console.log(`[sidecar] Pi SDK sidecar listening on http://${HOST}:${PORT}`);
|
|
148
|
+
const watchdogUrl = options?.watchdogUrl || process.env.SIDECAR_WATCHDOG_URL;
|
|
149
|
+
if (watchdogUrl) {
|
|
150
|
+
stopWatchdog = startWatchdog(watchdogUrl, () => {
|
|
151
|
+
console.log("[sidecar] Backend unresponsive, shutting down");
|
|
152
|
+
stopWatchdog?.();
|
|
153
|
+
clearInterval(cleanupInterval);
|
|
154
|
+
store.disposeAll();
|
|
155
|
+
server.close();
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
// Auto-discover models from extensions on startup
|
|
159
|
+
store.refreshModels().catch((err) => {
|
|
160
|
+
console.error("[sidecar] Model discovery failed:", err);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
return {
|
|
164
|
+
close: () => new Promise((resolve, reject) => {
|
|
165
|
+
stopWatchdog?.();
|
|
166
|
+
clearInterval(cleanupInterval);
|
|
167
|
+
store.disposeAll();
|
|
168
|
+
server.close((err) => {
|
|
169
|
+
if (err)
|
|
170
|
+
reject(err);
|
|
171
|
+
else
|
|
172
|
+
resolve();
|
|
173
|
+
console.log("[sidecar] Shut down");
|
|
174
|
+
});
|
|
175
|
+
}),
|
|
176
|
+
};
|
|
177
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export interface CreateSessionOptions {
|
|
2
|
+
provider: string;
|
|
3
|
+
model: string;
|
|
4
|
+
systemPrompt: string;
|
|
5
|
+
cwd: string;
|
|
6
|
+
customTools?: any[];
|
|
7
|
+
}
|
|
8
|
+
export declare class SessionStore {
|
|
9
|
+
private sessions;
|
|
10
|
+
private authStorage;
|
|
11
|
+
private modelRegistry;
|
|
12
|
+
private _ready;
|
|
13
|
+
private acpxModels;
|
|
14
|
+
get ready(): boolean;
|
|
15
|
+
count(): number;
|
|
16
|
+
getModels(): Array<{
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
provider: string;
|
|
20
|
+
}>;
|
|
21
|
+
/**
|
|
22
|
+
* Discover models from all configured providers. Blocks until complete.
|
|
23
|
+
* Called on startup — /health returns ok only after this finishes.
|
|
24
|
+
*/
|
|
25
|
+
refreshModels(): Promise<Array<{
|
|
26
|
+
id: string;
|
|
27
|
+
name: string;
|
|
28
|
+
provider: string;
|
|
29
|
+
}>>;
|
|
30
|
+
create(options: CreateSessionOptions): Promise<string>;
|
|
31
|
+
prompt(id: string, message: string): Promise<{
|
|
32
|
+
text: string;
|
|
33
|
+
usage: any;
|
|
34
|
+
}>;
|
|
35
|
+
abort(id: string): Promise<void>;
|
|
36
|
+
delete(id: string): void;
|
|
37
|
+
disposeAll(): void;
|
|
38
|
+
cleanupStale(maxAge: number): void;
|
|
39
|
+
}
|
package/dist/sessions.js
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { accessSync } from "node:fs";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { AuthStorage, createAgentSession, DefaultResourceLoader, ModelRegistry, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { getModel } from "@earendil-works/pi-ai";
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
function resolveExtensionPath(envVar, packageName, entryFile) {
|
|
10
|
+
const envPath = process.env[envVar];
|
|
11
|
+
if (envPath)
|
|
12
|
+
return envPath;
|
|
13
|
+
try {
|
|
14
|
+
// resolve() finds the package wherever npm installed it (hoisted or nested)
|
|
15
|
+
const pkgJson = require.resolve(`${packageName}/package.json`);
|
|
16
|
+
return join(dirname(pkgJson), entryFile);
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
console.debug(`[sidecar] Could not resolve ${packageName}:`, err);
|
|
20
|
+
return "";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const ACPX_EXTENSION = resolveExtensionPath("SIDECAR_ACPX_EXTENSION_PATH", "pi-orchestrator-config", "extensions/acpx-provider/index.ts");
|
|
24
|
+
const VERTEX_EXTENSION = resolveExtensionPath("SIDECAR_VERTEX_EXTENSION_PATH", "pi-vertex-claude", "index.ts");
|
|
25
|
+
export class SessionStore {
|
|
26
|
+
sessions = new Map();
|
|
27
|
+
authStorage = AuthStorage.create();
|
|
28
|
+
modelRegistry = ModelRegistry.create(this.authStorage);
|
|
29
|
+
_ready = false;
|
|
30
|
+
acpxModels = [];
|
|
31
|
+
get ready() {
|
|
32
|
+
return this._ready;
|
|
33
|
+
}
|
|
34
|
+
count() {
|
|
35
|
+
return this.sessions.size;
|
|
36
|
+
}
|
|
37
|
+
getModels() {
|
|
38
|
+
// Providers that require browser OAuth and cannot work in a headless container
|
|
39
|
+
const HEADLESS_EXCLUDED_PROVIDERS = new Set(["github-copilot"]);
|
|
40
|
+
const builtinModels = this.modelRegistry.getAvailable()
|
|
41
|
+
.filter((m) => !HEADLESS_EXCLUDED_PROVIDERS.has(m.provider))
|
|
42
|
+
.map((m) => ({
|
|
43
|
+
id: m.id,
|
|
44
|
+
name: m.name,
|
|
45
|
+
provider: m.provider,
|
|
46
|
+
}));
|
|
47
|
+
return [...builtinModels, ...this.acpxModels];
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Discover models from all configured providers. Blocks until complete.
|
|
51
|
+
* Called on startup — /health returns ok only after this finishes.
|
|
52
|
+
*/
|
|
53
|
+
async refreshModels() {
|
|
54
|
+
// Create a bootstrap session to trigger extension loading.
|
|
55
|
+
// Extensions like vertex-claude register models synchronously on load.
|
|
56
|
+
// The session is kept alive (disposed at cleanup) to maintain extension state.
|
|
57
|
+
const bootstrapId = await this.create({
|
|
58
|
+
provider: "google",
|
|
59
|
+
model: "gemini-2.5-flash",
|
|
60
|
+
systemPrompt: "bootstrap",
|
|
61
|
+
cwd: "/tmp",
|
|
62
|
+
});
|
|
63
|
+
console.log(`[sidecar] Bootstrap session created for extension loading`);
|
|
64
|
+
// Discover acpx models for each agent in parallel (blocks until all complete)
|
|
65
|
+
const agents = (process.env.ACPX_AGENTS || "")
|
|
66
|
+
.split(",")
|
|
67
|
+
.map((a) => a.trim())
|
|
68
|
+
.filter((a) => a.length > 0);
|
|
69
|
+
if (agents.length > 0) {
|
|
70
|
+
console.log(`[sidecar] Discovering models for ACPX agents: ${agents.join(", ")}`);
|
|
71
|
+
const results = await Promise.allSettled(agents.map((agent) => discoverAcpxModels(agent)));
|
|
72
|
+
this.acpxModels = [];
|
|
73
|
+
for (let i = 0; i < results.length; i++) {
|
|
74
|
+
const result = results[i];
|
|
75
|
+
const agent = agents[i];
|
|
76
|
+
if (result.status === "fulfilled" && result.value.length > 0) {
|
|
77
|
+
this.acpxModels.push(...result.value);
|
|
78
|
+
console.log(`[sidecar] acpx-${agent}: ${result.value.length} models discovered`);
|
|
79
|
+
}
|
|
80
|
+
else if (result.status === "rejected") {
|
|
81
|
+
console.error(`[sidecar] acpx-${agent}: discovery failed:`, result.reason);
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
console.warn(`[sidecar] acpx-${agent}: no models discovered`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Clean up bootstrap session
|
|
89
|
+
this.delete(bootstrapId);
|
|
90
|
+
this._ready = true;
|
|
91
|
+
console.log(`[sidecar] Model discovery complete: ${this.getModels().length} models available`);
|
|
92
|
+
return this.getModels();
|
|
93
|
+
}
|
|
94
|
+
async create(options) {
|
|
95
|
+
const id = randomUUID();
|
|
96
|
+
if (!options.model) {
|
|
97
|
+
throw new Error(`Model is required. Use GET /models to list available models.`);
|
|
98
|
+
}
|
|
99
|
+
// Find the model
|
|
100
|
+
let model = this.modelRegistry.find(options.provider, options.model) || undefined;
|
|
101
|
+
if (!model) {
|
|
102
|
+
// Try built-in models
|
|
103
|
+
model = getModel(options.provider, options.model) || undefined;
|
|
104
|
+
}
|
|
105
|
+
if (!model) {
|
|
106
|
+
throw new Error(`Model '${options.model}' not found for provider '${options.provider}'. Use GET /models to list available models.`);
|
|
107
|
+
}
|
|
108
|
+
// Build extension paths (only include existing files)
|
|
109
|
+
const extensionPaths = [];
|
|
110
|
+
if (ACPX_EXTENSION) {
|
|
111
|
+
try {
|
|
112
|
+
accessSync(ACPX_EXTENSION);
|
|
113
|
+
extensionPaths.push(ACPX_EXTENSION);
|
|
114
|
+
console.log(`[sidecar] Extension found: ${ACPX_EXTENSION}`);
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
console.warn(`[sidecar] ACPX extension not found at ${ACPX_EXTENSION}:`, err);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (VERTEX_EXTENSION) {
|
|
121
|
+
try {
|
|
122
|
+
accessSync(VERTEX_EXTENSION);
|
|
123
|
+
extensionPaths.push(VERTEX_EXTENSION);
|
|
124
|
+
console.log(`[sidecar] Extension found: ${VERTEX_EXTENSION}`);
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
console.warn(`[sidecar] Vertex extension not found at ${VERTEX_EXTENSION}:`, err);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
console.log(`[sidecar] Loading ${extensionPaths.length} extensions`);
|
|
131
|
+
// Build custom tools from config
|
|
132
|
+
const customTools = options.customTools || [];
|
|
133
|
+
const settingsManager = SettingsManager.inMemory({
|
|
134
|
+
compaction: { enabled: false },
|
|
135
|
+
});
|
|
136
|
+
const loader = new DefaultResourceLoader({
|
|
137
|
+
cwd: options.cwd,
|
|
138
|
+
agentDir: "/tmp/pi-sidecar-agent",
|
|
139
|
+
settingsManager,
|
|
140
|
+
additionalExtensionPaths: extensionPaths,
|
|
141
|
+
systemPromptOverride: () => options.systemPrompt,
|
|
142
|
+
});
|
|
143
|
+
await loader.reload();
|
|
144
|
+
const { session } = await createAgentSession({
|
|
145
|
+
cwd: options.cwd,
|
|
146
|
+
model,
|
|
147
|
+
thinkingLevel: "off",
|
|
148
|
+
tools: ["read", "grep", "find", "ls", "bash"],
|
|
149
|
+
customTools,
|
|
150
|
+
resourceLoader: loader,
|
|
151
|
+
sessionManager: SessionManager.inMemory(),
|
|
152
|
+
settingsManager,
|
|
153
|
+
authStorage: this.authStorage,
|
|
154
|
+
modelRegistry: this.modelRegistry,
|
|
155
|
+
});
|
|
156
|
+
this.sessions.set(id, { session, lastActivity: Date.now(), inFlight: false });
|
|
157
|
+
console.log(`[sidecar] Session created: ${id} (provider=${options.provider}, model=${options.model})`);
|
|
158
|
+
return id;
|
|
159
|
+
}
|
|
160
|
+
async prompt(id, message) {
|
|
161
|
+
const entry = this.sessions.get(id);
|
|
162
|
+
if (!entry)
|
|
163
|
+
throw new Error(`Session ${id} not found`);
|
|
164
|
+
if (entry.inFlight) {
|
|
165
|
+
throw new Error(`Session ${id} is busy — concurrent prompts are not supported`);
|
|
166
|
+
}
|
|
167
|
+
entry.lastActivity = Date.now();
|
|
168
|
+
entry.inFlight = true;
|
|
169
|
+
let responseText = "";
|
|
170
|
+
const usage = { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0, cost_usd: null, duration_ms: 0 };
|
|
171
|
+
const startTime = Date.now();
|
|
172
|
+
const unsubscribe = entry.session.subscribe((event) => {
|
|
173
|
+
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
|
174
|
+
responseText += event.assistantMessageEvent.delta;
|
|
175
|
+
}
|
|
176
|
+
if (event.type === "agent_end" && event.messages) {
|
|
177
|
+
for (const msg of event.messages) {
|
|
178
|
+
if (msg.role === "assistant" && msg.usage) {
|
|
179
|
+
usage.input_tokens += msg.usage.input || 0;
|
|
180
|
+
usage.output_tokens += msg.usage.output || 0;
|
|
181
|
+
usage.cache_read_tokens += msg.usage.cacheRead || 0;
|
|
182
|
+
usage.cache_write_tokens += msg.usage.cacheWrite || 0;
|
|
183
|
+
if (msg.usage.cost?.total != null) {
|
|
184
|
+
usage.cost_usd = (usage.cost_usd ?? 0) + msg.usage.cost.total;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
try {
|
|
191
|
+
await entry.session.prompt(message);
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
unsubscribe();
|
|
195
|
+
entry.inFlight = false;
|
|
196
|
+
entry.lastActivity = Date.now();
|
|
197
|
+
}
|
|
198
|
+
usage.duration_ms = Date.now() - startTime;
|
|
199
|
+
return { text: responseText, usage };
|
|
200
|
+
}
|
|
201
|
+
async abort(id) {
|
|
202
|
+
const entry = this.sessions.get(id);
|
|
203
|
+
if (!entry)
|
|
204
|
+
throw new Error(`Session ${id} not found`);
|
|
205
|
+
await entry.session.abort();
|
|
206
|
+
}
|
|
207
|
+
delete(id) {
|
|
208
|
+
const entry = this.sessions.get(id);
|
|
209
|
+
if (entry) {
|
|
210
|
+
entry.session.dispose();
|
|
211
|
+
this.sessions.delete(id);
|
|
212
|
+
console.log(`[sidecar] Session deleted: ${id}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
disposeAll() {
|
|
216
|
+
for (const [id, entry] of this.sessions) {
|
|
217
|
+
console.log(`[sidecar] Disposing session: ${id}`);
|
|
218
|
+
entry.session.dispose();
|
|
219
|
+
}
|
|
220
|
+
this.sessions.clear();
|
|
221
|
+
}
|
|
222
|
+
cleanupStale(maxAge) {
|
|
223
|
+
const now = Date.now();
|
|
224
|
+
for (const [id, entry] of this.sessions) {
|
|
225
|
+
if (entry.inFlight)
|
|
226
|
+
continue;
|
|
227
|
+
if (now - entry.lastActivity > maxAge) {
|
|
228
|
+
console.log(`[sidecar] Cleaning up stale session: ${id}`);
|
|
229
|
+
entry.session.dispose();
|
|
230
|
+
this.sessions.delete(id);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Discover models from an acpx agent by spawning `acpx --model __list__ <agent> exec x`.
|
|
237
|
+
* Blocks until the subprocess completes or times out (30s).
|
|
238
|
+
*/
|
|
239
|
+
function discoverAcpxModels(agent) {
|
|
240
|
+
return new Promise((resolve, reject) => {
|
|
241
|
+
const models = [];
|
|
242
|
+
let output = "";
|
|
243
|
+
let resolved = false;
|
|
244
|
+
const proc = spawn("acpx", ["--model", "__list__", agent, "exec", "x"], {
|
|
245
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
246
|
+
});
|
|
247
|
+
const timeout = setTimeout(() => {
|
|
248
|
+
if (!resolved) {
|
|
249
|
+
resolved = true;
|
|
250
|
+
console.warn(`[sidecar] acpx discovery for ${agent} timed out after 30s`);
|
|
251
|
+
proc.kill("SIGTERM");
|
|
252
|
+
resolve(models);
|
|
253
|
+
}
|
|
254
|
+
}, 30000);
|
|
255
|
+
proc.stderr.on("data", (chunk) => { output += chunk.toString(); });
|
|
256
|
+
proc.stdout.on("data", (chunk) => { output += chunk.toString(); });
|
|
257
|
+
proc.on("close", () => {
|
|
258
|
+
if (resolved)
|
|
259
|
+
return;
|
|
260
|
+
resolved = true;
|
|
261
|
+
clearTimeout(timeout);
|
|
262
|
+
// Parse "Available models: modelId[opts], modelId2[opts], ..."
|
|
263
|
+
const match = output.match(/Available models:\s*(.+)/);
|
|
264
|
+
if (match) {
|
|
265
|
+
const modelList = match[1].trim().replace(/\.$/, "");
|
|
266
|
+
// Bracket-aware split: commas inside [] are part of the model ID
|
|
267
|
+
const entries = [];
|
|
268
|
+
let current = "";
|
|
269
|
+
let depth = 0;
|
|
270
|
+
for (const ch of modelList) {
|
|
271
|
+
if (ch === "[")
|
|
272
|
+
depth++;
|
|
273
|
+
else if (ch === "]")
|
|
274
|
+
depth = Math.max(0, depth - 1);
|
|
275
|
+
if (ch === "," && depth === 0) {
|
|
276
|
+
entries.push(current.trim());
|
|
277
|
+
current = "";
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
current += ch;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (current.trim())
|
|
284
|
+
entries.push(current.trim());
|
|
285
|
+
for (const entry of entries) {
|
|
286
|
+
if (!entry)
|
|
287
|
+
continue;
|
|
288
|
+
const bracketIdx = entry.indexOf("[");
|
|
289
|
+
const baseName = bracketIdx >= 0 ? entry.substring(0, bracketIdx) : entry;
|
|
290
|
+
if (baseName) {
|
|
291
|
+
const name = baseName
|
|
292
|
+
.replace(/-/g, " ")
|
|
293
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
294
|
+
models.push({
|
|
295
|
+
id: `${agent}:${entry}`,
|
|
296
|
+
name: `${name} (${agent})`,
|
|
297
|
+
provider: `acpx-${agent}`,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
resolve(models);
|
|
303
|
+
});
|
|
304
|
+
proc.on("error", (err) => {
|
|
305
|
+
if (!resolved) {
|
|
306
|
+
resolved = true;
|
|
307
|
+
clearTimeout(timeout);
|
|
308
|
+
reject(err);
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function startWatchdog(healthUrl: string, onDead: () => void): () => void;
|
package/dist/watchdog.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const CHECK_INTERVAL = 10_000; // 10 seconds
|
|
2
|
+
const MAX_FAILURES = 3; // 30 seconds of failures before shutdown
|
|
3
|
+
export function startWatchdog(healthUrl, onDead) {
|
|
4
|
+
let consecutiveFailures = 0;
|
|
5
|
+
let stopped = false;
|
|
6
|
+
let dead = false;
|
|
7
|
+
let currentController;
|
|
8
|
+
let currentTimeout;
|
|
9
|
+
const intervalId = setInterval(async () => {
|
|
10
|
+
if (stopped)
|
|
11
|
+
return;
|
|
12
|
+
try {
|
|
13
|
+
currentController = new AbortController();
|
|
14
|
+
currentTimeout = setTimeout(() => currentController?.abort(), 5000);
|
|
15
|
+
const resp = await fetch(healthUrl, { signal: currentController.signal });
|
|
16
|
+
if (stopped)
|
|
17
|
+
return;
|
|
18
|
+
if (resp.ok) {
|
|
19
|
+
consecutiveFailures = 0;
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
console.debug("[watchdog] Health check returned status:", resp.status);
|
|
23
|
+
consecutiveFailures++;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
if (stopped)
|
|
28
|
+
return;
|
|
29
|
+
console.debug("[watchdog] Health check failed:", err);
|
|
30
|
+
consecutiveFailures++;
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
if (currentTimeout)
|
|
34
|
+
clearTimeout(currentTimeout);
|
|
35
|
+
currentTimeout = undefined;
|
|
36
|
+
currentController = undefined;
|
|
37
|
+
}
|
|
38
|
+
if (consecutiveFailures >= MAX_FAILURES && !dead) {
|
|
39
|
+
dead = true;
|
|
40
|
+
onDead();
|
|
41
|
+
}
|
|
42
|
+
}, CHECK_INTERVAL);
|
|
43
|
+
return () => {
|
|
44
|
+
stopped = true;
|
|
45
|
+
clearInterval(intervalId);
|
|
46
|
+
if (currentTimeout)
|
|
47
|
+
clearTimeout(currentTimeout);
|
|
48
|
+
currentController?.abort();
|
|
49
|
+
currentTimeout = undefined;
|
|
50
|
+
currentController = undefined;
|
|
51
|
+
};
|
|
52
|
+
}
|