groove-dev 0.27.183 → 0.27.185
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/.watch-test-flag-1784767927904 +1 -0
- package/.watch-test-flag-1784768055729 +1 -0
- package/.watch-test-flag-1784768066364 +1 -0
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/api.js +2 -0
- package/node_modules/@groove-dev/daemon/src/index.js +17 -0
- package/node_modules/@groove-dev/daemon/src/innerchat-docs.js +96 -0
- package/node_modules/@groove-dev/daemon/src/innerchat.js +265 -96
- package/node_modules/@groove-dev/daemon/src/introducer.js +31 -1
- package/node_modules/@groove-dev/daemon/src/process.js +36 -6
- package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +109 -13
- package/node_modules/@groove-dev/daemon/src/routes/teams.js +11 -0
- package/node_modules/@groove-dev/daemon/src/routes/watch.js +42 -0
- package/node_modules/@groove-dev/daemon/src/teams.js +28 -0
- package/node_modules/@groove-dev/daemon/src/watcher.js +258 -0
- package/node_modules/@groove-dev/daemon/test/innerchat.test.js +222 -179
- package/node_modules/@groove-dev/daemon/test/teams.test.js +53 -0
- package/node_modules/@groove-dev/daemon/test/watcher.test.js +158 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-CU8L_r5f.css +1 -0
- package/{packages/gui/dist/assets/index-DPjGBQ5X.js → node_modules/@groove-dev/gui/dist/assets/index-DEZwM4Hb.js} +233 -228
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/node_modules/@groove-dev/gui/src/app.css +1 -0
- package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +60 -4
- package/node_modules/@groove-dev/gui/src/components/agents/agent-panel.jsx +1 -29
- package/node_modules/@groove-dev/gui/src/components/fleet/fleet-agent-row.jsx +18 -2
- package/node_modules/@groove-dev/gui/src/components/fleet/fleet-pane.jsx +1 -15
- package/node_modules/@groove-dev/gui/src/components/fleet/fleet-sidebar.jsx +29 -11
- package/node_modules/@groove-dev/gui/src/stores/groove.js +36 -16
- package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +6 -0
- package/node_modules/@groove-dev/gui/src/stores/slices/teams-slice.js +22 -0
- package/node_modules/@groove-dev/gui/src/views/memory.jsx +56 -31
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/api.js +2 -0
- package/packages/daemon/src/index.js +17 -0
- package/packages/daemon/src/innerchat-docs.js +96 -0
- package/packages/daemon/src/innerchat.js +265 -96
- package/packages/daemon/src/introducer.js +31 -1
- package/packages/daemon/src/process.js +36 -6
- package/packages/daemon/src/routes/innerchat.js +109 -13
- package/packages/daemon/src/routes/teams.js +11 -0
- package/packages/daemon/src/routes/watch.js +42 -0
- package/packages/daemon/src/teams.js +28 -0
- package/packages/daemon/src/watcher.js +258 -0
- package/packages/gui/dist/assets/index-CU8L_r5f.css +1 -0
- package/{node_modules/@groove-dev/gui/dist/assets/index-DPjGBQ5X.js → packages/gui/dist/assets/index-DEZwM4Hb.js} +233 -228
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/app.css +1 -0
- package/packages/gui/src/components/agents/agent-feed.jsx +60 -4
- package/packages/gui/src/components/agents/agent-panel.jsx +1 -29
- package/packages/gui/src/components/fleet/fleet-agent-row.jsx +18 -2
- package/packages/gui/src/components/fleet/fleet-pane.jsx +1 -15
- package/packages/gui/src/components/fleet/fleet-sidebar.jsx +29 -11
- package/packages/gui/src/stores/groove.js +36 -16
- package/packages/gui/src/stores/slices/agents-slice.js +6 -0
- package/packages/gui/src/stores/slices/teams-slice.js +22 -0
- package/packages/gui/src/views/memory.jsx +56 -31
- package/node_modules/@groove-dev/gui/dist/assets/index-CiOy7wVS.css +0 -1
- package/node_modules/@groove-dev/gui/src/components/agents/innerchat-relay.jsx +0 -145
- package/packages/gui/dist/assets/index-CiOy7wVS.css +0 -1
- package/packages/gui/src/components/agents/innerchat-relay.jsx +0 -145
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { writeFileSync, readFileSync, existsSync, readdirSync, statSync } from 'fs';
|
|
5
5
|
import { resolve, dirname, basename } from 'path';
|
|
6
6
|
import { escapeMd } from './validate.js';
|
|
7
|
+
import { innerChatInstructions, watchInstructions } from './innerchat-docs.js';
|
|
7
8
|
|
|
8
9
|
const GROOVE_SECTION_START = '<!-- GROOVE:START -->';
|
|
9
10
|
const GROOVE_SECTION_END = '<!-- GROOVE:END -->';
|
|
@@ -556,6 +557,13 @@ export class Introducer {
|
|
|
556
557
|
return null;
|
|
557
558
|
}
|
|
558
559
|
|
|
560
|
+
// Written into every AGENTS_REGISTRY.md so the capability survives context
|
|
561
|
+
// compaction — the spawn prompt alone can scroll out of a long session.
|
|
562
|
+
_innerChatSection() {
|
|
563
|
+
const port = this.daemon.port || 31415;
|
|
564
|
+
return [...innerChatInstructions(port), '', ...watchInstructions(port)];
|
|
565
|
+
}
|
|
566
|
+
|
|
559
567
|
writeRegistryFile(projectDir) {
|
|
560
568
|
const agents = this.daemon.registry.getAll();
|
|
561
569
|
|
|
@@ -596,6 +604,27 @@ export class Introducer {
|
|
|
596
604
|
lines.push(`| ${escapeMd(a.id)} | ${escapeMd(a.name)} | ${escapeMd(a.role)} | ${escapeMd(a.provider)} | ${agentDir} | ${scope} | ${escapeMd(a.status)} |`);
|
|
597
605
|
}
|
|
598
606
|
|
|
607
|
+
// Agents on other teams are reachable via InnerChat, so they have to be
|
|
608
|
+
// discoverable here — otherwise cross-team consultation means guessing
|
|
609
|
+
// names. Kept to a separate, minimal table so the team's own roster
|
|
610
|
+
// stays the primary listing.
|
|
611
|
+
const others = agents.filter((a) => (a.teamId || '_default') !== teamId);
|
|
612
|
+
if (others.length > 0) {
|
|
613
|
+
lines.push('');
|
|
614
|
+
lines.push(`## Other Teams`);
|
|
615
|
+
lines.push('');
|
|
616
|
+
lines.push(`Reachable via InnerChat (see below). Not in your scope — do not edit their files.`);
|
|
617
|
+
lines.push('');
|
|
618
|
+
lines.push(`| Name | Role | Team | Status |`);
|
|
619
|
+
lines.push(`|------|------|------|--------|`);
|
|
620
|
+
for (const a of others) {
|
|
621
|
+
const otherTeam = this.daemon.teams?.get(a.teamId)?.name || '-';
|
|
622
|
+
lines.push(`| ${escapeMd(a.name)} | ${escapeMd(a.role)} | ${escapeMd(otherTeam)} | ${escapeMd(a.status)} |`);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
lines.push('');
|
|
627
|
+
lines.push(...this._innerChatSection());
|
|
599
628
|
lines.push('');
|
|
600
629
|
lines.push(`*Updated: ${new Date().toISOString()}*`);
|
|
601
630
|
|
|
@@ -632,7 +661,8 @@ export class Introducer {
|
|
|
632
661
|
running.length > 0 ? '|------|------|-------|' : '',
|
|
633
662
|
...running.map((a) => `| ${a.name} | ${a.role} | ${a.scope?.join(', ') || '-'} |`),
|
|
634
663
|
'',
|
|
635
|
-
`See AGENTS_REGISTRY.md for full agent state
|
|
664
|
+
`See AGENTS_REGISTRY.md for full agent state, the names of agents on other teams,`,
|
|
665
|
+
`and how to consult them directly (InnerChat).`,
|
|
636
666
|
'',
|
|
637
667
|
`**Memory policy:** GROOVE manages project memory automatically. Do not read or write MEMORY.md or .groove/memory/ files directly.`,
|
|
638
668
|
'',
|
|
@@ -10,6 +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, watchInstructions } from './innerchat-docs.js';
|
|
13
14
|
|
|
14
15
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
16
|
const SLIDES_ENGINE_SRC = resolve(__dirname, '../templates/groove-slides.cjs');
|
|
@@ -506,6 +507,13 @@ export class ProcessManager {
|
|
|
506
507
|
|
|
507
508
|
registry.update(agent.id, { status: finalStatus, pid: null });
|
|
508
509
|
|
|
510
|
+
// An agent that dies mid-question leaves its asker blocked on an open HTTP
|
|
511
|
+
// request — fail it now rather than making it wait out the timeout.
|
|
512
|
+
// 'completed' is normal: the answer arrives as a result before this fires.
|
|
513
|
+
if (this.daemon.innerchat && finalStatus !== 'completed') {
|
|
514
|
+
this.daemon.innerchat.onAgentGone(agent.id, finalStatus);
|
|
515
|
+
}
|
|
516
|
+
|
|
509
517
|
if (this.daemon.timeline) {
|
|
510
518
|
const agentData = registry.get(agent.id);
|
|
511
519
|
this.daemon.timeline.recordEvent(finalStatus === 'completed' ? 'complete' : finalStatus === 'crashed' ? 'crash' : 'kill', {
|
|
@@ -554,12 +562,17 @@ export class ProcessManager {
|
|
|
554
562
|
const pending = this.consumePendingMessage(agent.id);
|
|
555
563
|
if (pending) {
|
|
556
564
|
const agentData = registry.get(agent.id);
|
|
557
|
-
if
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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;
|
|
563
576
|
}
|
|
564
577
|
}
|
|
565
578
|
|
|
@@ -1152,6 +1165,23 @@ For normal file edits within your scope, proceed without review.
|
|
|
1152
1165
|
}
|
|
1153
1166
|
}
|
|
1154
1167
|
|
|
1168
|
+
// InnerChat — let the agent consult other agents directly.
|
|
1169
|
+
// Same constraint as PM review: the agent calls back over HTTP, so
|
|
1170
|
+
// sandboxed providers (Codex) can't reach it.
|
|
1171
|
+
if (!sandboxedProviders.includes(providerName) && !isOneShotProvider) {
|
|
1172
|
+
const port = this.daemon.port || 31415;
|
|
1173
|
+
const capabilities = [
|
|
1174
|
+
...innerChatInstructions(port, agent.name),
|
|
1175
|
+
'',
|
|
1176
|
+
...watchInstructions(port, agent.name),
|
|
1177
|
+
].join('\n') + '\n\n';
|
|
1178
|
+
if (spawnConfig.prompt.startsWith('# Handoff Brief')) {
|
|
1179
|
+
spawnConfig.prompt += '\n\n' + capabilities.trim();
|
|
1180
|
+
} else {
|
|
1181
|
+
spawnConfig.prompt = capabilities + spawnConfig.prompt;
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1155
1185
|
// Set up log capture (shared between CLI and agent loop paths)
|
|
1156
1186
|
const logDir = resolve(this.daemon.grooveDir, 'logs');
|
|
1157
1187
|
mkdirSync(logDir, { recursive: true });
|
|
@@ -1,21 +1,117 @@
|
|
|
1
1
|
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
2
2
|
|
|
3
|
+
import { MAX_EXCHANGES } from '../innerchat.js';
|
|
4
|
+
|
|
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.
|
|
10
|
+
function resolveAgent(daemon, ref) {
|
|
11
|
+
if (!ref || typeof ref !== 'string') return null;
|
|
12
|
+
const all = daemon.registry.getAll();
|
|
13
|
+
const needle = ref.trim().toLowerCase();
|
|
14
|
+
|
|
15
|
+
const exact = all.find((a) => a.id === ref)
|
|
16
|
+
|| all.find((a) => a.name === ref)
|
|
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() };
|
|
50
|
+
}
|
|
51
|
+
|
|
3
52
|
export function registerInnerChatRoutes(app, daemon) {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Ask another agent a question and BLOCK until it answers.
|
|
55
|
+
*
|
|
56
|
+
* This request is held open deliberately — the calling agent is waiting on
|
|
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.
|
|
60
|
+
*/
|
|
61
|
+
app.post('/api/innerchat/ask', async (req, res) => {
|
|
7
62
|
try {
|
|
8
|
-
const
|
|
9
|
-
if (!
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const result = await daemon.innerchat.
|
|
16
|
-
|
|
63
|
+
const parties = resolveParties(daemon, req, res);
|
|
64
|
+
if (!parties) return;
|
|
65
|
+
|
|
66
|
+
// Held open until the target answers — see the class doc in innerchat.js.
|
|
67
|
+
req.setTimeout(0);
|
|
68
|
+
res.setTimeout(0);
|
|
69
|
+
|
|
70
|
+
const result = await daemon.innerchat.ask(parties.fromAgent.id, parties.toAgent.id, parties.message, {
|
|
71
|
+
timeoutMs: req.body?.timeoutMs,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
res.json({
|
|
75
|
+
from: parties.toAgent.name,
|
|
76
|
+
reply: result.reply,
|
|
77
|
+
threadId: result.threadId,
|
|
78
|
+
exchangesUsed: result.exchanges,
|
|
79
|
+
exchangesRemaining: result.remaining,
|
|
80
|
+
maxExchanges: MAX_EXCHANGES,
|
|
81
|
+
});
|
|
82
|
+
} catch (err) {
|
|
83
|
+
// The agent reads this body — keep it actionable, it's the whole signal.
|
|
84
|
+
res.status(409).json({ error: err.message });
|
|
85
|
+
}
|
|
86
|
+
});
|
|
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
|
+
});
|
|
17
113
|
} catch (err) {
|
|
18
|
-
res.status(
|
|
114
|
+
res.status(409).json({ error: err.message });
|
|
19
115
|
}
|
|
20
116
|
});
|
|
21
117
|
|
|
@@ -26,6 +26,17 @@ export function registerTeamRoutes(app, daemon) {
|
|
|
26
26
|
}
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
+
// Reorder the sidebar list. Registered before /api/teams/:id so Express
|
|
30
|
+
// doesn't capture "reorder" as a team id.
|
|
31
|
+
app.post('/api/teams/reorder', (req, res) => {
|
|
32
|
+
try {
|
|
33
|
+
const teams = daemon.teams.reorder(req.body?.orderedIds);
|
|
34
|
+
res.json({ teams });
|
|
35
|
+
} catch (err) {
|
|
36
|
+
res.status(400).json({ error: err.message });
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
29
40
|
app.get('/api/teams/archived', (req, res) => {
|
|
30
41
|
res.json({ archived: daemon.teams.listArchived() });
|
|
31
42
|
});
|
|
@@ -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
|
+
}
|
|
@@ -113,6 +113,34 @@ export class Teams {
|
|
|
113
113
|
return team;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Reorder the team list. Teams persist as a JSON array, so Map insertion
|
|
118
|
+
* order is the display order — rebuilding the Map is the whole operation.
|
|
119
|
+
*
|
|
120
|
+
* Ids missing from `orderedIds` keep their relative order at the end, so a
|
|
121
|
+
* client working from a stale list can't drop teams.
|
|
122
|
+
*/
|
|
123
|
+
reorder(orderedIds) {
|
|
124
|
+
if (!Array.isArray(orderedIds)) throw new Error('orderedIds must be an array');
|
|
125
|
+
|
|
126
|
+
const seen = new Set();
|
|
127
|
+
const next = new Map();
|
|
128
|
+
for (const id of orderedIds) {
|
|
129
|
+
const team = this.teams.get(id);
|
|
130
|
+
if (!team || seen.has(id)) continue;
|
|
131
|
+
seen.add(id);
|
|
132
|
+
next.set(id, team);
|
|
133
|
+
}
|
|
134
|
+
for (const [id, team] of this.teams) {
|
|
135
|
+
if (!seen.has(id)) next.set(id, team);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
this.teams = next;
|
|
139
|
+
this._save();
|
|
140
|
+
this.daemon.broadcast({ type: 'teams:reordered', teams: this.list() });
|
|
141
|
+
return this.list();
|
|
142
|
+
}
|
|
143
|
+
|
|
116
144
|
get(id) {
|
|
117
145
|
return this.teams.get(id) || null;
|
|
118
146
|
}
|
|
@@ -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 };
|