ework-daemon 0.4.27 โ†’ 0.4.29

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.29",
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/index.ts CHANGED
@@ -88,6 +88,13 @@ async function boot() {
88
88
  process.on("SIGTERM", () => shutdown("SIGTERM"));
89
89
  process.on("SIGINT", () => shutdown("SIGINT"));
90
90
 
91
+ process.on("unhandledRejection", (reason) => {
92
+ log.error("unhandledRejection (continuing):", reason);
93
+ });
94
+ process.on("uncaughtException", (err) => {
95
+ log.error("uncaughtException (continuing):", err);
96
+ });
97
+
91
98
  const activeCount = (await store.listActiveIssues()).length;
92
99
  log.info(`\n${isTest ? "๐Ÿงช" : "โœ…"} ework-daemon ready at http://${server.hostname}:${server.port}/webhook`);
93
100
  log.info(` Configure Gitea webhook to POST to /webhook/gitea`);
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
  }
@@ -557,8 +562,8 @@ export class Engine {
557
562
  const tracker = this.getTracker(ref.trackerType);
558
563
  const scopeKey = tracker.formatScopeKey(ref.scope);
559
564
 
560
- if (this.paused && event.type === "issue_opened") {
561
- log.info(`engine: paused โ€” skipping issue_opened for ${ref.trackerType}:${scopeKey}#${ref.issueId}`);
565
+ if (this.paused && (event.type === "issue_opened" || event.type === "comment_created")) {
566
+ log.info(`engine: paused โ€” skipping ${event.type} for ${ref.trackerType}:${scopeKey}#${ref.issueId}`);
562
567
  return;
563
568
  }
564
569
 
@@ -791,13 +796,11 @@ export class Engine {
791
796
 
792
797
  // Kill all running processes for this issue's sessions
793
798
  const sessions = await this.store.getSessionsForIssue(issue.id);
799
+ let killedCount = 0;
794
800
  for (const session of sessions) {
795
801
  const k = this.sessionKey(session, issue);
796
- const proc = this.processes.get(k);
797
- if (proc) {
798
- this.stopping.add(k);
799
- try { this.killProcessTree(proc.pid, "SIGTERM"); } catch { /* already dead */ }
800
- }
802
+ const killed = await this.killSessionProcess(session, k);
803
+ if (killed) killedCount++;
801
804
  this.clearRuntimeState(k);
802
805
  const msgs = await this.store.getMessagesForSession(session.id);
803
806
  for (const msg of msgs) {
@@ -824,7 +827,7 @@ export class Engine {
824
827
  this.cloneUrls.delete(gcKey);
825
828
  this.senders.delete(gcKey);
826
829
 
827
- log.info(`engine: issue closed, ${sessions.length} sessions paused for ${scopeKey}#${ref.issueId}`);
830
+ log.info(`engine: issue closed, ${killedCount}/${sessions.length} sessions killed for ${scopeKey}#${ref.issueId}`);
828
831
  void tracker.updateStatus(ref, "completed");
829
832
  }
830
833
 
@@ -837,13 +840,11 @@ export class Engine {
837
840
  if (!issue) return;
838
841
  this.stopObserver(issue.id);
839
842
  const sessions = await this.store.getSessionsForIssue(issue.id);
843
+ let killedCount = 0;
840
844
  for (const session of sessions) {
841
845
  const k = this.sessionKey(session, issue);
842
- const proc = this.processes.get(k);
843
- if (proc) {
844
- this.stopping.add(k);
845
- try { this.killProcessTree(proc.pid, "SIGTERM"); } catch { /* already dead */ }
846
- }
846
+ const killed = await this.killSessionProcess(session, k);
847
+ if (killed) killedCount++;
847
848
  this.clearRuntimeState(k);
848
849
  const msgs = await this.store.getMessagesForSession(session.id);
849
850
  for (const msg of msgs) {
@@ -853,7 +854,7 @@ export class Engine {
853
854
  }
854
855
  await this.store.updateSession(session.id, { state: "idle", opencodePid: undefined });
855
856
  }
856
- log.info(`engine: issue halted, ${sessions.length} sessions killed for ${scopeKey}#${ref.issueId}`);
857
+ log.info(`engine: issue halted, ${killedCount}/${sessions.length} sessions killed for ${scopeKey}#${ref.issueId}`);
857
858
  try { await tracker.createComment(ref, "[system] โธ๏ธ AI processing halted by user."); } catch { /* tracker unavailable */ }
858
859
  }
859
860
 
@@ -873,6 +874,36 @@ export class Engine {
873
874
  this.generation.delete(k);
874
875
  }
875
876
 
877
+ private async killSessionProcess(session: OpSession, k: string): Promise<boolean> {
878
+ const handle = this.processes.get(k);
879
+ if (handle) {
880
+ this.stopping.add(k);
881
+ try { this.killProcessTree(handle.pid, "SIGTERM"); } catch { /* already dead */ }
882
+ this.processes.delete(k);
883
+ return true;
884
+ }
885
+
886
+ const pid = session.opencodePid;
887
+ if (!pid) return false;
888
+ try {
889
+ process.kill(pid, 0);
890
+ } catch {
891
+ return false;
892
+ }
893
+
894
+ log.info(`engine: killing orphaned pid=${pid} for ${k} (cross-restart)`);
895
+ this.stopping.add(k);
896
+ try {
897
+ this.killProcessTree(pid, "SIGTERM");
898
+ for (let i = 0; i < 30; i++) {
899
+ await new Promise(r => setTimeout(r, 100));
900
+ try { process.kill(pid, 0); } catch { break; }
901
+ }
902
+ try { process.kill(pid, "SIGKILL"); } catch { /* dead */ }
903
+ } catch { /* already dead */ }
904
+ return true;
905
+ }
906
+
876
907
  // โ”€โ”€โ”€ Preemptive Scheduler โ”€โ”€โ”€
877
908
 
878
909
  private async enqueueOrRun(session: OpSession, issue: Issue, prompt: string, sourceCommentId?: string, model?: string) {
@@ -978,7 +1009,7 @@ export class Engine {
978
1009
  await this.store.updateSession(session.id, { opencodeSessionId: undefined });
979
1010
  }
980
1011
 
981
- const model = msg.model || this.cfg.opencode.defaultModel;
1012
+ const model = msg.model || (this.cfg.runtime === "pi" && this.cfg.pi ? this.cfg.pi.defaultModel : this.cfg.opencode.defaultModel);
982
1013
  this.currentModel.set(k, model);
983
1014
 
984
1015
  if (msg.sourceCommentId) {
@@ -1388,16 +1419,20 @@ export class Engine {
1388
1419
  }
1389
1420
 
1390
1421
  private async runObserverCycle() {
1391
- // Multi-machine: periodically release stale owners so we can adopt their
1392
- // work, and only iterate issues/sessions this daemon owns.
1393
1422
  try {
1394
1423
  await this.store.releaseDeadOwners(this.cfg.work.leaseTtlMs);
1395
1424
  } catch (err) {
1396
1425
  log.error("engine: releaseDeadOwners failed:", (err as Error).message);
1397
1426
  }
1398
1427
 
1399
- const ownedIssues = (await this.store.listOwnedIssues(this.daemonId))
1400
- .filter((i) => this.observedIssues.has(i.id));
1428
+ let ownedIssues;
1429
+ try {
1430
+ ownedIssues = (await this.store.listOwnedIssues(this.daemonId))
1431
+ .filter((i) => this.observedIssues.has(i.id));
1432
+ } catch (err) {
1433
+ log.error("engine: listOwnedIssues failed:", (err as Error).message);
1434
+ return;
1435
+ }
1401
1436
 
1402
1437
  for (const issue of ownedIssues) {
1403
1438
  try {
@@ -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
+ }