groove-dev 0.27.184 → 0.27.186

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 (60) hide show
  1. package/.watch-test-flag-1784767927904 +1 -0
  2. package/.watch-test-flag-1784768055729 +1 -0
  3. package/.watch-test-flag-1784768066364 +1 -0
  4. package/node_modules/@groove-dev/cli/package.json +1 -1
  5. package/node_modules/@groove-dev/daemon/package.json +1 -1
  6. package/node_modules/@groove-dev/daemon/src/api.js +3 -0
  7. package/node_modules/@groove-dev/daemon/src/index.js +17 -0
  8. package/node_modules/@groove-dev/daemon/src/innerchat-docs.js +60 -5
  9. package/node_modules/@groove-dev/daemon/src/innerchat.js +167 -54
  10. package/node_modules/@groove-dev/daemon/src/introducer.js +3 -2
  11. package/node_modules/@groove-dev/daemon/src/journalist.js +83 -42
  12. package/node_modules/@groove-dev/daemon/src/process.js +19 -10
  13. package/node_modules/@groove-dev/daemon/src/rotator.js +6 -1
  14. package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +80 -28
  15. package/node_modules/@groove-dev/daemon/src/routes/watch.js +42 -0
  16. package/node_modules/@groove-dev/daemon/src/watcher.js +258 -0
  17. package/node_modules/@groove-dev/daemon/test/innerchat.test.js +71 -1
  18. package/node_modules/@groove-dev/daemon/test/journalist.test.js +59 -0
  19. package/node_modules/@groove-dev/daemon/test/rotator.test.js +22 -4
  20. package/node_modules/@groove-dev/daemon/test/watcher.test.js +158 -0
  21. package/node_modules/@groove-dev/gui/dist/assets/index-CU8L_r5f.css +1 -0
  22. package/node_modules/@groove-dev/gui/dist/assets/{index-BpOyN6Zf.js → index-DOOaCFRS.js} +227 -222
  23. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  24. package/node_modules/@groove-dev/gui/package.json +1 -1
  25. package/node_modules/@groove-dev/gui/src/app.css +1 -0
  26. package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +92 -4
  27. package/node_modules/@groove-dev/gui/src/components/editor/terminal.jsx +25 -0
  28. package/node_modules/@groove-dev/gui/src/lib/logpaths.js +45 -0
  29. package/node_modules/@groove-dev/gui/src/stores/groove.js +30 -16
  30. package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +6 -0
  31. package/node_modules/@groove-dev/gui/src/stores/slices/ui-slice.js +11 -0
  32. package/node_modules/@groove-dev/gui/src/views/memory.jsx +56 -31
  33. package/package.json +1 -1
  34. package/packages/cli/package.json +1 -1
  35. package/packages/daemon/package.json +1 -1
  36. package/packages/daemon/src/api.js +3 -0
  37. package/packages/daemon/src/index.js +17 -0
  38. package/packages/daemon/src/innerchat-docs.js +60 -5
  39. package/packages/daemon/src/innerchat.js +167 -54
  40. package/packages/daemon/src/introducer.js +3 -2
  41. package/packages/daemon/src/journalist.js +83 -42
  42. package/packages/daemon/src/process.js +19 -10
  43. package/packages/daemon/src/rotator.js +6 -1
  44. package/packages/daemon/src/routes/innerchat.js +80 -28
  45. package/packages/daemon/src/routes/watch.js +42 -0
  46. package/packages/daemon/src/watcher.js +258 -0
  47. package/packages/gui/dist/assets/index-CU8L_r5f.css +1 -0
  48. package/packages/gui/dist/assets/{index-BpOyN6Zf.js → index-DOOaCFRS.js} +227 -222
  49. package/packages/gui/dist/index.html +2 -2
  50. package/packages/gui/package.json +1 -1
  51. package/packages/gui/src/app.css +1 -0
  52. package/packages/gui/src/components/agents/agent-feed.jsx +92 -4
  53. package/packages/gui/src/components/editor/terminal.jsx +25 -0
  54. package/packages/gui/src/lib/logpaths.js +45 -0
  55. package/packages/gui/src/stores/groove.js +30 -16
  56. package/packages/gui/src/stores/slices/agents-slice.js +6 -0
  57. package/packages/gui/src/stores/slices/ui-slice.js +11 -0
  58. package/packages/gui/src/views/memory.jsx +56 -31
  59. package/node_modules/@groove-dev/gui/dist/assets/index-DiXB7yry.css +0 -1
  60. package/packages/gui/dist/assets/index-DiXB7yry.css +0 -1
@@ -10,7 +10,7 @@ import { LocalProvider } from './providers/local.js';
10
10
  import { OllamaProvider } from './providers/ollama.js';
11
11
  import { AgentLoop } from './agent-loop.js';
12
12
  import { validateAgentConfig } from './validate.js';
13
- import { innerChatInstructions } from './innerchat-docs.js';
13
+ import { innerChatInstructions, watchInstructions } from './innerchat-docs.js';
14
14
 
15
15
  const __dirname = dirname(fileURLToPath(import.meta.url));
16
16
  const SLIDES_ENGINE_SRC = resolve(__dirname, '../templates/groove-slides.cjs');
@@ -562,12 +562,17 @@ export class ProcessManager {
562
562
  const pending = this.consumePendingMessage(agent.id);
563
563
  if (pending) {
564
564
  const agentData = registry.get(agent.id);
565
- if (agentData?.sessionId) {
566
- this.resume(agent.id, pending.message).catch((err) => {
567
- console.error(`[Groove] Auto-resume with queued message failed for ${agent.name}: ${err.message}`);
568
- });
569
- return;
570
- }
565
+ // Deliver the queued message: resume if there's a session, otherwise
566
+ // rotate (fresh session + handoff brief). Previously a missing sessionId
567
+ // meant the already-consumed message was silently dropped — an
568
+ // InnerChat message to a busy agent would vanish and never fire it.
569
+ const deliver = agentData?.sessionId
570
+ ? this.resume(agent.id, pending.message)
571
+ : this.daemon.rotator.rotate(agent.id, { additionalPrompt: pending.message });
572
+ deliver.catch((err) => {
573
+ console.error(`[Groove] Delivering queued message to ${agent.name} failed: ${err.message}`);
574
+ });
575
+ return;
571
576
  }
572
577
  }
573
578
 
@@ -1165,11 +1170,15 @@ For normal file edits within your scope, proceed without review.
1165
1170
  // sandboxed providers (Codex) can't reach it.
1166
1171
  if (!sandboxedProviders.includes(providerName) && !isOneShotProvider) {
1167
1172
  const port = this.daemon.port || 31415;
1168
- const innerChatPrompt = innerChatInstructions(port, agent.name).join('\n') + '\n\n';
1173
+ const capabilities = [
1174
+ ...innerChatInstructions(port, agent.name),
1175
+ '',
1176
+ ...watchInstructions(port, agent.name),
1177
+ ].join('\n') + '\n\n';
1169
1178
  if (spawnConfig.prompt.startsWith('# Handoff Brief')) {
1170
- spawnConfig.prompt += '\n\n' + innerChatPrompt.trim();
1179
+ spawnConfig.prompt += '\n\n' + capabilities.trim();
1171
1180
  } else {
1172
- spawnConfig.prompt = innerChatPrompt + spawnConfig.prompt;
1181
+ spawnConfig.prompt = capabilities + spawnConfig.prompt;
1173
1182
  }
1174
1183
  }
1175
1184
 
@@ -18,7 +18,12 @@ const SCORE_HISTORY_MAX = 40; // ~10 min at 15s intervals
18
18
  const COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes between rotations per agent
19
19
  const QUALITY_COOLDOWN_MS = 2 * 60 * 1000; // 2 minutes for quality degradation rotations
20
20
  const TOKEN_CEILING = 5_000_000; // 5M tokens per agent (non-self-managing only)
21
- const REPLAY_CEILING = 0.50; // Self-managing providers: rotate idle agents above this context usage
21
+ // Self-managing providers: rotate idle agents above this context usage.
22
+ // 0.80 favors context retention over cache-read cost — the conversation-resume
23
+ // window is a slice of the full session, so every rotation is lossy; for
24
+ // complex long-lived work that loss compounds faster than token savings pay off.
25
+ // Lower via config (replayCeiling) for cost-sensitive fleets; 0 disables.
26
+ const REPLAY_CEILING = 0.80;
22
27
  const VELOCITY_CEILING = 250_000; // New (non-cache) tokens per window before force rotation
23
28
  const VELOCITY_WINDOW_MS = 5 * 60_000;
24
29
  const ROLE_MULTIPLIERS = {
@@ -2,15 +2,51 @@
2
2
 
3
3
  import { MAX_EXCHANGES } from '../innerchat.js';
4
4
 
5
- // Agents know each other by name, not id resolve either, preferring an
6
- // exact name match so `fullstack-1` never resolves to `fullstack-14`.
5
+ // Agents know each other by name, not id. Exact matches win first so
6
+ // `fullstack-1` never resolves to `fullstack-14`; only if nothing matches
7
+ // exactly do we accept a single unambiguous partial, since agents routinely
8
+ // half-remember a teammate's name. An ambiguous partial resolves to nothing
9
+ // and the caller gets the candidate list instead of a wrong recipient.
7
10
  function resolveAgent(daemon, ref) {
8
11
  if (!ref || typeof ref !== 'string') return null;
9
12
  const all = daemon.registry.getAll();
10
- return all.find((a) => a.id === ref)
13
+ const needle = ref.trim().toLowerCase();
14
+
15
+ const exact = all.find((a) => a.id === ref)
11
16
  || all.find((a) => a.name === ref)
12
- || all.find((a) => a.name.toLowerCase() === ref.toLowerCase())
13
- || null;
17
+ || all.find((a) => a.name.toLowerCase() === needle);
18
+ if (exact) return exact;
19
+
20
+ const partial = all.filter((a) => a.name.toLowerCase().includes(needle)
21
+ || needle.includes(a.name.toLowerCase()));
22
+ return partial.length === 1 ? partial[0] : null;
23
+ }
24
+
25
+ // Resolve the from/to pair from a request body, or write the appropriate
26
+ // 400/404 and return null so the caller bails.
27
+ function resolveParties(daemon, req, res) {
28
+ const { from, to, message } = req.body || {};
29
+
30
+ const fromAgent = resolveAgent(daemon, from);
31
+ if (!fromAgent) { res.status(404).json({ error: `Unknown calling agent: ${from}` }); return null; }
32
+
33
+ const toAgent = resolveAgent(daemon, to);
34
+ if (!toAgent) {
35
+ const others = daemon.registry.getAll().filter((a) => a.id !== fromAgent.id);
36
+ const needle = String(to || '').trim().toLowerCase();
37
+ const close = others.filter((a) => a.name.toLowerCase().includes(needle)).map((a) => a.name);
38
+ if (close.length > 1) {
39
+ res.status(404).json({ error: `"${to}" matches more than one agent — use the full name.`, didYouMean: close });
40
+ } else {
41
+ res.status(404).json({ error: `No agent named "${to}".`, availableAgents: others.map((a) => a.name) });
42
+ }
43
+ return null;
44
+ }
45
+
46
+ if (!message || typeof message !== 'string' || !message.trim()) {
47
+ res.status(400).json({ error: 'message is required' }); return null;
48
+ }
49
+ return { fromAgent, toAgent, message: message.trim() };
14
50
  }
15
51
 
16
52
  export function registerInnerChatRoutes(app, daemon) {
@@ -18,39 +54,25 @@ export function registerInnerChatRoutes(app, daemon) {
18
54
  * Ask another agent a question and BLOCK until it answers.
19
55
  *
20
56
  * This request is held open deliberately — the calling agent is waiting on
21
- * it, and the response body is the other agent's reply. That's what lets two
22
- * agents iterate without a human relaying between them.
57
+ * it, and the response body is the other agent's reply. Best for tight
58
+ * interface negotiation, where the finite exchange budget keeps both sides
59
+ * writing decision-dense messages.
23
60
  */
24
61
  app.post('/api/innerchat/ask', async (req, res) => {
25
62
  try {
26
- const { from, to, message, timeoutMs } = req.body || {};
27
-
28
- const fromAgent = resolveAgent(daemon, from);
29
- if (!fromAgent) return res.status(404).json({ error: `Unknown calling agent: ${from}` });
30
-
31
- const toAgent = resolveAgent(daemon, to);
32
- if (!toAgent) {
33
- const names = daemon.registry.getAll()
34
- .filter((a) => a.id !== fromAgent.id)
35
- .map((a) => a.name);
36
- return res.status(404).json({
37
- error: `No agent named "${to}".`,
38
- availableAgents: names,
39
- });
40
- }
41
-
42
- if (!message || typeof message !== 'string' || !message.trim()) {
43
- return res.status(400).json({ error: 'message is required' });
44
- }
63
+ const parties = resolveParties(daemon, req, res);
64
+ if (!parties) return;
45
65
 
46
66
  // Held open until the target answers — see the class doc in innerchat.js.
47
67
  req.setTimeout(0);
48
68
  res.setTimeout(0);
49
69
 
50
- const result = await daemon.innerchat.ask(fromAgent.id, toAgent.id, message.trim(), { timeoutMs });
70
+ const result = await daemon.innerchat.ask(parties.fromAgent.id, parties.toAgent.id, parties.message, {
71
+ timeoutMs: req.body?.timeoutMs,
72
+ });
51
73
 
52
74
  res.json({
53
- from: toAgent.name,
75
+ from: parties.toAgent.name,
54
76
  reply: result.reply,
55
77
  threadId: result.threadId,
56
78
  exchangesUsed: result.exchanges,
@@ -63,6 +85,36 @@ export function registerInnerChatRoutes(app, daemon) {
63
85
  }
64
86
  });
65
87
 
88
+ /**
89
+ * Send a message WITHOUT blocking — returns as soon as it's delivered. If the
90
+ * target replies, the reply is routed back to the sender asynchronously
91
+ * (resuming it if its turn ended). Best for handing off to a heads-down agent
92
+ * where waiting out a timeout would waste the turn.
93
+ */
94
+ app.post('/api/innerchat/tell', async (req, res) => {
95
+ try {
96
+ const parties = resolveParties(daemon, req, res);
97
+ if (!parties) return;
98
+
99
+ const result = await daemon.innerchat.tell(parties.fromAgent.id, parties.toAgent.id, parties.message, {
100
+ threadId: req.body?.threadId,
101
+ });
102
+
103
+ res.json({
104
+ ok: true,
105
+ to: parties.toAgent.name,
106
+ delivered: result.delivered,
107
+ threadId: result.threadId,
108
+ exchangesUsed: result.exchanges,
109
+ exchangesRemaining: result.remaining,
110
+ maxExchanges: MAX_EXCHANGES,
111
+ note: `Message delivered. ${parties.toAgent.name}'s reply, if any, will be routed back to you — you can end your turn.`,
112
+ });
113
+ } catch (err) {
114
+ res.status(409).json({ error: err.message });
115
+ }
116
+ });
117
+
66
118
  app.get('/api/innerchat/threads', (req, res) => {
67
119
  const { agentId } = req.query;
68
120
  res.json({ threads: daemon.innerchat.getThreads(agentId || null) });
@@ -0,0 +1,42 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+
3
+ // Agents identify themselves by name; resolve to the live record.
4
+ function resolveAgent(daemon, ref) {
5
+ if (!ref || typeof ref !== 'string') return null;
6
+ const all = daemon.registry.getAll();
7
+ return all.find((a) => a.id === ref)
8
+ || all.find((a) => a.name === ref)
9
+ || all.find((a) => a.name.toLowerCase() === ref.trim().toLowerCase())
10
+ || null;
11
+ }
12
+
13
+ export function registerWatchRoutes(app, daemon) {
14
+ // Register a watch and return immediately — the agent's turn ends, and the
15
+ // wake comes later when the watched thing finishes. This does NOT block.
16
+ app.post('/api/watch', (req, res) => {
17
+ try {
18
+ const { agent, command, until, label, timeoutMs, intervalMs } = req.body || {};
19
+ const who = resolveAgent(daemon, agent);
20
+ if (!who) return res.status(404).json({ error: `Unknown agent: ${agent}` });
21
+
22
+ const watch = daemon.watcher.create(who.id, { command, until, label, timeoutMs, intervalMs });
23
+ res.json({
24
+ ok: true,
25
+ watchId: watch.id,
26
+ message: `Watching "${watch.label}". You'll be resumed with the result when it ${watch.mode === 'command' ? 'finishes' : 'condition is met'}. You can end your turn now.`,
27
+ });
28
+ } catch (err) {
29
+ res.status(400).json({ error: err.message });
30
+ }
31
+ });
32
+
33
+ app.get('/api/watch', (req, res) => {
34
+ res.json({ watches: daemon.watcher.list(req.query.agentId || null) });
35
+ });
36
+
37
+ app.delete('/api/watch/:id', (req, res) => {
38
+ const ok = daemon.watcher.cancel(req.params.id);
39
+ if (!ok) return res.status(404).json({ error: 'Watch not found' });
40
+ res.json({ ok: true });
41
+ });
42
+ }
@@ -0,0 +1,258 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+
3
+ import { spawn } from 'child_process';
4
+ import { randomUUID } from 'crypto';
5
+ import { deliverInstruction } from './deliver.js';
6
+
7
+ // Watches are bounded so a wedged process or a condition that never comes true
8
+ // can't poll forever or hold a slot indefinitely.
9
+ const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
10
+ const MAX_TIMEOUT_MS = 6 * 60 * 60 * 1000;
11
+ const DEFAULT_POLL_MS = 15 * 1000;
12
+ const MIN_POLL_MS = 3 * 1000;
13
+ const MAX_WATCHES_PER_AGENT = 5;
14
+ const OUTPUT_TAIL = 4000;
15
+
16
+ /**
17
+ * Wake-on-completion for agents.
18
+ *
19
+ * An agent promises to "report back when the tests finish", but its process
20
+ * ends when the turn does — so nothing is left to report. A Watch lives in the
21
+ * daemon, not the agent's session: it outlives the turn, and when its target
22
+ * finishes it resumes the agent (from `completed` if need be, via the same
23
+ * pipe as user chat) with the outcome.
24
+ *
25
+ * Two flavours:
26
+ * - command: the daemon runs a command detached and owns its lifecycle, so
27
+ * it has the real exit code and output when it finishes.
28
+ * - until: the daemon polls a check command for something already running,
29
+ * and wakes the agent when the check first succeeds (exit 0).
30
+ */
31
+ export class Watcher {
32
+ constructor(daemon) {
33
+ this.daemon = daemon;
34
+ this.watches = new Map();
35
+ }
36
+
37
+ create(agentId, opts = {}) {
38
+ const agent = this.daemon.registry.get(agentId);
39
+ if (!agent) throw new Error('Agent not found');
40
+
41
+ const command = typeof opts.command === 'string' ? opts.command.trim() : '';
42
+ const until = typeof opts.until === 'string' ? opts.until.trim() : '';
43
+ if (!command && !until) throw new Error('Provide either "command" (run it) or "until" (poll it)');
44
+ if (command && until) throw new Error('Provide only one of "command" or "until"');
45
+
46
+ const active = [...this.watches.values()].filter((w) => w.agentId === agentId && w.status === 'active');
47
+ if (active.length >= MAX_WATCHES_PER_AGENT) {
48
+ throw new Error(`You already have ${MAX_WATCHES_PER_AGENT} active watches — cancel one before adding another`);
49
+ }
50
+
51
+ const timeoutMs = Math.min(Number(opts.timeoutMs) || DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS);
52
+ const watch = {
53
+ id: randomUUID().slice(0, 12),
54
+ agentId,
55
+ agentName: agent.name,
56
+ label: (opts.label && String(opts.label).slice(0, 120)) || command || until,
57
+ mode: command ? 'command' : 'until',
58
+ command, until,
59
+ cwd: agent.workingDir || this.daemon.projectDir,
60
+ pollMs: Math.max(Number(opts.intervalMs) || DEFAULT_POLL_MS, MIN_POLL_MS),
61
+ timeoutMs,
62
+ status: 'active',
63
+ createdAt: Date.now(),
64
+ _child: null,
65
+ _poll: null,
66
+ _deadline: null,
67
+ };
68
+
69
+ this.watches.set(watch.id, watch);
70
+ watch._deadline = setTimeout(() => this._fireTimeout(watch), timeoutMs);
71
+ if (watch.mode === 'command') this._runCommand(watch);
72
+ else this._startPolling(watch);
73
+
74
+ this.daemon.audit?.log('watch.create', { id: watch.id, agent: agentId, mode: watch.mode });
75
+ this.daemon.broadcast?.({ type: 'watch:created', data: this._public(watch) });
76
+ return this._public(watch);
77
+ }
78
+
79
+ // ── command mode: daemon owns the process ─────────────────────
80
+
81
+ _runCommand(watch) {
82
+ const child = spawn('/bin/sh', ['-c', watch.command], {
83
+ cwd: watch.cwd,
84
+ env: process.env,
85
+ detached: false,
86
+ });
87
+ watch._child = child;
88
+
89
+ const chunks = [];
90
+ let bytes = 0;
91
+ const collect = (buf) => {
92
+ // Keep only the tail — a chatty build shouldn't grow this unbounded.
93
+ chunks.push(buf);
94
+ bytes += buf.length;
95
+ while (bytes > OUTPUT_TAIL * 2 && chunks.length > 1) bytes -= chunks.shift().length;
96
+ };
97
+ child.stdout?.on('data', collect);
98
+ child.stderr?.on('data', collect);
99
+
100
+ child.on('error', (err) => {
101
+ this._wake(watch, {
102
+ outcome: 'error',
103
+ summary: `Could not start the command: ${err.message}`,
104
+ });
105
+ });
106
+
107
+ child.on('close', (code, signal) => {
108
+ if (watch.status !== 'active') return; // already timed out / cancelled
109
+ const output = Buffer.concat(chunks).toString('utf8').slice(-OUTPUT_TAIL).trim();
110
+ this._wake(watch, {
111
+ outcome: code === 0 ? 'success' : 'failure',
112
+ exitCode: code,
113
+ signal,
114
+ summary: signal
115
+ ? `Terminated by signal ${signal}.`
116
+ : `Finished with exit code ${code}.`,
117
+ output,
118
+ });
119
+ });
120
+ }
121
+
122
+ // ── until mode: poll something already running ────────────────
123
+
124
+ _startPolling(watch) {
125
+ const tick = () => {
126
+ if (watch.status !== 'active') return;
127
+ const check = spawn('/bin/sh', ['-c', watch.until], { cwd: watch.cwd, env: process.env });
128
+ const chunks = [];
129
+ check.stdout?.on('data', (b) => chunks.push(b));
130
+ check.stderr?.on('data', (b) => chunks.push(b));
131
+ check.on('error', () => { /* transient — try again next tick */ });
132
+ check.on('close', (code) => {
133
+ if (watch.status !== 'active') return;
134
+ if (code === 0) {
135
+ const output = Buffer.concat(chunks).toString('utf8').slice(-OUTPUT_TAIL).trim();
136
+ this._wake(watch, { outcome: 'success', summary: 'Your watch condition is now true.', output });
137
+ }
138
+ });
139
+ };
140
+ watch._poll = setInterval(tick, watch.pollMs);
141
+ tick(); // check immediately — the condition may already hold
142
+ }
143
+
144
+ // ── firing ────────────────────────────────────────────────────
145
+
146
+ _fireTimeout(watch) {
147
+ if (watch.status !== 'active') return;
148
+ this._wake(watch, {
149
+ outcome: 'timeout',
150
+ summary: `Your watch "${watch.label}" hit its ${Math.round(watch.timeoutMs / 60000)}-minute time limit `
151
+ + 'without finishing. It may still be running — check on it directly.',
152
+ });
153
+ }
154
+
155
+ async _wake(watch, result) {
156
+ if (watch.status !== 'active') return;
157
+ watch.status = 'fired';
158
+ watch.firedAt = Date.now();
159
+ watch.result = result;
160
+ this._cleanup(watch);
161
+
162
+ const message = this._composeMessage(watch, result);
163
+
164
+ // Resolve by name, not the id we stored at creation: over a 30-minute
165
+ // watch the agent has very likely been chatted with and rotated to a new
166
+ // id. Names are stable across rotation, so this follows the agent; a
167
+ // purged/gone agent simply doesn't resolve and the watch is undeliverable
168
+ // rather than resurrecting a killed agent under a stale id.
169
+ const target = this.daemon.registry.getAll().find((a) => a.name === watch.agentName);
170
+ if (!target) {
171
+ watch.status = 'undeliverable';
172
+ watch.deliveryError = `Agent ${watch.agentName} no longer exists`;
173
+ this.daemon.audit?.log('watch.undeliverable', { id: watch.id, reason: 'agent gone' });
174
+ this.daemon.broadcast?.({ type: 'watch:fired', data: this._public(watch) });
175
+ return;
176
+ }
177
+
178
+ try {
179
+ const delivered = await deliverInstruction(this.daemon, target.id, message, { recordFeedback: false });
180
+ watch.agentId = delivered.agentId;
181
+ watch.status = 'delivered';
182
+ } catch (err) {
183
+ watch.status = 'undeliverable';
184
+ watch.deliveryError = err.message;
185
+ this.daemon.audit?.log('watch.undeliverable', { id: watch.id, error: err.message });
186
+ }
187
+
188
+ this.daemon.broadcast?.({ type: 'watch:fired', data: this._public(watch) });
189
+ }
190
+
191
+ _composeMessage(watch, result) {
192
+ const lines = [`[Watch fired — "${watch.label}"]`, '', result.summary];
193
+ if (result.output) {
194
+ lines.push('', 'Output (tail):', '```', result.output, '```');
195
+ }
196
+ lines.push('', 'This is the notification you set up earlier. Continue from here and report to the user.');
197
+ return lines.join('\n');
198
+ }
199
+
200
+ // ── lifecycle ─────────────────────────────────────────────────
201
+
202
+ cancel(id, agentId = null) {
203
+ const watch = this.watches.get(id);
204
+ if (!watch) return false;
205
+ if (agentId && watch.agentId !== agentId) return false;
206
+ if (watch.status === 'active') {
207
+ watch.status = 'cancelled';
208
+ this._cleanup(watch);
209
+ this.daemon.broadcast?.({ type: 'watch:cancelled', data: this._public(watch) });
210
+ }
211
+ return true;
212
+ }
213
+
214
+ // An agent that is killed/purged shouldn't leave watches firing into a void.
215
+ cancelForAgent(agentId) {
216
+ for (const watch of this.watches.values()) {
217
+ if (watch.agentId === agentId && watch.status === 'active') {
218
+ watch.status = 'cancelled';
219
+ this._cleanup(watch);
220
+ }
221
+ }
222
+ }
223
+
224
+ _cleanup(watch) {
225
+ if (watch._deadline) { clearTimeout(watch._deadline); watch._deadline = null; }
226
+ if (watch._poll) { clearInterval(watch._poll); watch._poll = null; }
227
+ if (watch._child && watch.mode === 'command' && watch.status === 'cancelled') {
228
+ // Only kill the process when the user/agent cancelled — a fired watch
229
+ // means it exited on its own.
230
+ try { watch._child.kill('SIGTERM'); } catch { /* already gone */ }
231
+ }
232
+ watch._child = null;
233
+ }
234
+
235
+ list(agentId = null) {
236
+ const all = [...this.watches.values()].sort((a, b) => b.createdAt - a.createdAt);
237
+ return (agentId ? all.filter((w) => w.agentId === agentId) : all).map((w) => this._public(w));
238
+ }
239
+
240
+ stop() {
241
+ for (const watch of this.watches.values()) {
242
+ if (watch._deadline) clearTimeout(watch._deadline);
243
+ if (watch._poll) clearInterval(watch._poll);
244
+ if (watch._child) { try { watch._child.kill('SIGTERM'); } catch { /* gone */ } }
245
+ watch._deadline = watch._poll = watch._child = null;
246
+ }
247
+ }
248
+
249
+ _public(w) {
250
+ return {
251
+ id: w.id, agentId: w.agentId, agentName: w.agentName, label: w.label,
252
+ mode: w.mode, command: w.command, until: w.until, status: w.status,
253
+ createdAt: w.createdAt, firedAt: w.firedAt || null, result: w.result || null,
254
+ };
255
+ }
256
+ }
257
+
258
+ export { DEFAULT_TIMEOUT_MS, MAX_WATCHES_PER_AGENT };