ework-daemon 0.1.1 → 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.
- package/package.json +1 -1
- package/src/opencode.ts +96 -25
package/package.json
CHANGED
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
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 →
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
326
|
-
const
|
|
327
|
-
|
|
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);
|
|
@@ -474,10 +534,21 @@ export class Engine {
|
|
|
474
534
|
|
|
475
535
|
let exitCode: number | null = null;
|
|
476
536
|
|
|
537
|
+
// M-1: build the child env explicitly. When a model IS resolved we push
|
|
538
|
+
// --model above, so opencode ignores OPENCODE_MODEL anyway. When NO model
|
|
539
|
+
// is resolved we must not let a leaked OPENCODE_MODEL from our own env
|
|
540
|
+
// silently win — strip it so opencode falls back to its opencode.json
|
|
541
|
+
// (the operator's explicit config) instead of arbitrary env pollution.
|
|
542
|
+
// Provider keys / Gitea vars are preserved (opencode + its plugin need
|
|
543
|
+
// them); only the explicit model-override var is neutralized.
|
|
544
|
+
const childEnv = { ...process.env };
|
|
545
|
+
if (!msg.model) delete childEnv.OPENCODE_MODEL;
|
|
546
|
+
|
|
477
547
|
try {
|
|
478
548
|
const proc = spawn({
|
|
479
549
|
cmd: args,
|
|
480
550
|
cwd: workdir,
|
|
551
|
+
env: childEnv,
|
|
481
552
|
stdout: "pipe",
|
|
482
553
|
stderr: "pipe",
|
|
483
554
|
stdin: "ignore",
|