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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-daemon",
3
- "version": "0.4.26",
3
+ "version": "0.4.28",
4
4
  "description": "Issue-driven AI development daemon. Spawns opencode subprocesses to resolve Gitea issues.",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
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 state = ?, opencode_session_id = ?, opencode_pid = ?, workdir = ?,
235
- started_at = ?, progress_comment_id = ?, reaction_comment_id = ?, current_prompt = ?,
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, Subprocess<"ignore", "pipe", "pipe">>();
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 opencodeSessionExists(this.cfg.opencode.dbPath, resumeSessionId))) {
979
- log.warn(`stale opencode session ${resumeSessionId} not found in db, starting fresh`);
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
- if (resumeSessionId) {
984
- args.push("--session", resumeSessionId);
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
- if (!model) delete childEnv.OPENCODE_MODEL;
1014
- delete childEnv.OPENCODE;
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 proc = spawn({
1022
- cmd: args,
1023
- cwd: workdir,
1024
- env: childEnv,
1025
- stdout: "pipe",
1026
- stderr: "pipe",
1027
- stdin: "ignore",
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=${proc.pid} but generation superseded — killing orphan for ${k}`);
1032
- try { this.killProcessTree(proc.pid); } catch { /* already dead */ }
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, proc);
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
- // Persist PID for crash recovery
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=${proc.pid} for ${k}`);
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 proc.exited;
1088
- const stderr = await stderrPromise;
1034
+ exitCode = await handle.exited;
1035
+ const stderr = await handle.stderrText;
1089
1036
 
1090
- // Detect preemption or force-stop: if our process is no longer in the map, another took over
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=${proc.pid} exited ${exitCode} for ${k}`);
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=${proc.pid} completed for ${k}`);
1107
- if (stderr) log.warn(`engine: pid=${proc.pid} stderr on exit 0: ${stderr.slice(0, 500)}`);
1108
- if (!opencodeSessionId) log.warn(`engine: pid=${proc.pid} produced NO sessionID (no stdout output)`);
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.checkSessionOutput(session.opencodeSessionId);
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
+ }