groove-dev 0.27.182 → 0.27.184

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 (57) hide show
  1. package/CLAUDE.md +0 -7
  2. package/node_modules/@groove-dev/cli/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/package.json +1 -1
  4. package/node_modules/@groove-dev/daemon/src/deliver.js +130 -0
  5. package/node_modules/@groove-dev/daemon/src/index.js +7 -3
  6. package/node_modules/@groove-dev/daemon/src/innerchat-docs.js +41 -0
  7. package/node_modules/@groove-dev/daemon/src/innerchat.js +267 -60
  8. package/node_modules/@groove-dev/daemon/src/introducer.js +30 -1
  9. package/node_modules/@groove-dev/daemon/src/process.js +22 -1
  10. package/node_modules/@groove-dev/daemon/src/registry.js +5 -1
  11. package/node_modules/@groove-dev/daemon/src/rename.js +72 -0
  12. package/node_modules/@groove-dev/daemon/src/routes/agents.js +22 -100
  13. package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +63 -16
  14. package/node_modules/@groove-dev/daemon/src/routes/teams.js +11 -0
  15. package/node_modules/@groove-dev/daemon/src/teams.js +35 -1
  16. package/node_modules/@groove-dev/daemon/test/innerchat.test.js +193 -134
  17. package/node_modules/@groove-dev/daemon/test/rename.test.js +108 -0
  18. package/node_modules/@groove-dev/daemon/test/teams.test.js +53 -0
  19. package/node_modules/@groove-dev/gui/dist/assets/{index-CTer01Vg.js → index-BpOyN6Zf.js} +227 -227
  20. package/node_modules/@groove-dev/gui/dist/assets/index-DiXB7yry.css +1 -0
  21. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  22. package/node_modules/@groove-dev/gui/package.json +1 -1
  23. package/node_modules/@groove-dev/gui/src/components/agents/agent-panel.jsx +2 -95
  24. package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +3 -1
  25. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-agent-row.jsx +18 -2
  26. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-sidebar.jsx +56 -6
  27. package/node_modules/@groove-dev/gui/src/stores/groove.js +35 -44
  28. package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +27 -4
  29. package/node_modules/@groove-dev/gui/src/stores/slices/teams-slice.js +22 -0
  30. package/package.json +1 -1
  31. package/packages/cli/package.json +1 -1
  32. package/packages/daemon/package.json +1 -1
  33. package/packages/daemon/src/deliver.js +130 -0
  34. package/packages/daemon/src/index.js +7 -3
  35. package/packages/daemon/src/innerchat-docs.js +41 -0
  36. package/packages/daemon/src/innerchat.js +267 -60
  37. package/packages/daemon/src/introducer.js +30 -1
  38. package/packages/daemon/src/process.js +22 -1
  39. package/packages/daemon/src/registry.js +5 -1
  40. package/packages/daemon/src/rename.js +72 -0
  41. package/packages/daemon/src/routes/agents.js +22 -100
  42. package/packages/daemon/src/routes/innerchat.js +63 -16
  43. package/packages/daemon/src/routes/teams.js +11 -0
  44. package/packages/daemon/src/teams.js +35 -1
  45. package/packages/gui/dist/assets/{index-CTer01Vg.js → index-BpOyN6Zf.js} +227 -227
  46. package/packages/gui/dist/assets/index-DiXB7yry.css +1 -0
  47. package/packages/gui/dist/index.html +2 -2
  48. package/packages/gui/package.json +1 -1
  49. package/packages/gui/src/components/agents/agent-panel.jsx +2 -95
  50. package/packages/gui/src/components/chat/chat-messages.jsx +3 -1
  51. package/packages/gui/src/components/fleet/fleet-agent-row.jsx +18 -2
  52. package/packages/gui/src/components/fleet/fleet-sidebar.jsx +56 -6
  53. package/packages/gui/src/stores/groove.js +35 -44
  54. package/packages/gui/src/stores/slices/agents-slice.js +27 -4
  55. package/packages/gui/src/stores/slices/teams-slice.js +22 -0
  56. package/node_modules/@groove-dev/gui/dist/assets/index-DTFtRtkx.css +0 -1
  57. package/packages/gui/dist/assets/index-DTFtRtkx.css +0 -1
@@ -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 } 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');
@@ -320,7 +321,7 @@ const PERMISSION_PROMPTS = {
320
321
  supervised: null, // Maps to auto (supervised removed — too expensive)
321
322
  };
322
323
 
323
- function sanitizeFilename(name) {
324
+ export function sanitizeFilename(name) {
324
325
  return String(name).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);
325
326
  }
326
327
 
@@ -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', {
@@ -1152,6 +1160,19 @@ For normal file edits within your scope, proceed without review.
1152
1160
  }
1153
1161
  }
1154
1162
 
1163
+ // InnerChat — let the agent consult other agents directly.
1164
+ // Same constraint as PM review: the agent calls back over HTTP, so
1165
+ // sandboxed providers (Codex) can't reach it.
1166
+ if (!sandboxedProviders.includes(providerName) && !isOneShotProvider) {
1167
+ const port = this.daemon.port || 31415;
1168
+ const innerChatPrompt = innerChatInstructions(port, agent.name).join('\n') + '\n\n';
1169
+ if (spawnConfig.prompt.startsWith('# Handoff Brief')) {
1170
+ spawnConfig.prompt += '\n\n' + innerChatPrompt.trim();
1171
+ } else {
1172
+ spawnConfig.prompt = innerChatPrompt + spawnConfig.prompt;
1173
+ }
1174
+ }
1175
+
1155
1176
  // Set up log capture (shared between CLI and agent loop paths)
1156
1177
  const logDir = resolve(this.daemon.grooveDir, 'logs');
1157
1178
  mkdirSync(logDir, { recursive: true });
@@ -79,12 +79,16 @@ export class Registry extends EventEmitter {
79
79
  return Array.from(this.agents.values());
80
80
  }
81
81
 
82
- update(id, updates) {
82
+ update(id, updates, { allowRename = false } = {}) {
83
83
  const agent = this.agents.get(id);
84
84
  if (!agent) return null;
85
85
 
86
86
  // Only allow known fields to prevent prototype pollution
87
87
  for (const key of Object.keys(updates)) {
88
+ // Logs, personalities and agent-files are keyed by name — renaming
89
+ // without migrating them orphans the lot, and the GC then deletes it.
90
+ // renameAgent() (rename.js) does the migration and opts in here.
91
+ if (key === 'name' && !allowRename) continue;
88
92
  if (SAFE_FIELDS.has(key)) {
89
93
  agent[key] = updates[key];
90
94
  }
@@ -0,0 +1,72 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+
3
+ import { resolve } from 'path';
4
+ import { existsSync, renameSync } from 'fs';
5
+ import { sanitizeFilename } from './process.js';
6
+
7
+ /**
8
+ * Rename an agent, migrating everything keyed by its name.
9
+ *
10
+ * Agent logs, personalities and scratch files are keyed by NAME rather than id
11
+ * on purpose — rotation mints a new id, and name-keying is what carries an
12
+ * agent's history across it (see agentLogPath in process.js). The cost is that
13
+ * a bare rename orphans all of it, and the log GC then deletes the orphan. So
14
+ * a rename has to move those artifacts itself.
15
+ *
16
+ * Note the live process keeps the old name in its env (GROOVE_AGENT_NAME) and
17
+ * system prompt until it next respawns — the daemon-side view is what changes.
18
+ */
19
+ export function renameAgent(daemon, agentId, newName) {
20
+ const agent = daemon.registry.get(agentId);
21
+ if (!agent) throw new Error('Agent not found');
22
+
23
+ const trimmed = String(newName || '').trim();
24
+ if (!trimmed) throw new Error('name is required');
25
+
26
+ // The name becomes a path segment (agent-files/<name>, personalities/<name>.md),
27
+ // so anything with a separator or a dot-segment would escape the directory.
28
+ if (!/^[A-Za-z0-9._-]+$/.test(trimmed) || /^\.+$/.test(trimmed)) {
29
+ throw new Error('name may only contain letters, numbers, dots, dashes and underscores');
30
+ }
31
+ if (trimmed.length > 64) throw new Error('name must be 64 characters or fewer');
32
+
33
+ if (trimmed === agent.name) return agent;
34
+
35
+ // Two agents sharing a name means two agents sharing one log file.
36
+ const collision = daemon.registry.getAll()
37
+ .some((a) => a.id !== agentId && a.name === trimmed);
38
+ if (collision) throw new Error(`An agent named ${trimmed} already exists`);
39
+
40
+ const oldName = agent.name;
41
+ const { grooveDir, projectDir } = daemon;
42
+
43
+ const moves = [
44
+ // Raw log — the one whose loss breaks chat resume and synthesis.
45
+ [resolve(grooveDir, 'logs', `${sanitizeFilename(oldName)}.log`),
46
+ resolve(grooveDir, 'logs', `${sanitizeFilename(trimmed)}.log`)],
47
+ [resolve(grooveDir, 'personalities', `${oldName}.md`),
48
+ resolve(grooveDir, 'personalities', `${trimmed}.md`)],
49
+ [resolve(projectDir, 'agent-files', oldName),
50
+ resolve(projectDir, 'agent-files', trimmed)],
51
+ ];
52
+
53
+ const moved = [];
54
+ try {
55
+ for (const [from, to] of moves) {
56
+ if (!existsSync(from) || existsSync(to)) continue;
57
+ renameSync(from, to);
58
+ moved.push([from, to]);
59
+ }
60
+ } catch (err) {
61
+ // Roll back so a partial migration can't leave artifacts split across
62
+ // two names — that's the state the GC would then eat.
63
+ for (const [from, to] of moved.reverse()) {
64
+ try { renameSync(to, from); } catch { /* best effort */ }
65
+ }
66
+ throw new Error(`Rename failed while migrating files: ${err.message}`);
67
+ }
68
+
69
+ const updated = daemon.registry.update(agentId, { name: trimmed }, { allowRename: true });
70
+ daemon.audit.log('agent.rename', { id: agentId, from: oldName, to: trimmed });
71
+ return updated;
72
+ }
@@ -5,6 +5,8 @@ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, mkdirSy
5
5
  import { validateAgentConfig, validateReasoningEffort, validateVerbosity } from '../validate.js';
6
6
  import { ROLE_INTEGRATIONS, wrapWithRoleReminder } from '../process.js';
7
7
  import { getProvider } from '../providers/index.js';
8
+ import { deliverInstruction } from '../deliver.js';
9
+ import { renameAgent } from '../rename.js';
8
10
 
9
11
  export function registerAgentRoutes(app, daemon) {
10
12
  // List all agents
@@ -56,9 +58,21 @@ export function registerAgentRoutes(app, daemon) {
56
58
 
57
59
  // Update agent
58
60
  app.patch('/api/agents/:id', (req, res) => {
59
- const agent = daemon.registry.update(req.params.id, req.body);
60
- if (!agent) return res.status(404).json({ error: 'Agent not found' });
61
- res.json(agent);
61
+ try {
62
+ const { name, ...rest } = req.body || {};
63
+
64
+ // Renames migrate name-keyed files, so they can't go through the plain
65
+ // field update — registry.update ignores `name` without the opt-in.
66
+ let agent = daemon.registry.get(req.params.id);
67
+ if (!agent) return res.status(404).json({ error: 'Agent not found' });
68
+
69
+ if (Object.keys(rest).length) agent = daemon.registry.update(req.params.id, rest);
70
+ if (typeof name === 'string') agent = renameAgent(daemon, req.params.id, name);
71
+
72
+ res.json(agent);
73
+ } catch (err) {
74
+ res.status(400).json({ error: err.message });
75
+ }
62
76
  });
63
77
 
64
78
  // Kill an agent (add ?purge=true to also remove from registry)
@@ -426,104 +440,12 @@ export function registerAgentRoutes(app, daemon) {
426
440
  }
427
441
  }
428
442
 
429
- // Record user feedback so the journalist can include it in future agent context
430
- if (daemon.journalist) daemon.journalist.recordUserFeedback(agent, finalMessage);
431
- if (daemon.rotator) daemon.rotator.recordUserMessage(req.params.id);
432
-
433
- // Agent loop path — send message directly to the running loop
434
- const wrappedMessage = wrapWithRoleReminder(agent.role, finalMessage);
435
- if (daemon.processes.hasAgentLoop(req.params.id)) {
436
- const sent = await daemon.processes.sendMessage(req.params.id, wrappedMessage);
437
- if (sent) {
438
- daemon.audit.log('agent.chat', { id: req.params.id });
439
- return res.json({ id: agent.id, status: 'message_sent' });
440
- }
441
- // Loop exists but not running — fall through to resume/rotate
442
- }
443
-
444
- // One-shot providers (groove-network): kill any running instance and
445
- // respawn with the user's message as --prompt. No handoff brief, no
446
- // session resume, no message queue — each chat message is a fresh spawn.
447
- const provider = getProvider(agent.provider);
448
- if (provider?.constructor?.isOneShot) {
449
- const oldConfig = { ...agent };
450
- if (daemon.processes.isRunning(req.params.id)) {
451
- await daemon.processes.kill(req.params.id);
452
- }
453
- daemon.registry.remove(req.params.id, { silent: true });
454
- daemon.locks.release(req.params.id);
455
-
456
- let newAgent;
457
- try {
458
- newAgent = await daemon.processes.spawn({
459
- role: oldConfig.role,
460
- scope: oldConfig.scope,
461
- provider: oldConfig.provider,
462
- model: oldConfig.model,
463
- prompt: finalMessage,
464
- permission: oldConfig.permission || 'full',
465
- workingDir: oldConfig.workingDir,
466
- name: oldConfig.name,
467
- teamId: oldConfig.teamId,
468
- });
469
- } catch (spawnErr) {
470
- daemon.registry.flushPendingRemovals();
471
- throw spawnErr;
472
- }
473
- daemon.audit.log('agent.instruct', { id: req.params.id, newId: newAgent.id, resumed: false });
474
- return res.json(newAgent);
475
- }
476
-
477
- // Non-interactive CLI providers (e.g. Gemini): respawn with the new
478
- // message as the prompt, preserving original introContext. These providers
479
- // run one prompt per spawn and cannot resume sessions.
480
- if (provider?.constructor?.nonInteractive && !daemon.processes.isRunning(req.params.id)) {
481
- const oldConfig = { ...agent };
482
- daemon.registry.remove(req.params.id, { silent: true });
483
- daemon.locks.release(req.params.id);
484
-
485
- let newAgent;
486
- try {
487
- newAgent = await daemon.processes.spawn({
488
- role: oldConfig.role,
489
- scope: oldConfig.scope,
490
- provider: oldConfig.provider,
491
- model: oldConfig.model,
492
- prompt: finalMessage,
493
- introContext: oldConfig.introContext,
494
- permission: oldConfig.permission || 'full',
495
- workingDir: oldConfig.workingDir,
496
- name: oldConfig.name,
497
- teamId: oldConfig.teamId,
498
- });
499
- } catch (spawnErr) {
500
- daemon.registry.flushPendingRemovals();
501
- throw spawnErr;
502
- }
503
- daemon.audit.log('agent.instruct', { id: req.params.id, newId: newAgent.id, resumed: false });
504
- return res.json(newAgent);
505
- }
506
-
507
- // Running CLI agent (no loop) — queue the message for delivery after
508
- // the current task completes instead of killing and respawning.
509
- if (daemon.processes.isRunning(req.params.id)) {
510
- daemon.processes.queueMessage(req.params.id, wrappedMessage);
511
- daemon.audit.log('agent.chat.queued', { id: req.params.id });
512
- return res.json({ id: agent.id, status: 'message_queued' });
513
- }
443
+ const result = await deliverInstruction(daemon, req.params.id, finalMessage);
514
444
 
515
- // CLI agent path session resume or rotation.
516
- // Force rotation (fresh session + handoff brief) past the resume ceiling:
517
- // reviving a >5M-token claude session has crashed the CLI mid-HTTP-parse
518
- // (V8 fatal in JsonStringifier) the rotator's handoff brief sidesteps that.
519
- const SESSION_RESUME_CEILING = 5_000_000;
520
- const resumed = !!agent.sessionId && (agent.tokensUsed || 0) < SESSION_RESUME_CEILING;
521
- const newAgent = resumed
522
- ? await daemon.processes.resume(req.params.id, wrappedMessage)
523
- : await daemon.rotator.rotate(req.params.id, { additionalPrompt: wrappedMessage });
524
-
525
- daemon.audit.log('agent.instruct', { id: req.params.id, newId: newAgent.id, resumed });
526
- res.json(newAgent);
445
+ // Respawn paths return the fresh agent record; in-place delivery just
446
+ // acknowledges with a status the GUI uses to show queued vs sent.
447
+ if (result.agent && result.agentId !== req.params.id) return res.json(result.agent);
448
+ res.json({ id: agent.id, status: result.status });
527
449
  } catch (err) {
528
450
  res.status(400).json({ error: err.message });
529
451
  }
@@ -1,30 +1,77 @@
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 — resolve either, preferring an
6
+ // exact name match so `fullstack-1` never resolves to `fullstack-14`.
7
+ function resolveAgent(daemon, ref) {
8
+ if (!ref || typeof ref !== 'string') return null;
9
+ const all = daemon.registry.getAll();
10
+ return all.find((a) => a.id === ref)
11
+ || all.find((a) => a.name === ref)
12
+ || all.find((a) => a.name.toLowerCase() === ref.toLowerCase())
13
+ || null;
14
+ }
15
+
3
16
  export function registerInnerChatRoutes(app, daemon) {
4
- app.post('/api/innerchat/send', async (req, res) => {
17
+ /**
18
+ * Ask another agent a question and BLOCK until it answers.
19
+ *
20
+ * 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.
23
+ */
24
+ app.post('/api/innerchat/ask', async (req, res) => {
5
25
  try {
6
- const { from, to, message } = req.body;
7
- if (!from || typeof from !== 'string') return res.status(400).json({ error: 'from (agent ID) is required' });
8
- if (!to || typeof to !== 'string') return res.status(400).json({ error: 'to (agent ID) is required' });
9
- if (!message || typeof message !== 'string' || !message.trim()) return res.status(400).json({ error: 'message is required' });
10
- if (from === to) return res.status(400).json({ error: 'cannot send a message to yourself' });
11
-
12
- const msg = await daemon.innerchat.send(from, to, message.trim());
13
- res.json(msg);
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
+ }
45
+
46
+ // Held open until the target answers — see the class doc in innerchat.js.
47
+ req.setTimeout(0);
48
+ res.setTimeout(0);
49
+
50
+ const result = await daemon.innerchat.ask(fromAgent.id, toAgent.id, message.trim(), { timeoutMs });
51
+
52
+ res.json({
53
+ from: toAgent.name,
54
+ reply: result.reply,
55
+ threadId: result.threadId,
56
+ exchangesUsed: result.exchanges,
57
+ exchangesRemaining: result.remaining,
58
+ maxExchanges: MAX_EXCHANGES,
59
+ });
14
60
  } catch (err) {
15
- res.status(400).json({ error: err.message });
61
+ // The agent reads this body — keep it actionable, it's the whole signal.
62
+ res.status(409).json({ error: err.message });
16
63
  }
17
64
  });
18
65
 
19
- app.get('/api/innerchat/messages', (req, res) => {
66
+ app.get('/api/innerchat/threads', (req, res) => {
20
67
  const { agentId } = req.query;
21
- res.json({ messages: daemon.innerchat.getMessages(agentId || null) });
68
+ res.json({ threads: daemon.innerchat.getThreads(agentId || null) });
22
69
  });
23
70
 
24
- app.get('/api/innerchat/messages/:id', (req, res) => {
25
- const msg = daemon.innerchat.getMessage(req.params.id);
26
- if (!msg) return res.status(404).json({ error: 'Message not found' });
27
- res.json(msg);
71
+ app.get('/api/innerchat/threads/:id', (req, res) => {
72
+ const thread = daemon.innerchat.getThread(req.params.id);
73
+ if (!thread) return res.status(404).json({ error: 'Thread not found' });
74
+ res.json(thread);
28
75
  });
29
76
 
30
77
  app.get('/api/innerchat/pending/:agentId', (req, res) => {
@@ -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
  });
@@ -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
  }
@@ -148,11 +176,17 @@ export class Teams {
148
176
  renameSync(oldWorkingDir, newWorkingDir);
149
177
  team.workingDir = newWorkingDir;
150
178
 
151
- // Update all agents in this team with the new working directory
179
+ // Repoint every agent under the old tree, not just those sitting at
180
+ // its root — an agent in a subdirectory would otherwise keep a path
181
+ // that no longer exists.
152
182
  const agents = this.daemon.registry.getAll().filter((a) => a.teamId === id);
153
183
  for (const agent of agents) {
184
+ if (!agent.workingDir) continue;
154
185
  if (agent.workingDir === oldWorkingDir) {
155
186
  this.daemon.registry.update(agent.id, { workingDir: newWorkingDir });
187
+ } else if (agent.workingDir.startsWith(`${oldWorkingDir}/`)) {
188
+ const suffix = agent.workingDir.slice(oldWorkingDir.length);
189
+ this.daemon.registry.update(agent.id, { workingDir: `${newWorkingDir}${suffix}` });
156
190
  }
157
191
  }
158
192
  } catch (err) {