ework-daemon 0.1.2 → 0.1.3

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/opencode.ts +85 -25
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-daemon",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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/opencode.ts CHANGED
@@ -14,6 +14,51 @@ interface TrackerRegistry {
14
14
  get(type: string): IssueTracker | undefined;
15
15
  }
16
16
 
17
+ /**
18
+ * Extract the target name of an @mention from comment text.
19
+ *
20
+ * Strips fenced + inline code first (terminal pastes with "user@host" / "git@repo"),
21
+ * then matches `@name`. Rejects two phantom-mention shapes that previously spawned
22
+ * stray agent sessions (ework-daemon#2):
23
+ * - scoped package refs (`@types/node`, `@babel/core` — the trailing `/` means an
24
+ * npm path, not a person);
25
+ * - version-like `@<digits>` (`@123`).
26
+ *
27
+ * Exported so tests can pin the exact accept/reject behavior (regression coverage).
28
+ */
29
+ export function detectMention(text: string): string | null {
30
+ const stripped = text
31
+ .replace(/```[\s\S]*?```/g, "")
32
+ .replace(/`[^`\n]*`/g, "");
33
+ const re = /(?:^|\s)@([\w\u4e00-\u9fff]+)/g;
34
+ let m: RegExpExecArray | null;
35
+ while ((m = re.exec(stripped)) !== null) {
36
+ const name = m[1];
37
+ if (!name) continue;
38
+ if (/^\d+$/.test(name)) continue; // @<digits> → version ref, skip
39
+ if (stripped[re.lastIndex] === "/") continue; // @scope/pkg → scoped package, skip
40
+ return name;
41
+ }
42
+ return null;
43
+ }
44
+
45
+ /**
46
+ * Pick the most recently active session from a list: the one whose process last
47
+ * started (`startedAt`), falling back to creation time when a session has never
48
+ * run yet. Returns `undefined` for an empty list.
49
+ *
50
+ * Used by the no-mention dispatch path to route a comment to a single session
51
+ * (the "last AI") instead of broadcasting to all of them.
52
+ */
53
+ export function pickLastActive(sessions: OpSession[]): OpSession | undefined {
54
+ if (sessions.length === 0) return undefined;
55
+ return sessions.reduce((a, b) => {
56
+ const aT = a.startedAt ?? a.createdAt.getTime();
57
+ const bT = b.startedAt ?? b.createdAt.getTime();
58
+ return bT > aT ? b : a;
59
+ });
60
+ }
61
+
17
62
  // ─── Engine ───
18
63
 
19
64
  export class Engine {
@@ -107,13 +152,21 @@ export class Engine {
107
152
  }
108
153
 
109
154
  private extractMentionName(text: string): string | null {
110
- // Strip fenced code blocks (```...```) and inline code (`...`) before scanning for @mentions,
111
- // otherwise pasted terminal output / code containing "user@host" or "git@repo" creates phantom sessions.
112
- const stripped = text
113
- .replace(/```[\s\S]*?```/g, "")
114
- .replace(/`[^`\n]*`/g, "");
115
- const match = stripped.match(/(?:^|\s)@([\w\u4e00-\u9fff]+)/);
116
- return match?.[1] ?? null;
155
+ return detectMention(text);
156
+ }
157
+
158
+ /**
159
+ * Whitelist gate: default only the bot itself is a valid @mention target.
160
+ * Unknown names (misread from plain text — `@types/node`, etc.) don't spawn a
161
+ * new session; they fall back to broadcast. To run multiple named agents, set
162
+ * `DAEMON_ALLOWED_AGENTS=ework,tester,...`.
163
+ */
164
+ private isAllowedAgent(name: string): boolean {
165
+ const env = process.env.DAEMON_ALLOWED_AGENTS;
166
+ const allowed = env && env.trim()
167
+ ? env.split(",").map(s => s.trim()).filter(Boolean)
168
+ : [this.cfg.bot.username];
169
+ return allowed.includes(name);
117
170
  }
118
171
 
119
172
  private parseDirCommand(text: string): string | null {
@@ -261,7 +314,14 @@ export class Engine {
261
314
  }
262
315
 
263
316
  const dirPath = this.parseDirCommand(comment.body);
264
- const mentionName = this.extractMentionName(comment.body);
317
+ const rawMention = this.extractMentionName(comment.body);
318
+ // Gate extracted @mentions through the agent whitelist. An unknown name
319
+ // (e.g. `@types` misread from `@types/node`) is treated as no mention and
320
+ // falls through to broadcast instead of spawning a phantom session (ework-daemon#2).
321
+ const mentionName = rawMention && this.isAllowedAgent(rawMention) ? rawMention : null;
322
+ if (rawMention && !mentionName) {
323
+ log.info(`engine: @${rawMention} is not an allowed agent — routing to last session instead of spawning`);
324
+ }
265
325
 
266
326
  if (mentionName) {
267
327
  // @mention → targeted delivery
@@ -304,27 +364,27 @@ export class Engine {
304
364
  this.enqueueOrRun(session, issue, prompt, comment.id, model);
305
365
  }
306
366
  } else {
307
- // No @mention → broadcast to all sessions on this issue
367
+ // No valid @mention → forward to the most recently active session only.
368
+ // Broadcasting to all sessions makes multiple AIs race on the same request;
369
+ // routing to the last-active lets the user continue without re-@mentioning,
370
+ // while @mention switches to a different AI.
308
371
  const sessions = this.store.getSessionsForIssue(issue.id);
309
372
  if (sessions.length === 0) return;
310
373
 
311
- for (const session of sessions) {
312
- if (dirPath) {
313
- this.store.updateSession(session.id, { workdir: dirPath });
314
- session.workdir = dirPath;
315
- }
316
- const workdir = this.resolveWorkdir(session, issue);
317
- const instructions = tracker.getTrackerInstructions(ref);
318
- const prompt = this.buildForwardPrompt(
319
- session.name, this.handleLargeContent(workdir, comment.body, `comment-${comment.id}.txt`),
320
- comment.author, issueData.title, workdir, instructions
321
- );
322
- this.enqueueOrRun(session, issue, prompt, comment.id, model);
374
+ const session = pickLastActive(sessions);
375
+ if (!session) return;
376
+ if (dirPath) {
377
+ this.store.updateSession(session.id, { workdir: dirPath });
378
+ session.workdir = dirPath;
323
379
  }
324
-
325
- // Immediate ack
326
- const names = sessions.map(s => `**${s.name}** (\`${s.opencodeSessionId ?? s.id}\`, \`${this.resolveWorkdir(s, issue)}\`)`).join(", ");
327
- await tracker.createComment(ref, `[system] ✓ Message broadcasted to: ${names}.`);
380
+ const workdir = this.resolveWorkdir(session, issue);
381
+ const instructions = tracker.getTrackerInstructions(ref);
382
+ const prompt = this.buildForwardPrompt(
383
+ session.name, this.handleLargeContent(workdir, comment.body, `comment-${comment.id}.txt`),
384
+ comment.author, issueData.title, workdir, instructions
385
+ );
386
+ await tracker.createComment(ref, `[system] ✓ Message forwarded to **${session.name}**${this.running.has(this.sessionKey(session, issue)) ? " (running)" : ""}.\n> session: \`${session.id}\` | workdir: \`${workdir}\``);
387
+ this.enqueueOrRun(session, issue, prompt, comment.id, model);
328
388
  }
329
389
  } finally {
330
390
  if (comment.id) this.processingComments.delete(comment.id);