groove-dev 0.27.37 → 0.27.40

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 (39) hide show
  1. package/README.md +3 -3
  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/api.js +91 -11
  5. package/node_modules/@groove-dev/daemon/src/index.js +3 -0
  6. package/node_modules/@groove-dev/daemon/src/lockmanager.js +44 -0
  7. package/node_modules/@groove-dev/daemon/src/memory.js +22 -5
  8. package/node_modules/@groove-dev/daemon/src/preview.js +249 -0
  9. package/node_modules/@groove-dev/daemon/src/process.js +145 -7
  10. package/node_modules/@groove-dev/daemon/src/providers/claude-code.js +37 -1
  11. package/node_modules/@groove-dev/daemon/src/rotator.js +18 -3
  12. package/node_modules/@groove-dev/daemon/templates/knock-hook.cjs +44 -0
  13. package/node_modules/@groove-dev/gui/dist/assets/{index-Df4O6yJI.js → index-zzVaD3-G.js} +3 -3
  14. package/node_modules/@groove-dev/gui/dist/index.html +1 -1
  15. package/node_modules/@groove-dev/gui/package.json +1 -1
  16. package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +2 -2
  17. package/node_modules/@groove-dev/gui/src/components/onboarding/setup-wizard.jsx +1 -1
  18. package/node_modules/@groove-dev/gui/src/components/ui/toast.jsx +12 -0
  19. package/node_modules/@groove-dev/gui/src/stores/groove.js +42 -4
  20. package/package.json +1 -1
  21. package/packages/cli/package.json +1 -1
  22. package/packages/daemon/package.json +1 -1
  23. package/packages/daemon/src/api.js +91 -11
  24. package/packages/daemon/src/index.js +3 -0
  25. package/packages/daemon/src/lockmanager.js +44 -0
  26. package/packages/daemon/src/memory.js +22 -5
  27. package/packages/daemon/src/preview.js +249 -0
  28. package/packages/daemon/src/process.js +145 -7
  29. package/packages/daemon/src/providers/claude-code.js +37 -1
  30. package/packages/daemon/src/rotator.js +18 -3
  31. package/packages/daemon/templates/knock-hook.cjs +44 -0
  32. package/packages/gui/dist/assets/{index-Df4O6yJI.js → index-zzVaD3-G.js} +3 -3
  33. package/packages/gui/dist/index.html +1 -1
  34. package/packages/gui/package.json +1 -1
  35. package/packages/gui/src/components/layout/activity-bar.jsx +2 -2
  36. package/packages/gui/src/components/onboarding/setup-wizard.jsx +1 -1
  37. package/packages/gui/src/components/ui/toast.jsx +12 -0
  38. package/packages/gui/src/stores/groove.js +42 -4
  39. package/plans/chat-persistence-refactor.md +0 -154
@@ -6,7 +6,7 @@
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <link rel="icon" type="image/png" href="/favicon.png" />
8
8
  <title>Groove GUI</title>
9
- <script type="module" crossorigin src="/assets/index-Df4O6yJI.js"></script>
9
+ <script type="module" crossorigin src="/assets/index-zzVaD3-G.js"></script>
10
10
  <link rel="modulepreload" crossorigin href="/assets/vendor-C0HXlhrU.js">
11
11
  <link rel="modulepreload" crossorigin href="/assets/reactflow-BQPfi37R.js">
12
12
  <link rel="modulepreload" crossorigin href="/assets/codemirror-BBL3i_JW.js">
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/gui",
3
- "version": "0.27.37",
3
+ "version": "0.27.40",
4
4
  "description": "GROOVE GUI — visual agent control plane",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -25,9 +25,9 @@ export function ActivityBar({ activeView, detailPanel, onNavigate, onTogglePanel
25
25
 
26
26
  return (
27
27
  <nav className="w-12 flex-shrink-0 flex flex-col bg-surface-3 border-r border-border">
28
- {/* Sidebar header — aligns with BreadcrumbBar */}
28
+ {/* Sidebar header — no border (can't cleanly match BreadcrumbBar border due to h-9 vs h-11) */}
29
29
  {darwinTrafficLights && (
30
- <div className="flex-shrink-0 h-9 flex items-end justify-center pb-1.5 border-b border-border">
30
+ <div className="flex-shrink-0 h-9 flex items-end justify-center pb-0.5">
31
31
  <img src="/favicon.png" alt="Groove" className="h-6 w-6 rounded-full" />
32
32
  </div>
33
33
  )}
@@ -19,7 +19,7 @@ const PROVIDERS = [
19
19
  id: 'claude-code',
20
20
  name: 'Claude Code',
21
21
  subtitle: 'by Anthropic',
22
- models: ['Opus 4.6', 'Sonnet 4.6', 'Haiku 4.5'],
22
+ models: ['Opus 4.7', 'Opus 4.6', 'Sonnet 4.6', 'Haiku 4.5'],
23
23
  authType: 'Subscription or API key',
24
24
  authModes: ['subscription', 'apikey'],
25
25
  recommended: true,
@@ -64,6 +64,18 @@ function ToastItem({ toast }) {
64
64
  <p className="text-xs text-text-3 font-sans mt-0.5">{toast.detail}</p>
65
65
  )}
66
66
  </div>
67
+ {toast.action?.url && (
68
+ <button
69
+ onClick={(e) => {
70
+ e.stopPropagation();
71
+ try { window.open(toast.action.url, '_blank', 'noopener'); } catch {}
72
+ removeToast(toast.id);
73
+ }}
74
+ className="text-xs font-medium text-accent hover:text-accent-hover bg-surface-5 hover:bg-surface-6 px-3 py-1.5 rounded transition-colors cursor-pointer flex-shrink-0 whitespace-nowrap"
75
+ >
76
+ {toast.action.label || 'Open'}
77
+ </button>
78
+ )}
67
79
  <button
68
80
  onClick={(e) => { e.stopPropagation(); removeToast(toast.id); }}
69
81
  className="p-1.5 text-text-4 hover:text-text-1 hover:bg-surface-5 rounded transition-colors cursor-pointer flex-shrink-0 z-10"
@@ -381,6 +381,39 @@ export const useGrooveStore = create((set, get) => ({
381
381
  get().addToast('info', `QC agent ${msg.name} auto-spawned`, 'Auditing phase 1 work');
382
382
  break;
383
383
 
384
+ case 'preview:ready':
385
+ get().addToast(
386
+ 'success',
387
+ 'Project ready to preview',
388
+ msg.url,
389
+ { label: 'View Site', url: msg.url },
390
+ );
391
+ break;
392
+
393
+ case 'preview:failed':
394
+ get().addToast(
395
+ 'warning',
396
+ 'Preview could not launch',
397
+ msg.reason ? String(msg.reason).slice(0, 200) : 'Unknown error',
398
+ );
399
+ break;
400
+
401
+ case 'preview:stopped':
402
+ break;
403
+
404
+ case 'agent:stalled': {
405
+ const name = msg.agentName || msg.agentId;
406
+ const secs = Math.round((msg.silentMs || 0) / 1000);
407
+ get().addToast('warning', `${name} may be stalled`, `No output for ${secs}s — API stream may be hung`);
408
+ break;
409
+ }
410
+
411
+ case 'knock:denied': {
412
+ const name = msg.agentName || msg.agentId;
413
+ get().addToast('warning', `${name} blocked`, `${msg.toolName} on ${msg.target} — ${msg.reason || 'scope conflict'}`);
414
+ break;
415
+ }
416
+
384
417
  case 'phase2:failed':
385
418
  get().addToast('error', `QC agent failed to spawn`, msg.error || 'Unknown error');
386
419
  break;
@@ -725,9 +758,9 @@ export const useGrooveStore = create((set, get) => ({
725
758
 
726
759
  // ── Toasts ────────────────────────────────────────────────
727
760
 
728
- addToast(type, message, detail) {
761
+ addToast(type, message, detail, action) {
729
762
  const id = ++toastCounter;
730
- set((s) => ({ toasts: [...s.toasts, { id, type, message, detail }] }));
763
+ set((s) => ({ toasts: [...s.toasts, { id, type, message, detail, action }] }));
731
764
  },
732
765
  removeToast(id) {
733
766
  set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) }));
@@ -1002,8 +1035,13 @@ export const useGrooveStore = create((set, get) => ({
1002
1035
  thinkingAgents: new Set([...s.thinkingAgents, ...launchedAgents.map((a) => a.id)]),
1003
1036
  }));
1004
1037
  }
1005
- // Clean up stale files
1006
- api.post('/cleanup').catch(() => {});
1038
+ // Clean up stale files — scoped to the launched team so plans in other
1039
+ // teams' workspaces survive. The launch endpoint already unlinks the
1040
+ // exact plan it read; this is a belt-and-suspenders sweep.
1041
+ const launchedTeamId = body?.teamId || result?.teamId || null;
1042
+ if (launchedTeamId) {
1043
+ api.post('/cleanup', { teamId: launchedTeamId }).catch(() => {});
1044
+ }
1007
1045
  return result;
1008
1046
  } catch (err) {
1009
1047
  get().addToast('error', 'Launch failed', err.message);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "groove-dev",
3
- "version": "0.27.37",
3
+ "version": "0.27.40",
4
4
  "description": "Open-source agent orchestration layer — the AI company OS. Local model agent engine (GGUF/Ollama/llama-server), HuggingFace model browser, MCP integrations (Slack, Gmail, Stripe, 15+), agent scheduling (cron), business roles (CMO, CFO, EA). GUI dashboard, multi-agent coordination, zero cold-start, infinite sessions. Works with Claude Code, Codex, Gemini CLI, Ollama, any local model.",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "author": "Groove Dev <hello@groovedev.ai> (https://groovedev.ai)",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.37",
3
+ "version": "0.27.40",
4
4
  "description": "GROOVE CLI — manage AI coding agents from your terminal",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/daemon",
3
- "version": "0.27.37",
3
+ "version": "0.27.40",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -268,6 +268,49 @@ export function createApi(app, daemon) {
268
268
  res.json(daemon.locks.getAll());
269
269
  });
270
270
 
271
+ // Knock protocol: Claude Code PreToolUse hook POSTs every Bash/Write/Edit
272
+ // tool call here. The daemon checks the target path (for file ops) against
273
+ // the agent's declared scope and against other agents' active locks, and
274
+ // allows or denies. Non-Claude providers don't hit this path.
275
+ app.post('/api/knock', (req, res) => {
276
+ const body = req.body || {};
277
+ const agentId = body.grooveAgentId;
278
+ const toolName = body.tool_name || body.toolName || '';
279
+ const toolInput = body.tool_input || body.toolInput || {};
280
+
281
+ // Unknown / no agent id → fail open (don't wedge an agent we can't identify)
282
+ if (!agentId) return res.json({ allow: true });
283
+ const agent = daemon.registry.get(agentId);
284
+ if (!agent) return res.json({ allow: true });
285
+
286
+ // Extract the target file paths from the tool input
287
+ const targets = [];
288
+ if (toolInput.file_path) targets.push(String(toolInput.file_path));
289
+ if (toolInput.path) targets.push(String(toolInput.path));
290
+ if (Array.isArray(toolInput.edits)) {
291
+ for (const e of toolInput.edits) if (e?.file_path) targets.push(String(e.file_path));
292
+ }
293
+
294
+ // Scope guard: if agent has a declared scope and the op targets a path,
295
+ // verify the path matches the scope or belongs to no one.
296
+ if (agent.scope && agent.scope.length > 0 && targets.length > 0) {
297
+ for (const target of targets) {
298
+ const conflict = daemon.locks.check(agentId, target);
299
+ if (conflict.conflict) {
300
+ daemon.audit.log('knock.denied', { agentId, toolName, target, owner: conflict.owner, pattern: conflict.pattern });
301
+ daemon.broadcast({ type: 'knock:denied', agentId, agentName: agent.name, toolName, target, owner: conflict.owner, reason: 'scope_conflict' });
302
+ return res.json({
303
+ allow: false,
304
+ reason: `GROOVE PM: ${target} is owned by another agent (pattern ${conflict.pattern}). Use the handoff protocol (write .groove/handoffs/<role>.md) or request approval instead of editing it directly.`,
305
+ });
306
+ }
307
+ }
308
+ }
309
+
310
+ daemon.audit.log('knock.allowed', { agentId, toolName, targets });
311
+ res.json({ allow: true });
312
+ });
313
+
271
314
  // Coordination protocol — agents declare intent on shared resources
272
315
  // (npm install, server restart, package.json edit) to prevent races.
273
316
  // Returns 423 Locked if another agent holds a conflicting resource.
@@ -2613,14 +2656,16 @@ Keep responses concise. Help them think, don't lecture them about the system the
2613
2656
  // Delete immediately after reading to prevent duplicate launches from poll races
2614
2657
  try { unlinkSync(found.path); } catch { /* already gone */ }
2615
2658
 
2616
- // Support both old format (bare array) and new format ({ projectDir, agents })
2659
+ // Support both old format (bare array) and new format ({ projectDir, agents, preview })
2617
2660
  let agentConfigs;
2618
2661
  let projectDir = null;
2662
+ let previewBlock = null;
2619
2663
  if (Array.isArray(raw)) {
2620
2664
  agentConfigs = raw;
2621
2665
  } else if (raw && Array.isArray(raw.agents)) {
2622
2666
  agentConfigs = raw.agents;
2623
2667
  projectDir = raw.projectDir || null;
2668
+ previewBlock = raw.preview || null;
2624
2669
  } else {
2625
2670
  return res.status(400).json({ error: 'Invalid recommended team format' });
2626
2671
  }
@@ -2783,33 +2828,68 @@ Keep responses concise. Help them think, don't lecture them about the system the
2783
2828
  }
2784
2829
  }
2785
2830
 
2831
+ // Stash the preview block so the daemon can launch it when the team
2832
+ // finishes. The plan file gets deleted seconds after this endpoint returns.
2833
+ if (previewBlock && daemon.preview && defaultTeamId) {
2834
+ daemon.preview.stashPlan(defaultTeamId, previewBlock, projectWorkingDir);
2835
+ }
2836
+
2786
2837
  daemon.audit.log('team.launch', {
2787
2838
  phase1: spawned.length, reused: reused.length, phase2Pending: phase2.length, failed: failed.length,
2788
- agents: [...spawned, ...reused].map((a) => a.role), projectDir: projectDir || null,
2839
+ agents: [...spawned, ...reused].map((a) => a.role), projectDir: projectDir || null, preview: !!previewBlock,
2789
2840
  });
2790
- res.json({ launched: spawned.length, reused: reused.length, phase2Pending: phase2.length, agents: [...spawned, ...reused], failed, projectDir: projectDir || null });
2841
+ res.json({ launched: spawned.length, reused: reused.length, phase2Pending: phase2.length, agents: [...spawned, ...reused], failed, projectDir: projectDir || null, preview: previewBlock ? previewBlock.kind : null });
2791
2842
  } catch (err) {
2792
2843
  res.status(500).json({ error: err.message });
2793
2844
  }
2794
2845
  });
2795
2846
 
2796
- // Clean up stale artifacts (old plans, recommended teams, etc.)
2847
+ // Preview service one-click View Site for completed teams
2848
+ app.get('/api/preview', (req, res) => {
2849
+ res.json({ previews: daemon.preview?.list() || [] });
2850
+ });
2851
+
2852
+ app.get('/api/preview/:teamId', (req, res) => {
2853
+ const entry = daemon.preview?.get(req.params.teamId);
2854
+ if (!entry) return res.status(404).json({ error: 'No preview for this team' });
2855
+ res.json(entry);
2856
+ });
2857
+
2858
+ app.delete('/api/preview/:teamId', async (req, res) => {
2859
+ const killed = await daemon.preview?.kill(req.params.teamId);
2860
+ res.json({ stopped: !!killed });
2861
+ });
2862
+
2863
+ // Manually (re)launch the preview for a team using the stashed plan.
2864
+ app.post('/api/preview/:teamId/launch', async (req, res) => {
2865
+ const plan = daemon.preview?.getPlan(req.params.teamId);
2866
+ if (!plan) return res.status(404).json({ error: 'No preview plan stashed for this team' });
2867
+ const result = await daemon.preview.launch(req.params.teamId, plan.workingDir, plan.preview);
2868
+ res.json(result);
2869
+ });
2870
+
2871
+ // Clean up stale artifacts. Scope to a single team when teamId is provided —
2872
+ // wiping every agent's working dir on a global cleanup would delete other
2873
+ // in-flight teams' unlaunched plans. When called with no teamId, only the
2874
+ // daemon-root plan file is touched (safe baseline).
2797
2875
  app.post('/api/cleanup', (req, res) => {
2876
+ const teamId = req.body?.teamId || req.query?.teamId || null;
2798
2877
  let cleaned = 0;
2799
- // Clean recommended-team.json from all known locations
2800
2878
  const locations = [resolve(daemon.grooveDir, 'recommended-team.json')];
2801
- for (const agent of daemon.registry.getAll()) {
2802
- if (agent.workingDir) {
2803
- locations.push(resolve(agent.workingDir, '.groove', 'recommended-team.json'));
2879
+
2880
+ if (teamId) {
2881
+ // Only agents in this team get their workspace scanned
2882
+ for (const agent of daemon.registry.getAll()) {
2883
+ if (agent.teamId === teamId && agent.workingDir) {
2884
+ locations.push(resolve(agent.workingDir, '.groove', 'recommended-team.json'));
2885
+ }
2804
2886
  }
2805
2887
  }
2806
- const defaultDir = daemon.config?.defaultWorkingDir;
2807
- if (defaultDir) locations.push(resolve(defaultDir, '.groove', 'recommended-team.json'));
2808
2888
 
2809
2889
  for (const p of locations) {
2810
2890
  if (existsSync(p)) { try { unlinkSync(p); cleaned++; } catch { /* */ } }
2811
2891
  }
2812
- daemon.audit.log('cleanup', { cleaned });
2892
+ daemon.audit.log('cleanup', { cleaned, teamId });
2813
2893
  res.json({ ok: true, cleaned });
2814
2894
  });
2815
2895
 
@@ -16,6 +16,7 @@ import { Introducer } from './introducer.js';
16
16
  import { LockManager } from './lockmanager.js';
17
17
  import { Supervisor } from './supervisor.js';
18
18
  import { Journalist } from './journalist.js';
19
+ import { PreviewService } from './preview.js';
19
20
  import { TokenTracker } from './tokentracker.js';
20
21
  import { Rotator } from './rotator.js';
21
22
  import { AdaptiveThresholds } from './adaptive.js';
@@ -137,6 +138,7 @@ export class Daemon {
137
138
  this.fileWatcher = new FileWatcher(this);
138
139
  this.terminalManager = new TerminalManager(this);
139
140
  this.gateways = new GatewayManager(this);
141
+ this.preview = new PreviewService(this);
140
142
  this.modelManager = new ModelManager(this);
141
143
  this.llamaServer = new LlamaServerManager(this);
142
144
  this.mcpManager = new McpManager(this);
@@ -657,6 +659,7 @@ export class Daemon {
657
659
 
658
660
  // Kill all agent processes, stop MCP servers, and stop inference servers
659
661
  await this.processes.killAll();
662
+ if (this.preview) await this.preview.killAll();
660
663
  this.mcpManager.stopAll();
661
664
  await this.llamaServer.stopAll();
662
665
 
@@ -76,6 +76,50 @@ export class LockManager {
76
76
  return { conflict: false };
77
77
  }
78
78
 
79
+ /**
80
+ * Prefix-based overlap test between two scope pattern sets.
81
+ * Two scopes overlap if any pair of patterns has a prefix containment
82
+ * relationship (one prefix is a parent dir of the other) or shares an
83
+ * identical prefix. An empty/broad pattern (e.g. `**`) always overlaps.
84
+ *
85
+ * Used at spawn time to block two agents claiming the same files.
86
+ * Intentionally conservative: returns overlap for ambiguous cases so
87
+ * collisions fail loud rather than silently.
88
+ */
89
+ static scopesOverlap(patternsA, patternsB) {
90
+ if (!Array.isArray(patternsA) || !Array.isArray(patternsB)) return false;
91
+ if (patternsA.length === 0 || patternsB.length === 0) return false;
92
+ const prefixOf = (p) => {
93
+ const idx = p.search(/[*?[{]/);
94
+ const head = idx === -1 ? p : p.slice(0, idx);
95
+ return head.replace(/\/+$/, '');
96
+ };
97
+ for (const a of patternsA) {
98
+ const pa = prefixOf(a);
99
+ for (const b of patternsB) {
100
+ const pb = prefixOf(b);
101
+ if (pa === pb) return { overlap: true, a, b };
102
+ if (pa === '' || pb === '') return { overlap: true, a, b };
103
+ const longer = pa.length > pb.length ? pa : pb;
104
+ const shorter = pa.length > pb.length ? pb : pa;
105
+ if (longer.startsWith(shorter + '/')) return { overlap: true, a, b };
106
+ }
107
+ }
108
+ return { overlap: false };
109
+ }
110
+
111
+ /**
112
+ * Find any currently-locked agent whose scope overlaps with candidateScope.
113
+ * Returns { overlap: true, owner, ... } for the first conflict, else {overlap:false}.
114
+ */
115
+ findOverlappingOwner(candidateScope) {
116
+ for (const [ownerId, patterns] of this.locks) {
117
+ const res = LockManager.scopesOverlap(candidateScope, patterns);
118
+ if (res.overlap) return { overlap: true, owner: ownerId, ownerScope: patterns, ...res };
119
+ }
120
+ return { overlap: false };
121
+ }
122
+
79
123
  purgeOrphans(aliveAgentIds) {
80
124
  const alive = new Set(aliveAgentIds);
81
125
  let purged = 0;
@@ -19,6 +19,12 @@ const MAX_HANDOFF_ROTATIONS = 25;
19
19
  const MAX_DISCOVERIES = 1000;
20
20
  const HANDOFF_BRIEF_MAX_CHARS = 4000;
21
21
 
22
+ // Legacy auto-extraction (since disabled) captured raw user prompts into a
23
+ // "user-directive" constraint. Existing entries still live in projects that
24
+ // predate the fix and would otherwise leak into every new agent's brief.
25
+ // Suppressed at render time so stored state doesn't need migration.
26
+ const SUPPRESSED_CONSTRAINT_CATEGORIES = new Set(['user-directive']);
27
+
22
28
  function hashText(text) {
23
29
  return createHash('sha1').update(text.trim().toLowerCase()).digest('hex').slice(0, 12);
24
30
  }
@@ -76,6 +82,9 @@ export class MemoryStore {
76
82
 
77
83
  addConstraint({ text, category = 'general' }) {
78
84
  if (!text || typeof text !== 'string') return { added: false, error: 'text required' };
85
+ if (SUPPRESSED_CONSTRAINT_CATEGORIES.has(category)) {
86
+ return { added: false, error: `category "${category}" suppressed — not a real constraint` };
87
+ }
79
88
  const trimmed = text.trim();
80
89
  if (trimmed.length < 3) return { added: false, error: 'text too short' };
81
90
  if (trimmed.length > 500) return { added: false, error: 'text too long (max 500 chars)' };
@@ -117,7 +126,8 @@ export class MemoryStore {
117
126
  }
118
127
 
119
128
  getConstraintsMarkdown(maxChars = 4000) {
120
- const constraints = this.listConstraints();
129
+ const constraints = this.listConstraints()
130
+ .filter((c) => !SUPPRESSED_CONSTRAINT_CATEGORIES.has(c.category));
121
131
  if (constraints.length === 0) return '';
122
132
  const byCategory = {};
123
133
  for (const c of constraints) {
@@ -165,13 +175,14 @@ export class MemoryStore {
165
175
  const entries = [];
166
176
  const blocks = content.split(/\n(?=## Rotation )/);
167
177
  for (const block of blocks) {
168
- const headerMatch = block.match(/^## Rotation (\d+) — [^(]*\(([\w?-]+) →/);
178
+ const headerMatch = block.match(/^## Rotation (\d+) — (\S+) \(([\w?-]+) →/);
169
179
  if (!headerMatch) continue;
170
180
  const body = block.replace(/\n---\s*$/, '').trim();
171
181
  entries.push({
172
182
  rotationN: parseInt(headerMatch[1], 10),
173
183
  body,
174
- agentId: headerMatch[2] || null,
184
+ timestamp: headerMatch[2] || null,
185
+ agentId: headerMatch[3] || null,
175
186
  });
176
187
  }
177
188
  return entries;
@@ -184,8 +195,14 @@ export class MemoryStore {
184
195
  if (!role || !entry) return false;
185
196
  const chain = this.getHandoffChain(role, workingDir, teamId);
186
197
 
187
- // Dedup: prevent the same agent from having multiple entries in the chain
188
- if (entry.agentId && chain.some(c => c.agentId === entry.agentId)) return false;
198
+ // Guard only the pathological double-write case: identical agent AND timestamp
199
+ // means the same event fired twice (e.g. race). A resumed agent that legitimately
200
+ // completes multiple sessions produces distinct timestamps and must be kept —
201
+ // that's how the chain records continuous work across resumes.
202
+ if (entry.agentId && entry.timestamp
203
+ && chain.some(c => c.agentId === entry.agentId && c.timestamp === entry.timestamp)) {
204
+ return false;
205
+ }
189
206
 
190
207
  const nextN = (chain[0]?.rotationN || 0) + 1;
191
208