ework-daemon 0.4.27 → 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.27",
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/opencode.ts CHANGED
@@ -9,6 +9,7 @@ import type { IssueTracker, TrackerRef, TrackerEvent, TrackerComment, Issue, OpS
9
9
  import { formatKey, parseKey } from "./trackers/types";
10
10
  import type { RuntimeBackend, RuntimeHandle } from "./runtime/types";
11
11
  import { OpencodeBackend } from "./runtime/opencode-backend";
12
+ import { PiBackend } from "./runtime/pi-backend";
12
13
 
13
14
  // ─── Types ───
14
15
 
@@ -279,14 +280,18 @@ export function pickLastActive(sessions: OpSession[]): OpSession | undefined {
279
280
  // ─── Engine ───
280
281
 
281
282
  export interface EngineOptions {
282
- /** DB-allocated logical daemon id (from Store.registerDaemon). */
283
283
  daemonId: number;
284
- /** Workdir + session-resume strategy; defaults to RecloneStrategy. */
285
284
  takeover?: TakeoverStrategy;
286
- /** Runtime backend (opencode/pi); defaults to OpencodeBackend. */
287
285
  backend?: RuntimeBackend;
288
286
  }
289
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);
293
+ }
294
+
290
295
  export class Engine {
291
296
  private cfg: Config;
292
297
  private store: Store;
@@ -342,7 +347,7 @@ export class Engine {
342
347
  this.trackers = trackers;
343
348
  this.daemonId = opts.daemonId;
344
349
  this.takeover = opts.takeover ?? new RecloneStrategy(cfg);
345
- this.backend = opts.backend ?? new OpencodeBackend(cfg.opencode.binary, cfg.opencode.dbPath, cfg.childEnvDeny);
350
+ this.backend = opts.backend ?? createDefaultBackend(cfg);
346
351
  this.startGlobalObserver();
347
352
  void this.recover();
348
353
  }
@@ -978,7 +983,7 @@ export class Engine {
978
983
  await this.store.updateSession(session.id, { opencodeSessionId: undefined });
979
984
  }
980
985
 
981
- const model = msg.model || this.cfg.opencode.defaultModel;
986
+ const model = msg.model || (this.cfg.runtime === "pi" && this.cfg.pi ? this.cfg.pi.defaultModel : this.cfg.opencode.defaultModel);
982
987
  this.currentModel.set(k, model);
983
988
 
984
989
  if (msg.sourceCommentId) {
@@ -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
+ }