groove-dev 0.27.181 → 0.27.183

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 (63) hide show
  1. package/node_modules/@groove-dev/cli/package.json +1 -1
  2. package/node_modules/@groove-dev/daemon/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/scripts/repair-tokens.mjs +78 -0
  4. package/node_modules/@groove-dev/daemon/src/api.js +1 -1
  5. package/node_modules/@groove-dev/daemon/src/deliver.js +130 -0
  6. package/node_modules/@groove-dev/daemon/src/index.js +8 -3
  7. package/node_modules/@groove-dev/daemon/src/innerchat.js +213 -62
  8. package/node_modules/@groove-dev/daemon/src/journalist.js +15 -8
  9. package/node_modules/@groove-dev/daemon/src/process.js +59 -34
  10. package/node_modules/@groove-dev/daemon/src/providers/claude-code.js +9 -1
  11. package/node_modules/@groove-dev/daemon/src/registry.js +5 -1
  12. package/node_modules/@groove-dev/daemon/src/rename.js +72 -0
  13. package/node_modules/@groove-dev/daemon/src/rotator.js +62 -5
  14. package/node_modules/@groove-dev/daemon/src/routes/agents.js +22 -100
  15. package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +12 -9
  16. package/node_modules/@groove-dev/daemon/src/teams.js +7 -1
  17. package/node_modules/@groove-dev/daemon/src/tokentracker.js +46 -6
  18. package/node_modules/@groove-dev/daemon/test/claude-code-provider.test.js +52 -0
  19. package/node_modules/@groove-dev/daemon/test/innerchat.test.js +197 -111
  20. package/node_modules/@groove-dev/daemon/test/journalist.test.js +4 -2
  21. package/node_modules/@groove-dev/daemon/test/rename.test.js +108 -0
  22. package/node_modules/@groove-dev/daemon/test/rotator.test.js +131 -0
  23. package/node_modules/@groove-dev/daemon/test/tokentracker.test.js +4 -0
  24. package/node_modules/@groove-dev/gui/dist/assets/{index-DTFtRtkx.css → index-CiOy7wVS.css} +1 -1
  25. package/node_modules/@groove-dev/gui/dist/assets/{index-CTer01Vg.js → index-DPjGBQ5X.js} +225 -225
  26. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  27. package/node_modules/@groove-dev/gui/package.json +1 -1
  28. package/node_modules/@groove-dev/gui/src/components/agents/agent-panel.jsx +3 -68
  29. package/node_modules/@groove-dev/gui/src/components/agents/innerchat-relay.jsx +145 -0
  30. package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +3 -1
  31. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-pane.jsx +15 -1
  32. package/node_modules/@groove-dev/gui/src/components/fleet/fleet-sidebar.jsx +33 -1
  33. package/node_modules/@groove-dev/gui/src/stores/groove.js +29 -44
  34. package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +27 -4
  35. package/package.json +1 -1
  36. package/packages/cli/package.json +1 -1
  37. package/packages/daemon/package.json +1 -1
  38. package/packages/daemon/scripts/repair-tokens.mjs +78 -0
  39. package/packages/daemon/src/api.js +1 -1
  40. package/packages/daemon/src/deliver.js +130 -0
  41. package/packages/daemon/src/index.js +8 -3
  42. package/packages/daemon/src/innerchat.js +213 -62
  43. package/packages/daemon/src/journalist.js +15 -8
  44. package/packages/daemon/src/process.js +59 -34
  45. package/packages/daemon/src/providers/claude-code.js +9 -1
  46. package/packages/daemon/src/registry.js +5 -1
  47. package/packages/daemon/src/rename.js +72 -0
  48. package/packages/daemon/src/rotator.js +62 -5
  49. package/packages/daemon/src/routes/agents.js +22 -100
  50. package/packages/daemon/src/routes/innerchat.js +12 -9
  51. package/packages/daemon/src/teams.js +7 -1
  52. package/packages/daemon/src/tokentracker.js +46 -6
  53. package/packages/gui/dist/assets/{index-DTFtRtkx.css → index-CiOy7wVS.css} +1 -1
  54. package/packages/gui/dist/assets/{index-CTer01Vg.js → index-DPjGBQ5X.js} +225 -225
  55. package/packages/gui/dist/index.html +2 -2
  56. package/packages/gui/package.json +1 -1
  57. package/packages/gui/src/components/agents/agent-panel.jsx +3 -68
  58. package/packages/gui/src/components/agents/innerchat-relay.jsx +145 -0
  59. package/packages/gui/src/components/chat/chat-messages.jsx +3 -1
  60. package/packages/gui/src/components/fleet/fleet-pane.jsx +15 -1
  61. package/packages/gui/src/components/fleet/fleet-sidebar.jsx +33 -1
  62. package/packages/gui/src/stores/groove.js +29 -44
  63. package/packages/gui/src/stores/slices/agents-slice.js +27 -4
@@ -12,6 +12,14 @@ const COLD_START_PER_FILE = 15;
12
12
  const COLD_START_PER_DIR = 40;
13
13
  // Estimated tokens wasted per file conflict (agent discovers, backs off, retries)
14
14
  const CONFLICT_OVERHEAD = 500;
15
+ // Max per-agent session records kept. Aggregates are cumulative and unaffected;
16
+ // windowed queries (getVelocity/getTokensInWindow) only need the recent tail,
17
+ // which 500 records covers by orders of magnitude. Uncapped, tokens.json grew
18
+ // to 13.8MB and was rewritten on every assistant stream event.
19
+ const SESSION_CAP = 500;
20
+ // Debounce window for persisting to disk. record() fires per assistant event —
21
+ // writing the full ledger each time is a hot-path full-file rewrite.
22
+ const SAVE_DEBOUNCE_MS = 5_000;
15
23
 
16
24
  export class TokenTracker {
17
25
  constructor(grooveDir) {
@@ -23,6 +31,7 @@ export class TokenTracker {
23
31
  this.coldStartsSkipped = 0;
24
32
  this.projectFiles = 0; // Set from indexer stats
25
33
  this.projectDirs = 0;
34
+ this._saveTimer = null;
26
35
  this.load();
27
36
  }
28
37
 
@@ -47,6 +56,7 @@ export class TokenTracker {
47
56
  if (!entry.totalDurationMs) entry.totalDurationMs = 0;
48
57
  if (!entry.totalTurns) entry.totalTurns = 0;
49
58
  if (!entry.modelDistribution) entry.modelDistribution = {};
59
+ this._capSessions(entry);
50
60
  }
51
61
  } catch {
52
62
  this.usage = {};
@@ -55,6 +65,10 @@ export class TokenTracker {
55
65
  }
56
66
 
57
67
  save() {
68
+ if (this._saveTimer) {
69
+ clearTimeout(this._saveTimer);
70
+ this._saveTimer = null;
71
+ }
58
72
  writeFileSync(this.path, JSON.stringify({
59
73
  usage: this.usage,
60
74
  rotationSavings: this.rotationSavings,
@@ -64,6 +78,30 @@ export class TokenTracker {
64
78
  }, null, 2));
65
79
  }
66
80
 
81
+ // Debounced persistence for hot-path recording. In-memory state is always
82
+ // current; at most SAVE_DEBOUNCE_MS of records are at risk on a crash.
83
+ _scheduleSave() {
84
+ if (this._saveTimer) return;
85
+ this._saveTimer = setTimeout(() => {
86
+ this._saveTimer = null;
87
+ try { this.save(); } catch { /* best-effort */ }
88
+ }, SAVE_DEBOUNCE_MS);
89
+ this._saveTimer.unref?.();
90
+ }
91
+
92
+ // Write any pending debounced state to disk. Call on daemon shutdown.
93
+ flush() {
94
+ if (this._saveTimer) {
95
+ try { this.save(); } catch { /* best-effort */ }
96
+ }
97
+ }
98
+
99
+ _capSessions(entry) {
100
+ if (Array.isArray(entry.sessions) && entry.sessions.length > SESSION_CAP) {
101
+ entry.sessions.splice(0, entry.sessions.length - SESSION_CAP);
102
+ }
103
+ }
104
+
67
105
  _initAgent(agentId) {
68
106
  if (!this.usage[agentId]) {
69
107
  this.usage[agentId] = {
@@ -89,7 +127,8 @@ export class TokenTracker {
89
127
  if (typeof detail === 'number') {
90
128
  entry.total += detail;
91
129
  entry.sessions.push({ tokens: detail, timestamp: new Date().toISOString() });
92
- this.save();
130
+ this._capSessions(entry);
131
+ this._scheduleSave();
93
132
  return;
94
133
  }
95
134
 
@@ -121,8 +160,9 @@ export class TokenTracker {
121
160
  projectRoot: detail.projectRoot || null,
122
161
  timestamp: new Date().toISOString(),
123
162
  });
163
+ this._capSessions(entry);
124
164
 
125
- this.save();
165
+ this._scheduleSave();
126
166
  }
127
167
 
128
168
  // Sum tokens recorded for an agent since a given timestamp.
@@ -152,25 +192,25 @@ export class TokenTracker {
152
192
  if (costUsd) entry.totalCostUsd += costUsd;
153
193
  if (durationMs) entry.totalDurationMs += durationMs;
154
194
  if (turns) entry.totalTurns += turns;
155
- this.save();
195
+ this._scheduleSave();
156
196
  }
157
197
 
158
198
  // Record that a rotation saved context tokens
159
199
  recordRotation(agentId, tokensBefore) {
160
200
  this.rotationSavings += Math.round(tokensBefore * 0.3); // ~30% of context was degraded
161
- this.save();
201
+ this._scheduleSave();
162
202
  }
163
203
 
164
204
  // Record that a conflict was prevented (scope enforcement)
165
205
  recordConflictPrevented() {
166
206
  this.conflictsPrevented++;
167
- this.save();
207
+ this._scheduleSave();
168
208
  }
169
209
 
170
210
  // Record that a cold-start was skipped (Journalist provided context)
171
211
  recordColdStartSkipped() {
172
212
  this.coldStartsSkipped++;
173
- this.save();
213
+ this._scheduleSave();
174
214
  }
175
215
 
176
216
  // Set project size from indexer for dynamic cold-start estimation
@@ -69,6 +69,58 @@ describe('ClaudeCodeProvider result parsing', () => {
69
69
  });
70
70
  });
71
71
 
72
+ describe('ClaudeCodeProvider resume handshake (CLI >= 2.1.212)', () => {
73
+ // The 0-turn no-op result the CLI emits ~180ms into --resume reconciliation.
74
+ const HANDSHAKE = JSON.stringify({
75
+ type: 'result', subtype: 'success', is_error: false,
76
+ duration_ms: 186, duration_api_ms: 0, num_turns: 0, total_cost_usd: 0,
77
+ });
78
+ const ABORT = JSON.stringify({
79
+ type: 'result', subtype: 'error_during_execution', is_error: true,
80
+ duration_ms: 5126, duration_api_ms: 0, num_turns: 2, total_cost_usd: 0,
81
+ terminal_reason: 'aborted_streaming',
82
+ });
83
+
84
+ // This is the gate the daemon uses to decide whether a result means "the
85
+ // turn completed". Treating the handshake as a completion arms a 5s
86
+ // force-kill that SIGTERMs the process mid-turn.
87
+ const reachedModel = (o) => o.apiDurationMs === undefined || o.apiDurationMs > 0;
88
+
89
+ it('does not count the resume handshake as a completed turn', () => {
90
+ const out = provider.parseOutput(HANDSHAKE);
91
+ assert.equal(out.isError, false);
92
+ assert.equal(reachedModel(out), false,
93
+ 'handshake must not arm the force-kill or clear the queued message');
94
+ });
95
+
96
+ it('counts a real turn as completed', () => {
97
+ const out = provider.parseOutput(JSON.stringify({
98
+ type: 'result', subtype: 'success', is_error: false,
99
+ duration_ms: 17850, duration_api_ms: 17672, num_turns: 3, total_cost_usd: 0.42,
100
+ }));
101
+ assert.equal(reachedModel(out), true);
102
+ });
103
+
104
+ it('providers without an API duration still count as completed', () => {
105
+ // codex / gemini / ollama don't report duration_api_ms — they must be
106
+ // unaffected by the guard.
107
+ const out = provider.parseOutput(JSON.stringify({
108
+ type: 'result', subtype: 'success', duration_ms: 1200, num_turns: 1,
109
+ }));
110
+ assert.equal(out.apiDurationMs, undefined);
111
+ assert.equal(reachedModel(out), true);
112
+ });
113
+
114
+ // Regression: taking the first result in a chunk let the handshake mask a
115
+ // subsequent abort, so the failure looked like a clean completion.
116
+ it('surfaces the abort when a chunk carries handshake + abort together', () => {
117
+ const out = provider.parseOutput(`${HANDSHAKE}\n${ABORT}`);
118
+ assert.equal(out.isError, true, 'abort must win over the no-op success result');
119
+ assert.equal(out.terminalReason, 'aborted_streaming');
120
+ assert.equal(reachedModel(out), false);
121
+ });
122
+ });
123
+
72
124
  describe('ClaudeCodeProvider models', () => {
73
125
  it('offers Fable 5', () => {
74
126
  assert.ok(ClaudeCodeProvider.models.some((m) => m.id === 'claude-fable-5'));
@@ -5,22 +5,33 @@ import { describe, it, beforeEach } from 'node:test';
5
5
  import assert from 'node:assert/strict';
6
6
  import { InnerChat } from '../src/innerchat.js';
7
7
 
8
+ // Mirrors the daemon surface deliverInstruction touches. Agents live in the
9
+ // registry; `_loops` marks an interactive loop, `_running` a busy CLI agent,
10
+ // and anything in neither is stopped (resume path, which mints a new id).
8
11
  function makeDaemon() {
9
12
  const broadcasts = [];
10
13
  const audits = [];
11
14
  const sentMessages = [];
12
15
  const queuedMessages = [];
16
+ const resumes = [];
17
+
18
+ let idCounter = 0;
13
19
 
14
20
  return {
15
21
  broadcasts,
16
22
  audits,
17
23
  sentMessages,
18
24
  queuedMessages,
25
+ resumes,
19
26
  registry: {
20
27
  _agents: new Map(),
21
28
  get(id) { return this._agents.get(id) || null; },
22
29
  add(agent) { this._agents.set(agent.id, agent); },
30
+ remove(id) { this._agents.delete(id); },
31
+ flushPendingRemovals() {},
32
+ update() {},
23
33
  },
34
+ locks: { release() {} },
24
35
  processes: {
25
36
  _loops: new Set(),
26
37
  _running: new Set(),
@@ -33,171 +44,246 @@ function makeDaemon() {
33
44
  queueMessage(id, msg) {
34
45
  queuedMessages.push({ id, msg });
35
46
  },
47
+ // Resume mints a new agent id, exactly as the real one does.
48
+ async resume(id, msg) {
49
+ const old = this._agentsRef.get(id);
50
+ const fresh = { ...old, id: `${id}-r${++idCounter}` };
51
+ this._agentsRef.delete(id);
52
+ this._agentsRef.set(fresh.id, fresh);
53
+ resumes.push({ id, msg, newId: fresh.id });
54
+ return fresh;
55
+ },
56
+ },
57
+ rotator: {
58
+ async rotate(id, opts) { return this._daemon.processes.resume(id, opts.additionalPrompt); },
36
59
  },
37
60
  broadcast(msg) { broadcasts.push(msg); },
38
61
  audit: { log(type, data) { audits.push({ type, data }); } },
39
62
  };
40
63
  }
41
64
 
65
+ function wire(daemon) {
66
+ daemon.processes._agentsRef = daemon.registry._agents;
67
+ daemon.rotator._daemon = daemon;
68
+ }
69
+
70
+ const result = (text) => ({ type: 'result', data: text });
71
+
42
72
  describe('InnerChat', () => {
43
73
  let daemon;
44
74
  let innerchat;
45
75
 
46
76
  beforeEach(() => {
47
77
  daemon = makeDaemon();
78
+ wire(daemon);
48
79
  innerchat = new InnerChat(daemon);
49
80
  daemon.innerchat = innerchat;
50
81
 
51
- daemon.registry.add({ id: 'a1', name: 'fullstack-1', role: 'fullstack' });
52
- daemon.registry.add({ id: 'a2', name: 'fullstack-14', role: 'fullstack' });
82
+ daemon.registry.add({ id: 'a1', name: 'fullstack-1', role: 'fullstack', provider: 'claude-code' });
83
+ daemon.registry.add({ id: 'a2', name: 'fullstack-14', role: 'fullstack', provider: 'claude-code' });
84
+ daemon.processes._loops.add('a1');
85
+ daemon.processes._running.add('a1');
53
86
  daemon.processes._loops.add('a2');
54
87
  daemon.processes._running.add('a2');
55
88
  });
56
89
 
57
- it('should send a message between agents', async () => {
58
- const msg = await innerchat.send('a1', 'a2', 'What endpoint shape are you using?');
59
- assert.ok(msg.id);
60
- assert.equal(msg.from.id, 'a1');
61
- assert.equal(msg.from.name, 'fullstack-1');
62
- assert.equal(msg.to.id, 'a2');
63
- assert.equal(msg.to.name, 'fullstack-14');
64
- assert.equal(msg.message, 'What endpoint shape are you using?');
65
- assert.equal(msg.response, null);
66
- assert.equal(msg.status, 'delivered');
67
- });
90
+ // ── Sending ───────────────────────────────────────────────
68
91
 
69
- it('should deliver via sendMessage when agent has loop', async () => {
70
- await innerchat.send('a1', 'a2', 'Hello');
71
- assert.equal(daemon.sentMessages.length, 1);
72
- assert.equal(daemon.sentMessages[0].id, 'a2');
73
- assert.ok(daemon.sentMessages[0].msg.includes('InnerChat from fullstack-1'));
74
- assert.ok(daemon.sentMessages[0].msg.includes('Hello'));
75
- });
92
+ it('opens a thread and delivers the relay to the target', async () => {
93
+ const { thread, turn } = await innerchat.send('a1', 'a2', 'What endpoint shape are you using?');
76
94
 
77
- it('should queue when agent is running but no loop', async () => {
78
- daemon.processes._loops.delete('a2');
79
- await innerchat.send('a1', 'a2', 'Hello');
80
- assert.equal(daemon.queuedMessages.length, 1);
81
- assert.equal(daemon.queuedMessages[0].id, 'a2');
95
+ assert.ok(thread.id);
96
+ assert.equal(thread.status, 'awaiting_reply');
97
+ assert.equal(thread.turns.length, 1);
98
+ assert.equal(turn.kind, 'relay');
99
+ assert.equal(turn.from.id, 'a1');
100
+ assert.equal(turn.to.id, 'a2');
101
+
102
+ const delivered = daemon.sentMessages.at(-1);
103
+ assert.equal(delivered.id, 'a2');
104
+ assert.match(delivered.msg, /InnerChat from fullstack-1/);
105
+ assert.match(delivered.msg, /What endpoint shape are you using\?/);
82
106
  });
83
107
 
84
- it('should throw when target agent is not running', async () => {
85
- daemon.processes._running.delete('a2');
86
- daemon.processes._loops.delete('a2');
87
- await assert.rejects(() => innerchat.send('a1', 'a2', 'Hello'), /not running/);
108
+ it('rejects unknown, self-addressed, and empty relays', async () => {
109
+ await assert.rejects(() => innerchat.send('nope', 'a2', 'hi'), /not found/);
110
+ await assert.rejects(() => innerchat.send('a1', 'nope', 'hi'), /not found/);
111
+ await assert.rejects(() => innerchat.send('a1', 'a1', 'hi'), /itself/);
112
+ await assert.rejects(() => innerchat.send('a1', 'a2', ' '), /message is required/);
88
113
  });
89
114
 
90
- it('should throw when sender does not exist', async () => {
91
- await assert.rejects(() => innerchat.send('unknown', 'a2', 'Hello'), /not found/);
115
+ it('rejects an unknown threadId', async () => {
116
+ await assert.rejects(() => innerchat.send('a1', 'a2', 'hi', 'bogus'), /not found/);
92
117
  });
93
118
 
94
- it('should throw when sending to self', async () => {
95
- // The route validates this, but let's verify it's caught
96
- daemon.processes._loops.add('a1');
97
- daemon.processes._running.add('a1');
98
- const msg = await innerchat.send('a1', 'a1', 'Hello');
99
- assert.ok(msg.id);
119
+ // ── Reply capture and auto-forward ────────────────────────
120
+
121
+ it('forwards the reply back to the asker automatically', async () => {
122
+ await innerchat.send('a1', 'a2', 'What shape?');
123
+ innerchat.onAgentOutput('a2', result('REST, /api/v2/orders'));
124
+ await new Promise((r) => setImmediate(r));
125
+
126
+ const forwarded = daemon.sentMessages.at(-1);
127
+ assert.equal(forwarded.id, 'a1');
128
+ assert.match(forwarded.msg, /InnerChat reply from fullstack-14/);
129
+ assert.match(forwarded.msg, /REST, \/api\/v2\/orders/);
100
130
  });
101
131
 
102
- it('should broadcast innerchat:sent on send', async () => {
103
- await innerchat.send('a1', 'a2', 'Hello');
104
- const sent = daemon.broadcasts.find(b => b.type === 'innerchat:sent');
105
- assert.ok(sent);
106
- assert.equal(sent.data.message, 'Hello');
132
+ it('ignores output from agents with no relay outstanding', () => {
133
+ const before = daemon.sentMessages.length;
134
+ innerchat.onAgentOutput('a2', result('unrelated work'));
135
+ assert.equal(daemon.sentMessages.length, before);
107
136
  });
108
137
 
109
- it('should capture response on agent output', async () => {
110
- const msg = await innerchat.send('a1', 'a2', 'What shape?');
111
- innerchat.onAgentOutput('a2', { type: 'result', data: 'GET /api/projects/:id returns { id, name }' });
112
- assert.equal(msg.status, 'responded');
113
- assert.equal(msg.response, 'GET /api/projects/:id returns { id, name }');
114
- assert.ok(msg.respondedAt);
138
+ it('ignores non-result and empty output', async () => {
139
+ await innerchat.send('a1', 'a2', 'What shape?');
140
+ innerchat.onAgentOutput('a2', { type: 'assistant', data: 'thinking...' });
141
+ innerchat.onAgentOutput('a2', result(' '));
142
+ await new Promise((r) => setImmediate(r));
143
+
144
+ assert.equal(daemon.sentMessages.at(-1).id, 'a2', 'no forward should have happened');
145
+ assert.ok(innerchat.getPending('a2'), 'relay is still outstanding');
115
146
  });
116
147
 
117
- it('should relay response back to sender', async () => {
118
- daemon.processes._loops.add('a1');
119
- daemon.processes._running.add('a1');
148
+ it('captures a reply only once', async () => {
120
149
  await innerchat.send('a1', 'a2', 'What shape?');
121
- daemon.sentMessages.length = 0;
150
+ innerchat.onAgentOutput('a2', result('first'));
151
+ innerchat.onAgentOutput('a2', result('second'));
152
+ await new Promise((r) => setImmediate(r));
122
153
 
123
- innerchat.onAgentOutput('a2', { type: 'result', data: 'The answer' });
124
- assert.equal(daemon.sentMessages.length, 1);
125
- assert.equal(daemon.sentMessages[0].id, 'a1');
126
- assert.ok(daemon.sentMessages[0].msg.includes('InnerChat reply from fullstack-14'));
127
- assert.ok(daemon.sentMessages[0].msg.includes('The answer'));
154
+ const forwards = daemon.sentMessages.filter((m) => /InnerChat reply/.test(m.msg));
155
+ assert.equal(forwards.length, 1);
156
+ assert.match(forwards[0].msg, /first/);
128
157
  });
129
158
 
130
- it('should ignore non-result output', async () => {
159
+ it('skips the in-flight result when the relay was queued behind live work', async () => {
160
+ // No loop, but running → the relay queues behind the current task.
161
+ daemon.processes._loops.delete('a2');
131
162
  await innerchat.send('a1', 'a2', 'What shape?');
132
- innerchat.onAgentOutput('a2', { type: 'activity', subtype: 'stream', data: 'partial...' });
133
- const msg = innerchat.getMessage(innerchat.getMessages()[0].id);
134
- assert.equal(msg.status, 'delivered');
135
- assert.equal(msg.response, null);
163
+ assert.equal(daemon.queuedMessages.at(-1).id, 'a2');
164
+
165
+ // This result belongs to the task that was already underway.
166
+ innerchat.onAgentOutput('a2', result('finished the previous task'));
167
+ await new Promise((r) => setImmediate(r));
168
+ assert.equal(daemon.sentMessages.filter((m) => /InnerChat reply/.test(m.msg)).length, 0);
169
+
170
+ // This one is the actual answer.
171
+ innerchat.onAgentOutput('a2', result('REST, /api/v2/orders'));
172
+ await new Promise((r) => setImmediate(r));
173
+ const forwarded = daemon.sentMessages.at(-1);
174
+ assert.equal(forwarded.id, 'a1');
175
+ assert.match(forwarded.msg, /REST, \/api\/v2\/orders/);
136
176
  });
137
177
 
138
- it('should ignore output from agents with no pending question', () => {
139
- innerchat.onAgentOutput('a2', { type: 'result', data: 'Some output' });
140
- assert.equal(daemon.broadcasts.length, 0);
178
+ // ── Stopped agents / id remapping ─────────────────────────
179
+
180
+ it('resumes a stopped target and tracks it under its new id', async () => {
181
+ daemon.processes._loops.delete('a2');
182
+ daemon.processes._running.delete('a2');
183
+
184
+ const { thread, turn } = await innerchat.send('a1', 'a2', 'ping');
185
+ const newId = daemon.resumes.at(-1).newId;
186
+
187
+ assert.notEqual(newId, 'a2');
188
+ assert.equal(turn.to.id, newId);
189
+ assert.ok(thread.participants.some((p) => p.id === newId));
190
+ assert.equal(innerchat.getPending('a2'), null, 'old id no longer tracked');
191
+ assert.ok(innerchat.getPending(newId), 'reply is awaited on the new id');
192
+
193
+ // The reply arrives on the new id and still forwards correctly.
194
+ daemon.processes._loops.add(newId);
195
+ innerchat.onAgentOutput(newId, result('pong'));
196
+ await new Promise((r) => setImmediate(r));
197
+ assert.match(daemon.sentMessages.at(-1).msg, /pong/);
141
198
  });
142
199
 
143
- it('should extract text from array-format output', async () => {
144
- await innerchat.send('a1', 'a2', 'Hello');
145
- innerchat.onAgentOutput('a2', {
146
- type: 'result',
147
- data: [
148
- { type: 'text', text: 'Part 1' },
149
- { type: 'tool_use', name: 'Read', input: {} },
150
- { type: 'text', text: 'Part 2' },
151
- ],
152
- });
153
- const msg = innerchat.getMessages()[0];
154
- assert.equal(msg.response, 'Part 1\nPart 2');
200
+ it('resumes a stopped asker to deliver the reply', async () => {
201
+ await innerchat.send('a1', 'a2', 'What shape?');
202
+ daemon.processes._loops.delete('a1');
203
+ daemon.processes._running.delete('a1');
204
+
205
+ innerchat.onAgentOutput('a2', result('REST'));
206
+ await new Promise((r) => setImmediate(r));
207
+
208
+ const resume = daemon.resumes.at(-1);
209
+ assert.equal(resume.id, 'a1');
210
+ assert.match(resume.msg, /InnerChat reply from fullstack-14/);
155
211
  });
156
212
 
157
- it('should broadcast innerchat:response on response', async () => {
158
- await innerchat.send('a1', 'a2', 'Hello');
159
- innerchat.onAgentOutput('a2', { type: 'result', data: 'Response here' });
160
- const resp = daemon.broadcasts.find(b => b.type === 'innerchat:response');
161
- assert.ok(resp);
162
- assert.equal(resp.data.response, 'Response here');
213
+ it('marks the turn failed when delivery throws', async () => {
214
+ daemon.processes.sendMessage = async () => { throw new Error('pipe closed'); };
215
+ await assert.rejects(() => innerchat.send('a1', 'a2', 'ping'), /pipe closed/);
216
+
217
+ const thread = innerchat.getThreads('a1')[0];
218
+ assert.equal(thread.status, 'failed');
219
+ assert.equal(thread.turns[0].status, 'failed');
220
+ assert.equal(thread.turns[0].error, 'pipe closed');
163
221
  });
164
222
 
165
- it('should list messages filtered by agent', async () => {
166
- daemon.processes._loops.add('a1');
167
- daemon.processes._running.add('a1');
168
- daemon.registry.add({ id: 'a3', name: 'backend-1', role: 'backend' });
223
+ // ── Threads ───────────────────────────────────────────────
224
+
225
+ it('continues a thread and replays prior turns as context', async () => {
226
+ const first = await innerchat.send('a1', 'a2', 'What shape?');
227
+ innerchat.onAgentOutput('a2', result('REST'));
228
+ await new Promise((r) => setImmediate(r));
229
+
230
+ await innerchat.send('a1', 'a2', 'Versioned?', first.thread.id);
231
+
232
+ const relay = daemon.sentMessages.filter((m) => /InnerChat from/.test(m.msg)).at(-1);
233
+ assert.match(relay.msg, /Earlier in this conversation/);
234
+ assert.match(relay.msg, /fullstack-1: What shape\?/);
235
+ assert.match(relay.msg, /fullstack-14: REST/);
236
+ assert.match(relay.msg, /Versioned\?/);
237
+
238
+ assert.equal(innerchat.getThreads().length, 1, 'reused the existing thread');
239
+ assert.equal(first.thread.turns.length, 3);
240
+ });
241
+
242
+ it('keeps concurrent relays to different agents separate', async () => {
243
+ daemon.registry.add({ id: 'a3', name: 'fullstack-9', role: 'fullstack', provider: 'claude-code' });
169
244
  daemon.processes._loops.add('a3');
170
245
  daemon.processes._running.add('a3');
171
246
 
172
- await innerchat.send('a1', 'a2', 'Message 1');
173
- await innerchat.send('a1', 'a3', 'Message 2');
247
+ const one = await innerchat.send('a1', 'a2', 'question for 14');
248
+ const two = await innerchat.send('a1', 'a3', 'question for 9');
249
+ assert.notEqual(one.thread.id, two.thread.id);
174
250
 
175
- assert.equal(innerchat.getMessages().length, 2);
176
- assert.equal(innerchat.getMessages('a2').length, 1);
177
- assert.equal(innerchat.getMessages('a3').length, 1);
178
- assert.equal(innerchat.getMessages('a1').length, 2);
179
- });
251
+ innerchat.onAgentOutput('a3', result('answer from 9'));
252
+ await new Promise((r) => setImmediate(r));
180
253
 
181
- it('should return pending question for agent', async () => {
182
- assert.equal(innerchat.getPending('a2'), null);
183
- await innerchat.send('a1', 'a2', 'Question?');
184
- const pending = innerchat.getPending('a2');
185
- assert.ok(pending);
186
- assert.equal(pending.message, 'Question?');
254
+ assert.equal(two.thread.turns.at(-1).text, 'answer from 9');
255
+ assert.equal(one.thread.turns.length, 1, 'the other thread is untouched');
256
+ assert.ok(innerchat.getPending('a2'), 'still awaiting the first reply');
187
257
  });
188
258
 
189
- it('should clear pending after response', async () => {
190
- await innerchat.send('a1', 'a2', 'Question?');
191
- innerchat.onAgentOutput('a2', { type: 'result', data: 'Answer' });
192
- assert.equal(innerchat.getPending('a2'), null);
259
+ it('filters threads by participant', async () => {
260
+ daemon.registry.add({ id: 'a3', name: 'fullstack-9', role: 'fullstack', provider: 'claude-code' });
261
+ daemon.processes._loops.add('a3');
262
+ daemon.processes._running.add('a3');
263
+
264
+ await innerchat.send('a1', 'a2', 'hi');
265
+ await innerchat.send('a1', 'a3', 'hi');
266
+
267
+ assert.equal(innerchat.getThreads().length, 2);
268
+ assert.equal(innerchat.getThreads('a1').length, 2);
269
+ assert.equal(innerchat.getThreads('a2').length, 1);
270
+ assert.equal(innerchat.getThreads('a3').length, 1);
271
+ assert.equal(innerchat.getThreads('nobody').length, 0);
193
272
  });
194
273
 
195
- it('should audit send and response events', async () => {
196
- await innerchat.send('a1', 'a2', 'Hello');
197
- innerchat.onAgentOutput('a2', { type: 'result', data: 'Reply' });
198
- const sendAudit = daemon.audits.find(a => a.type === 'innerchat.send');
199
- const respAudit = daemon.audits.find(a => a.type === 'innerchat.response');
200
- assert.ok(sendAudit);
201
- assert.ok(respAudit);
274
+ // ── Broadcast & audit ─────────────────────────────────────
275
+
276
+ it('broadcasts and audits both hops', async () => {
277
+ await innerchat.send('a1', 'a2', 'What shape?');
278
+ innerchat.onAgentOutput('a2', result('REST'));
279
+ await new Promise((r) => setImmediate(r));
280
+
281
+ const turns = daemon.broadcasts.filter((b) => b.type === 'innerchat:turn');
282
+ assert.equal(turns.length, 2);
283
+ assert.equal(turns[0].data.turn.kind, 'relay');
284
+ assert.equal(turns[1].data.turn.kind, 'reply');
285
+
286
+ assert.ok(daemon.audits.some((a) => a.type === 'innerchat.send'));
287
+ assert.ok(daemon.audits.some((a) => a.type === 'innerchat.reply'));
202
288
  });
203
289
  });
@@ -174,13 +174,15 @@ describe('Journalist', () => {
174
174
  const { daemon, grooveDir } = createMockDaemon();
175
175
  const journalist = new Journalist(daemon);
176
176
 
177
- // Create a mock log — filename must match agent.id ('a1')
177
+ // Create a mock log — filename must match agent.name ('backend-1'),
178
+ // mirroring the writer in process.js (logs are keyed by sanitized name
179
+ // so rotated agents keep appending to the same file)
178
180
  const logLines = [
179
181
  JSON.stringify({ type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Write', input: { file_path: 'src/api/auth.js' } }] } }),
180
182
  JSON.stringify({ type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Edit', input: { file_path: 'src/api/users.js', old_string: 'old', new_string: 'new' } }] } }),
181
183
  JSON.stringify({ type: 'user', message: { content: 'Add JWT middleware to the auth route' } }),
182
184
  ].join('\n');
183
- writeFileSync(join(grooveDir, 'logs', 'a1.log'), logLines);
185
+ writeFileSync(join(grooveDir, 'logs', 'backend-1.log'), logLines);
184
186
 
185
187
  const agent = {
186
188
  id: 'a1', name: 'backend-1', role: 'backend',