ework-daemon 0.4.26 → 0.4.28
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/package.json +1 -1
- package/src/config.ts +21 -1
- package/src/op.ts +20 -18
- package/src/opencode.ts +54 -113
- package/src/runtime/opencode-backend.ts +129 -0
- package/src/runtime/pi-backend.ts +167 -0
- package/src/runtime/types.ts +33 -0
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -28,12 +28,20 @@ export const configSchema = z.object({
|
|
|
28
28
|
}),
|
|
29
29
|
opencode: z.object({
|
|
30
30
|
binary: z.string().default("opencode"),
|
|
31
|
-
baseWorkdir: z.string()
|
|
31
|
+
baseWorkdir: z.string().default(
|
|
32
|
+
`${process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share")}/ework-aio/opencode-workdir`
|
|
33
|
+
),
|
|
32
34
|
dbPath: z.string().default(
|
|
33
35
|
`${process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share")}/opencode/opencode.db`
|
|
34
36
|
),
|
|
35
37
|
defaultModel: z.string().default(""),
|
|
36
38
|
}),
|
|
39
|
+
pi: z.object({
|
|
40
|
+
binary: z.string().default("pi"),
|
|
41
|
+
provider: z.string().default("bailian"),
|
|
42
|
+
defaultModel: z.string().default(""),
|
|
43
|
+
}).optional(),
|
|
44
|
+
runtime: z.enum(["opencode", "pi"]).default("opencode"),
|
|
37
45
|
work: z.object({
|
|
38
46
|
capacity: z.coerce.number().int().positive().default(4),
|
|
39
47
|
maxConcurrent: z.coerce.number().int().positive().default(4),
|
|
@@ -138,6 +146,12 @@ export function loadConfig(): Config {
|
|
|
138
146
|
dbPath: process.env.OPENCODE_DB_PATH ?? `${process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share")}/opencode/opencode.db`,
|
|
139
147
|
defaultModel: process.env.WORK_DEFAULT_MODEL ?? TEST_DEFAULTS.opencode.defaultModel,
|
|
140
148
|
},
|
|
149
|
+
pi: {
|
|
150
|
+
binary: process.env.WORK_PI_BINARY ?? "pi",
|
|
151
|
+
provider: process.env.WORK_PI_PROVIDER ?? "bailian",
|
|
152
|
+
defaultModel: process.env.WORK_PI_DEFAULT_MODEL ?? process.env.WORK_DEFAULT_MODEL ?? "",
|
|
153
|
+
},
|
|
154
|
+
runtime: (process.env.WORK_RUNTIME ?? "opencode").trim().toLowerCase() as "opencode" | "pi",
|
|
141
155
|
work: readWorkSection(),
|
|
142
156
|
db: readDbSection(TEST_DEFAULTS.db.path),
|
|
143
157
|
completionCheck: process.env.COMPLETION_CHECK_API_KEY ? {
|
|
@@ -175,6 +189,12 @@ export function loadConfig(): Config {
|
|
|
175
189
|
dbPath: process.env.OPENCODE_DB_PATH ?? `${process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share")}/opencode/opencode.db`,
|
|
176
190
|
defaultModel: process.env.WORK_DEFAULT_MODEL ?? "",
|
|
177
191
|
},
|
|
192
|
+
pi: {
|
|
193
|
+
binary: process.env.WORK_PI_BINARY ?? "pi",
|
|
194
|
+
provider: process.env.WORK_PI_PROVIDER ?? "bailian",
|
|
195
|
+
defaultModel: process.env.WORK_PI_DEFAULT_MODEL ?? process.env.WORK_DEFAULT_MODEL ?? "",
|
|
196
|
+
},
|
|
197
|
+
runtime: (process.env.WORK_RUNTIME ?? "opencode").trim().toLowerCase() as "opencode" | "pi",
|
|
178
198
|
work: readWorkSection(),
|
|
179
199
|
db: readDbSection(PRODUCTION_DB_DEFAULT),
|
|
180
200
|
completionCheck: process.env.COMPLETION_CHECK_API_KEY ? {
|
package/src/op.ts
CHANGED
|
@@ -230,25 +230,27 @@ export class Store {
|
|
|
230
230
|
const existing = rowToSession(row);
|
|
231
231
|
const updated = { ...existing, ...patch };
|
|
232
232
|
|
|
233
|
+
const sets: string[] = [];
|
|
234
|
+
const vals: (string | number | null)[] = [];
|
|
235
|
+
if (updated.state !== undefined) { sets.push("state = ?"); vals.push(updated.state); }
|
|
236
|
+
if (updated.opencodeSessionId !== undefined) { sets.push("opencode_session_id = ?"); vals.push(updated.opencodeSessionId ?? null); }
|
|
237
|
+
if (updated.opencodePid !== undefined) { sets.push("opencode_pid = ?"); vals.push(updated.opencodePid ?? null); }
|
|
238
|
+
if (updated.workdir !== undefined) { sets.push("workdir = ?"); vals.push(updated.workdir ?? null); }
|
|
239
|
+
if (updated.startedAt !== undefined) { sets.push("started_at = ?"); vals.push(updated.startedAt ?? null); }
|
|
240
|
+
if (updated.progressCommentId !== undefined) { sets.push("progress_comment_id = ?"); vals.push(updated.progressCommentId ?? null); }
|
|
241
|
+
if (updated.reactionCommentId !== undefined) { sets.push("reaction_comment_id = ?"); vals.push(updated.reactionCommentId ?? null); }
|
|
242
|
+
if (updated.currentPrompt !== undefined) { sets.push("current_prompt = ?"); vals.push(updated.currentPrompt ?? null); }
|
|
243
|
+
if (updated.lastOutputAt !== undefined) { sets.push("last_output_at = ?"); vals.push(updated.lastOutputAt != null ? new Date(updated.lastOutputAt).toISOString() : null); }
|
|
244
|
+
if (updated.nudgeRounds !== undefined) { sets.push("nudge_rounds = ?"); vals.push(updated.nudgeRounds); }
|
|
245
|
+
if (updated.stuckNudgeRounds !== undefined) { sets.push("stuck_nudge_rounds = ?"); vals.push(updated.stuckNudgeRounds); }
|
|
246
|
+
if (updated.generation !== undefined) { sets.push("generation = ?"); vals.push(updated.generation); }
|
|
247
|
+
|
|
248
|
+
if (sets.length === 0) return updated;
|
|
249
|
+
|
|
250
|
+
vals.push(id);
|
|
233
251
|
await getDB().run(
|
|
234
|
-
`UPDATE {{op_sessions}} SET
|
|
235
|
-
|
|
236
|
-
last_output_at = ?, nudge_rounds = ?, stuck_nudge_rounds = ?, generation = ? WHERE uid = ?`,
|
|
237
|
-
[
|
|
238
|
-
updated.state,
|
|
239
|
-
updated.opencodeSessionId ?? null,
|
|
240
|
-
updated.opencodePid ?? null,
|
|
241
|
-
updated.workdir ?? null,
|
|
242
|
-
updated.startedAt ?? null,
|
|
243
|
-
updated.progressCommentId ?? null,
|
|
244
|
-
updated.reactionCommentId ?? null,
|
|
245
|
-
updated.currentPrompt ?? null,
|
|
246
|
-
updated.lastOutputAt != null ? new Date(updated.lastOutputAt).toISOString() : null,
|
|
247
|
-
updated.nudgeRounds ?? 0,
|
|
248
|
-
updated.stuckNudgeRounds ?? 0,
|
|
249
|
-
updated.generation ?? 0,
|
|
250
|
-
id,
|
|
251
|
-
]
|
|
252
|
+
`UPDATE {{op_sessions}} SET ${sets.join(", ")} WHERE uid = ?`,
|
|
253
|
+
vals
|
|
252
254
|
);
|
|
253
255
|
return updated;
|
|
254
256
|
}
|
package/src/opencode.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { spawn, type Subprocess } from "bun";
|
|
2
1
|
import { Database } from "bun:sqlite";
|
|
3
2
|
import { mkdirSync, writeFileSync, readdirSync, existsSync } from "fs";
|
|
4
3
|
import { join, resolve, isAbsolute } from "path";
|
|
@@ -8,6 +7,9 @@ import type { Config } from "./config";
|
|
|
8
7
|
import type { Store } from "./op";
|
|
9
8
|
import type { IssueTracker, TrackerRef, TrackerEvent, TrackerComment, Issue, OpSession, Message } from "./trackers/types";
|
|
10
9
|
import { formatKey, parseKey } from "./trackers/types";
|
|
10
|
+
import type { RuntimeBackend, RuntimeHandle } from "./runtime/types";
|
|
11
|
+
import { OpencodeBackend } from "./runtime/opencode-backend";
|
|
12
|
+
import { PiBackend } from "./runtime/pi-backend";
|
|
11
13
|
|
|
12
14
|
// ─── Types ───
|
|
13
15
|
|
|
@@ -278,10 +280,16 @@ export function pickLastActive(sessions: OpSession[]): OpSession | undefined {
|
|
|
278
280
|
// ─── Engine ───
|
|
279
281
|
|
|
280
282
|
export interface EngineOptions {
|
|
281
|
-
/** DB-allocated logical daemon id (from Store.registerDaemon). */
|
|
282
283
|
daemonId: number;
|
|
283
|
-
/** Workdir + session-resume strategy; defaults to RecloneStrategy. */
|
|
284
284
|
takeover?: TakeoverStrategy;
|
|
285
|
+
backend?: RuntimeBackend;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function createDefaultBackend(cfg: Config): RuntimeBackend {
|
|
289
|
+
if (cfg.runtime === "pi" && cfg.pi) {
|
|
290
|
+
return new PiBackend(cfg.pi.binary, cfg.pi.provider, cfg.pi.defaultModel, cfg.childEnvDeny);
|
|
291
|
+
}
|
|
292
|
+
return new OpencodeBackend(cfg.opencode.binary, cfg.opencode.dbPath, cfg.childEnvDeny);
|
|
285
293
|
}
|
|
286
294
|
|
|
287
295
|
export class Engine {
|
|
@@ -290,10 +298,11 @@ export class Engine {
|
|
|
290
298
|
private trackers: TrackerRegistry;
|
|
291
299
|
private readonly daemonId: number;
|
|
292
300
|
private readonly takeover: TakeoverStrategy;
|
|
301
|
+
private readonly backend: RuntimeBackend;
|
|
293
302
|
private heartbeatTimer?: ReturnType<typeof setInterval>;
|
|
294
303
|
|
|
295
304
|
// Runtime state keyed by session key (trackerType:scopeKey#issueId@sessionName)
|
|
296
|
-
private processes = new Map<string,
|
|
305
|
+
private processes = new Map<string, RuntimeHandle>();
|
|
297
306
|
private running = new Set<string>();
|
|
298
307
|
private stopping = new Set<string>();
|
|
299
308
|
private processingComments = new Set<string>();
|
|
@@ -338,6 +347,7 @@ export class Engine {
|
|
|
338
347
|
this.trackers = trackers;
|
|
339
348
|
this.daemonId = opts.daemonId;
|
|
340
349
|
this.takeover = opts.takeover ?? new RecloneStrategy(cfg);
|
|
350
|
+
this.backend = opts.backend ?? createDefaultBackend(cfg);
|
|
341
351
|
this.startGlobalObserver();
|
|
342
352
|
void this.recover();
|
|
343
353
|
}
|
|
@@ -545,10 +555,6 @@ export class Engine {
|
|
|
545
555
|
return [...comments].reverse().find(c => tracker.isBotUser(c.author) && !this.isSystemComment(c));
|
|
546
556
|
}
|
|
547
557
|
|
|
548
|
-
private async checkSessionOutput(opencodeSessionId: string | undefined): Promise<{ hasOutput: boolean; tokenCount: number }> {
|
|
549
|
-
return checkSessionOutput(this.cfg.opencode.dbPath, opencodeSessionId);
|
|
550
|
-
}
|
|
551
|
-
|
|
552
558
|
// ─── Event Dispatch ───
|
|
553
559
|
|
|
554
560
|
async handleEvent(event: TrackerEvent, groupConfig?: GroupConfig) {
|
|
@@ -966,129 +972,69 @@ export class Engine {
|
|
|
966
972
|
const ref = this.sessionToRef(session, issue);
|
|
967
973
|
const tracker = this.getTracker(issue.trackerType);
|
|
968
974
|
|
|
969
|
-
const args = [this.cfg.opencode.binary, "run", "--format", "json", "--dir", workdir];
|
|
970
|
-
// Prefer the captured session id (resume our own previous run). Otherwise
|
|
971
|
-
// ask the takeover strategy whether a resumable session exists elsewhere
|
|
972
|
-
// (e.g. NAS-backed). Default strategy returns null → fresh session.
|
|
973
975
|
let resumeSessionId = session.opencodeSessionId;
|
|
974
976
|
if (!resumeSessionId) {
|
|
975
977
|
const fromStrategy = await this.takeover.resumeOpenCodeSession(session);
|
|
976
978
|
if (fromStrategy) resumeSessionId = fromStrategy;
|
|
977
979
|
}
|
|
978
|
-
if (resumeSessionId && !(await
|
|
979
|
-
log.warn(`stale
|
|
980
|
+
if (resumeSessionId && !(await this.backend.sessionExists(resumeSessionId))) {
|
|
981
|
+
log.warn(`stale session ${resumeSessionId} not found in db, starting fresh`);
|
|
980
982
|
resumeSessionId = undefined;
|
|
981
983
|
await this.store.updateSession(session.id, { opencodeSessionId: undefined });
|
|
982
984
|
}
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
}
|
|
986
|
-
// Resolve the model: explicit per-message > daemon default > omit.
|
|
987
|
-
// Without this, opencode's own default (influenced by plugin agent
|
|
988
|
-
// presets) can pick a non-existent model, causing ProviderModelNotFoundError.
|
|
989
|
-
const model = msg.model || this.cfg.opencode.defaultModel;
|
|
985
|
+
|
|
986
|
+
const model = msg.model || (this.cfg.runtime === "pi" && this.cfg.pi ? this.cfg.pi.defaultModel : this.cfg.opencode.defaultModel);
|
|
990
987
|
this.currentModel.set(k, model);
|
|
991
|
-
if (model) {
|
|
992
|
-
args.push("--model", model);
|
|
993
|
-
}
|
|
994
|
-
args.push(msg.content);
|
|
995
988
|
|
|
996
|
-
// Set eyes reaction on the source comment
|
|
997
989
|
if (msg.sourceCommentId) {
|
|
998
|
-
try {
|
|
999
|
-
await tracker.setReaction(ref, msg.sourceCommentId, "eyes");
|
|
1000
|
-
} catch { /* non-critical */ }
|
|
990
|
+
try { await tracker.setReaction(ref, msg.sourceCommentId, "eyes"); } catch { /* non-critical */ }
|
|
1001
991
|
}
|
|
1002
992
|
|
|
1003
|
-
let exitCode: number | null = null;
|
|
1004
|
-
|
|
1005
|
-
// M-1: build the child env explicitly. When a model IS resolved we push
|
|
1006
|
-
// --model above, so opencode ignores OPENCODE_MODEL anyway. When NO model
|
|
1007
|
-
// is resolved we must not let a leaked OPENCODE_MODEL from our own env
|
|
1008
|
-
// silently win — strip it so opencode falls back to its opencode.json
|
|
1009
|
-
// (the operator's explicit config) instead of arbitrary env pollution.
|
|
1010
|
-
// Provider keys / Gitea vars are preserved (opencode + its plugin need
|
|
1011
|
-
// them); only the explicit model-override var is neutralized.
|
|
1012
993
|
const childEnv = { ...process.env, ...this.hookEnvFor(issue, session, workdir) };
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
delete childEnv.OPENCODE_PID;
|
|
1016
|
-
delete childEnv.OPENCODE_RUN_ID;
|
|
1017
|
-
delete childEnv.OPENCODE_PROCESS_ROLE;
|
|
1018
|
-
for (const k of this.cfg.childEnvDeny) delete childEnv[k];
|
|
994
|
+
|
|
995
|
+
let exitCode: number | null = null;
|
|
1019
996
|
|
|
1020
997
|
try {
|
|
1021
|
-
const
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
998
|
+
const handle = await this.backend.spawn(
|
|
999
|
+
{
|
|
1000
|
+
workdir,
|
|
1001
|
+
prompt: msg.content,
|
|
1002
|
+
model: model || undefined,
|
|
1003
|
+
resumeSessionId: resumeSessionId || undefined,
|
|
1004
|
+
env: childEnv,
|
|
1005
|
+
},
|
|
1006
|
+
{
|
|
1007
|
+
onOutput: () => { this.lastOutputAt.set(k, Date.now()); },
|
|
1008
|
+
onSessionId: async (id: string) => {
|
|
1009
|
+
if (!session.opencodeSessionId) {
|
|
1010
|
+
await this.store.updateSession(session.id, { opencodeSessionId: id });
|
|
1011
|
+
session.opencodeSessionId = id;
|
|
1012
|
+
log.info(`engine: captured sessionID=${id.slice(0, 8)} for ${k} (early persist)`);
|
|
1013
|
+
}
|
|
1014
|
+
},
|
|
1015
|
+
},
|
|
1016
|
+
);
|
|
1029
1017
|
|
|
1030
1018
|
if (this.generation.get(k) !== gen) {
|
|
1031
|
-
log.warn(`engine: spawned pid=${
|
|
1032
|
-
try { this.killProcessTree(
|
|
1019
|
+
log.warn(`engine: spawned pid=${handle.pid} but generation superseded — killing orphan for ${k}`);
|
|
1020
|
+
try { this.killProcessTree(handle.pid); } catch { /* already dead */ }
|
|
1033
1021
|
return;
|
|
1034
1022
|
}
|
|
1035
1023
|
|
|
1036
|
-
this.processes.set(k,
|
|
1024
|
+
this.processes.set(k, handle);
|
|
1037
1025
|
this.lastOutputAt.set(k, Date.now());
|
|
1038
|
-
if (!this.startedAt.has(k))
|
|
1039
|
-
this.startedAt.set(k, Date.now());
|
|
1040
|
-
}
|
|
1026
|
+
if (!this.startedAt.has(k)) this.startedAt.set(k, Date.now());
|
|
1041
1027
|
this.currentPrompt.set(k, msg.content);
|
|
1042
1028
|
|
|
1043
|
-
|
|
1044
|
-
await this.store.updateSession(session.id, { opencodePid: proc.pid });
|
|
1029
|
+
await this.store.updateSession(session.id, { opencodePid: handle.pid });
|
|
1045
1030
|
await this.persistRuntimeState(session.id);
|
|
1046
1031
|
|
|
1047
|
-
log.info(`engine: spawned pid=${
|
|
1048
|
-
|
|
1049
|
-
// Read stdout to capture session ID
|
|
1050
|
-
let opencodeSessionId: string | null = null;
|
|
1051
|
-
const stderrPromise = new Response(proc.stderr).text();
|
|
1052
|
-
const reader = proc.stdout.getReader();
|
|
1053
|
-
const decoder = new TextDecoder();
|
|
1054
|
-
let lineBuf = "";
|
|
1055
|
-
|
|
1056
|
-
while (true) {
|
|
1057
|
-
const { done, value } = await reader.read();
|
|
1058
|
-
if (done) break;
|
|
1059
|
-
|
|
1060
|
-
this.lastOutputAt.set(k, Date.now());
|
|
1061
|
-
|
|
1062
|
-
if (!opencodeSessionId) {
|
|
1063
|
-
lineBuf += decoder.decode(value, { stream: true });
|
|
1064
|
-
const lines = lineBuf.split("\n");
|
|
1065
|
-
lineBuf = lines.pop()!;
|
|
1066
|
-
for (const line of lines) {
|
|
1067
|
-
if (!line.trim()) continue;
|
|
1068
|
-
try {
|
|
1069
|
-
const ev = JSON.parse(line);
|
|
1070
|
-
if (ev.sessionID) {
|
|
1071
|
-
const sid: string = ev.sessionID;
|
|
1072
|
-
opencodeSessionId = sid;
|
|
1073
|
-
// Persist now, not at exit: a preempt/crash before exit must
|
|
1074
|
-
// not lose the ID, otherwise the re-run opens a fresh session.
|
|
1075
|
-
if (!session.opencodeSessionId) {
|
|
1076
|
-
await this.store.updateSession(session.id, { opencodeSessionId: sid });
|
|
1077
|
-
session.opencodeSessionId = sid;
|
|
1078
|
-
log.info(`engine: captured sessionID=${sid.slice(0, 8)} for ${k} (early persist)`);
|
|
1079
|
-
}
|
|
1080
|
-
break;
|
|
1081
|
-
}
|
|
1082
|
-
} catch { /* not json */ }
|
|
1083
|
-
}
|
|
1084
|
-
}
|
|
1085
|
-
}
|
|
1032
|
+
log.info(`engine: spawned pid=${handle.pid} for ${k} (backend=${this.backend.name})`);
|
|
1086
1033
|
|
|
1087
|
-
exitCode = await
|
|
1088
|
-
const stderr = await
|
|
1034
|
+
exitCode = await handle.exited;
|
|
1035
|
+
const stderr = await handle.stderrText;
|
|
1089
1036
|
|
|
1090
|
-
|
|
1091
|
-
if (this.processes.get(k) !== proc) {
|
|
1037
|
+
if (this.processes.get(k) !== handle) {
|
|
1092
1038
|
log.info(`engine: process replaced, skipping finishRun for ${k}`);
|
|
1093
1039
|
this.stopping.delete(k);
|
|
1094
1040
|
return;
|
|
@@ -1099,20 +1045,15 @@ export class Engine {
|
|
|
1099
1045
|
await this.store.updateSession(session.id, { opencodePid: undefined });
|
|
1100
1046
|
|
|
1101
1047
|
if (exitCode !== 0) {
|
|
1102
|
-
log.error(`engine: pid=${
|
|
1048
|
+
log.error(`engine: pid=${handle.pid} exited ${exitCode} for ${k}`);
|
|
1103
1049
|
log.error(` stderr: ${stderr.slice(0, 2000)}`);
|
|
1104
1050
|
await this.store.updateMessageStatus(msg.id, "failed", `exit ${exitCode}: ${stderr.slice(0, 500)}`);
|
|
1105
1051
|
} else {
|
|
1106
|
-
log.info(`engine: pid=${
|
|
1107
|
-
if (stderr) log.warn(`engine: pid=${
|
|
1108
|
-
if (!opencodeSessionId) log.warn(`engine: pid=${
|
|
1052
|
+
log.info(`engine: pid=${handle.pid} completed for ${k}`);
|
|
1053
|
+
if (stderr) log.warn(`engine: pid=${handle.pid} stderr on exit 0: ${stderr.slice(0, 500)}`);
|
|
1054
|
+
if (!session.opencodeSessionId) log.warn(`engine: pid=${handle.pid} produced NO sessionID (no stdout output)`);
|
|
1109
1055
|
await this.store.updateMessageStatus(msg.id, "done");
|
|
1110
1056
|
}
|
|
1111
|
-
|
|
1112
|
-
// Save opencode session ID for continuity
|
|
1113
|
-
if (opencodeSessionId && !session.opencodeSessionId) {
|
|
1114
|
-
await this.store.updateSession(session.id, { opencodeSessionId });
|
|
1115
|
-
}
|
|
1116
1057
|
} catch (err) {
|
|
1117
1058
|
log.error(`engine: exec failed for ${k}:`, err);
|
|
1118
1059
|
await this.store.updateMessageStatus(msg.id, "failed", (err as Error).message);
|
|
@@ -1198,7 +1139,7 @@ export class Engine {
|
|
|
1198
1139
|
await this.persistRuntimeState(session.id);
|
|
1199
1140
|
void tracker.updateStatus(ref, "");
|
|
1200
1141
|
} else {
|
|
1201
|
-
const sessionOutput = await this.
|
|
1142
|
+
const sessionOutput = await this.backend.getSessionOutputTokens(session.opencodeSessionId);
|
|
1202
1143
|
const emptyRound = this.emptyResponseRounds.get(k) ?? 0;
|
|
1203
1144
|
|
|
1204
1145
|
if (!sessionOutput.hasOutput && emptyRound < Engine.MAX_EMPTY_RESPONSE_ROUNDS) {
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { spawn } from "bun";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { log } from "../logger";
|
|
4
|
+
import type {
|
|
5
|
+
RuntimeBackend,
|
|
6
|
+
RuntimeSpawnOpts,
|
|
7
|
+
RuntimeSpawnCallbacks,
|
|
8
|
+
RuntimeHandle,
|
|
9
|
+
SessionOutputResult,
|
|
10
|
+
} from "./types";
|
|
11
|
+
|
|
12
|
+
const ENV_DENY_ALWAYS = ["OPENCODE", "OPENCODE_PID", "OPENCODE_RUN_ID", "OPENCODE_PROCESS_ROLE"] as const;
|
|
13
|
+
|
|
14
|
+
export class OpencodeBackend implements RuntimeBackend {
|
|
15
|
+
readonly name = "opencode";
|
|
16
|
+
|
|
17
|
+
constructor(
|
|
18
|
+
private binary: string,
|
|
19
|
+
private dbPath: string,
|
|
20
|
+
private childEnvDeny: string[] = [],
|
|
21
|
+
) {}
|
|
22
|
+
|
|
23
|
+
async spawn(opts: RuntimeSpawnOpts, cb: RuntimeSpawnCallbacks): Promise<RuntimeHandle> {
|
|
24
|
+
const args = [this.binary, "run", "--format", "json", "--dir", opts.workdir];
|
|
25
|
+
|
|
26
|
+
if (opts.resumeSessionId) {
|
|
27
|
+
args.push("--session", opts.resumeSessionId);
|
|
28
|
+
}
|
|
29
|
+
if (opts.model) {
|
|
30
|
+
args.push("--model", opts.model);
|
|
31
|
+
}
|
|
32
|
+
args.push(opts.prompt);
|
|
33
|
+
|
|
34
|
+
const childEnv = { ...opts.env };
|
|
35
|
+
if (!opts.model) delete childEnv.OPENCODE_MODEL;
|
|
36
|
+
for (const key of ENV_DENY_ALWAYS) delete childEnv[key];
|
|
37
|
+
for (const key of this.childEnvDeny) delete childEnv[key];
|
|
38
|
+
|
|
39
|
+
const proc = spawn({
|
|
40
|
+
cmd: args,
|
|
41
|
+
cwd: opts.workdir,
|
|
42
|
+
env: childEnv,
|
|
43
|
+
stdout: "pipe",
|
|
44
|
+
stderr: "pipe",
|
|
45
|
+
stdin: "ignore",
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const stderrText = new Response(proc.stderr).text();
|
|
49
|
+
|
|
50
|
+
void this.readStdout(proc, cb);
|
|
51
|
+
|
|
52
|
+
return { pid: proc.pid, exited: proc.exited, stderrText };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private async readStdout(
|
|
56
|
+
proc: ReturnType<typeof spawn>,
|
|
57
|
+
cb: RuntimeSpawnCallbacks,
|
|
58
|
+
): Promise<void> {
|
|
59
|
+
let captured = false;
|
|
60
|
+
const stdout = proc.stdout;
|
|
61
|
+
if (!stdout || typeof stdout === "number") return;
|
|
62
|
+
const reader = stdout.getReader();
|
|
63
|
+
const decoder = new TextDecoder();
|
|
64
|
+
let lineBuf = "";
|
|
65
|
+
|
|
66
|
+
while (true) {
|
|
67
|
+
const { done, value } = await reader.read();
|
|
68
|
+
if (done) break;
|
|
69
|
+
|
|
70
|
+
cb.onOutput();
|
|
71
|
+
|
|
72
|
+
if (!captured) {
|
|
73
|
+
lineBuf += decoder.decode(value, { stream: true });
|
|
74
|
+
const lines = lineBuf.split("\n");
|
|
75
|
+
lineBuf = lines.pop()!;
|
|
76
|
+
for (const line of lines) {
|
|
77
|
+
if (!line.trim()) continue;
|
|
78
|
+
try {
|
|
79
|
+
const ev = JSON.parse(line);
|
|
80
|
+
if (ev.sessionID) {
|
|
81
|
+
await cb.onSessionId(ev.sessionID as string);
|
|
82
|
+
captured = true;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
} catch { /* not json */ }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async sessionExists(sessionId: string): Promise<boolean> {
|
|
92
|
+
let db: Database;
|
|
93
|
+
try {
|
|
94
|
+
db = new Database(this.dbPath, { readonly: true });
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const row = db.prepare("SELECT 1 FROM session WHERE id = ? LIMIT 1").get(sessionId);
|
|
100
|
+
return !!row;
|
|
101
|
+
} catch {
|
|
102
|
+
return false;
|
|
103
|
+
} finally {
|
|
104
|
+
db.close();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async getSessionOutputTokens(sessionId: string | undefined): Promise<SessionOutputResult> {
|
|
109
|
+
if (!sessionId) return { hasOutput: true, tokenCount: 0 };
|
|
110
|
+
let db: Database;
|
|
111
|
+
try {
|
|
112
|
+
db = new Database(this.dbPath, { readonly: true });
|
|
113
|
+
} catch {
|
|
114
|
+
return { hasOutput: true, tokenCount: 0 };
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
const row = db.prepare(
|
|
118
|
+
"SELECT COUNT(*) AS n, COALESCE(SUM(CAST(json_extract(data,'$.tokens.output') AS INT)), 0) AS tokens " +
|
|
119
|
+
"FROM message WHERE session_id = ? AND json_extract(data,'$.role') = 'assistant'"
|
|
120
|
+
).get(sessionId) as { n: number; tokens: number } | null;
|
|
121
|
+
if (!row) return { hasOutput: true, tokenCount: 0 };
|
|
122
|
+
return { hasOutput: row.n > 0 && row.tokens > 0, tokenCount: row.tokens };
|
|
123
|
+
} catch {
|
|
124
|
+
return { hasOutput: true, tokenCount: 0 };
|
|
125
|
+
} finally {
|
|
126
|
+
db.close();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { spawn } from "bun";
|
|
2
|
+
import { readdirSync, existsSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { log } from "../logger";
|
|
5
|
+
import type {
|
|
6
|
+
RuntimeBackend,
|
|
7
|
+
RuntimeSpawnOpts,
|
|
8
|
+
RuntimeSpawnCallbacks,
|
|
9
|
+
RuntimeHandle,
|
|
10
|
+
SessionOutputResult,
|
|
11
|
+
} from "./types";
|
|
12
|
+
|
|
13
|
+
export class PiBackend implements RuntimeBackend {
|
|
14
|
+
readonly name = "pi";
|
|
15
|
+
|
|
16
|
+
constructor(
|
|
17
|
+
private binary: string,
|
|
18
|
+
private provider: string,
|
|
19
|
+
private defaultModel: string | undefined,
|
|
20
|
+
private childEnvDeny: string[] = [],
|
|
21
|
+
) {}
|
|
22
|
+
|
|
23
|
+
async spawn(opts: RuntimeSpawnOpts, cb: RuntimeSpawnCallbacks): Promise<RuntimeHandle> {
|
|
24
|
+
const args: string[] = [this.binary, "--mode", "json", "--print"];
|
|
25
|
+
|
|
26
|
+
const model = opts.model || this.defaultModel;
|
|
27
|
+
if (model) {
|
|
28
|
+
if (model.includes("/")) {
|
|
29
|
+
const slashIdx = model.indexOf("/");
|
|
30
|
+
const provider = model.slice(0, slashIdx);
|
|
31
|
+
const modelId = model.slice(slashIdx + 1);
|
|
32
|
+
args.push("--provider", provider, "--model", modelId);
|
|
33
|
+
} else {
|
|
34
|
+
args.push("--provider", this.provider, "--model", model);
|
|
35
|
+
}
|
|
36
|
+
} else {
|
|
37
|
+
args.push("--provider", this.provider);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (opts.resumeSessionId) {
|
|
41
|
+
args.push("--session-id", opts.resumeSessionId);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
args.push(opts.prompt);
|
|
45
|
+
|
|
46
|
+
const childEnv = { ...opts.env };
|
|
47
|
+
for (const key of this.childEnvDeny) delete childEnv[key];
|
|
48
|
+
|
|
49
|
+
const proc = spawn({
|
|
50
|
+
cmd: args,
|
|
51
|
+
cwd: opts.workdir,
|
|
52
|
+
env: childEnv,
|
|
53
|
+
stdout: "pipe",
|
|
54
|
+
stderr: "pipe",
|
|
55
|
+
stdin: "ignore",
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const stderrText = new Response(proc.stderr).text();
|
|
59
|
+
|
|
60
|
+
void this.readStdout(proc, cb);
|
|
61
|
+
|
|
62
|
+
return { pid: proc.pid, exited: proc.exited, stderrText };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private async readStdout(
|
|
66
|
+
proc: ReturnType<typeof spawn>,
|
|
67
|
+
cb: RuntimeSpawnCallbacks,
|
|
68
|
+
): Promise<void> {
|
|
69
|
+
let captured = false;
|
|
70
|
+
const stdout = proc.stdout;
|
|
71
|
+
if (!stdout || typeof stdout === "number") return;
|
|
72
|
+
const reader = stdout.getReader();
|
|
73
|
+
const decoder = new TextDecoder();
|
|
74
|
+
let lineBuf = "";
|
|
75
|
+
|
|
76
|
+
while (true) {
|
|
77
|
+
const { done, value } = await reader.read();
|
|
78
|
+
if (done) break;
|
|
79
|
+
|
|
80
|
+
cb.onOutput();
|
|
81
|
+
|
|
82
|
+
if (!captured) {
|
|
83
|
+
lineBuf += decoder.decode(value, { stream: true });
|
|
84
|
+
const lines = lineBuf.split("\n");
|
|
85
|
+
lineBuf = lines.pop()!;
|
|
86
|
+
for (const line of lines) {
|
|
87
|
+
if (!line.trim()) continue;
|
|
88
|
+
try {
|
|
89
|
+
const ev = JSON.parse(line);
|
|
90
|
+
if (ev.type === "session" && ev.id) {
|
|
91
|
+
await cb.onSessionId(ev.id as string);
|
|
92
|
+
captured = true;
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
} catch { /* not json */ }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async sessionExists(sessionId: string): Promise<boolean> {
|
|
102
|
+
const sessionDir = this.resolveSessionDir();
|
|
103
|
+
if (!existsSync(sessionDir)) return false;
|
|
104
|
+
try {
|
|
105
|
+
const files = readdirSync(sessionDir);
|
|
106
|
+
return files.some((f) => f.includes(sessionId) && f.endsWith(".jsonl"));
|
|
107
|
+
} catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async getSessionOutputTokens(sessionId: string | undefined): Promise<SessionOutputResult> {
|
|
113
|
+
if (!sessionId) return { hasOutput: true, tokenCount: 0 };
|
|
114
|
+
|
|
115
|
+
const filePath = this.findSessionFile(sessionId);
|
|
116
|
+
if (!filePath) return { hasOutput: true, tokenCount: 0 };
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const { readFileSync } = await import("fs");
|
|
120
|
+
const content = readFileSync(filePath, "utf-8");
|
|
121
|
+
const lines = content.split("\n").filter((l) => l.trim());
|
|
122
|
+
let totalTokens = 0;
|
|
123
|
+
let hasAssistant = false;
|
|
124
|
+
|
|
125
|
+
for (const line of lines) {
|
|
126
|
+
try {
|
|
127
|
+
const ev = JSON.parse(line);
|
|
128
|
+
if (ev.type === "message" && ev.message?.role === "assistant") {
|
|
129
|
+
hasAssistant = true;
|
|
130
|
+
const output = ev.message?.usage?.output ?? 0;
|
|
131
|
+
totalTokens += output;
|
|
132
|
+
}
|
|
133
|
+
} catch { /* skip */ }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { hasOutput: hasAssistant && totalTokens > 0, tokenCount: totalTokens };
|
|
137
|
+
} catch {
|
|
138
|
+
return { hasOutput: true, tokenCount: 0 };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private resolveSessionDir(): string {
|
|
143
|
+
const envDir = process.env.PI_CODING_AGENT_SESSION_DIR;
|
|
144
|
+
if (envDir) return envDir;
|
|
145
|
+
const agentDir = process.env.PI_CODING_AGENT_DIR || join(process.env.HOME || "~", ".pi", "agent");
|
|
146
|
+
return join(agentDir, "sessions");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private findSessionFile(sessionId: string): string | null {
|
|
150
|
+
const sessionDir = this.resolveSessionDir();
|
|
151
|
+
if (!existsSync(sessionDir)) return null;
|
|
152
|
+
try {
|
|
153
|
+
const subdirs = readdirSync(sessionDir, { withFileTypes: true });
|
|
154
|
+
for (const dir of subdirs) {
|
|
155
|
+
if (!dir.isDirectory()) continue;
|
|
156
|
+
const dirPath = join(sessionDir, dir.name);
|
|
157
|
+
const files = readdirSync(dirPath);
|
|
158
|
+
for (const f of files) {
|
|
159
|
+
if (f.includes(sessionId) && f.endsWith(".jsonl")) {
|
|
160
|
+
return join(dirPath, f);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} catch { /* not found */ }
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface RuntimeSpawnOpts {
|
|
2
|
+
workdir: string;
|
|
3
|
+
prompt: string;
|
|
4
|
+
model?: string;
|
|
5
|
+
resumeSessionId?: string;
|
|
6
|
+
env: Record<string, string | undefined>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface RuntimeSpawnCallbacks {
|
|
10
|
+
onOutput: () => void;
|
|
11
|
+
onSessionId: (id: string) => Promise<void> | void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface RuntimeHandle {
|
|
15
|
+
pid: number;
|
|
16
|
+
exited: Promise<number>;
|
|
17
|
+
stderrText: Promise<string>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SessionOutputResult {
|
|
21
|
+
hasOutput: boolean;
|
|
22
|
+
tokenCount: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RuntimeBackend {
|
|
26
|
+
readonly name: string;
|
|
27
|
+
|
|
28
|
+
spawn(opts: RuntimeSpawnOpts, callbacks: RuntimeSpawnCallbacks): Promise<RuntimeHandle>;
|
|
29
|
+
|
|
30
|
+
sessionExists(sessionId: string): Promise<boolean>;
|
|
31
|
+
|
|
32
|
+
getSessionOutputTokens(sessionId: string | undefined): Promise<SessionOutputResult>;
|
|
33
|
+
}
|