groove-dev 0.27.25 → 0.27.27

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/CLAUDE.md CHANGED
@@ -263,10 +263,3 @@ Audit-driven release. Multi-agent orchestration system with 7 coordination layer
263
263
  - Dashboard: routing donut, cache panel, context health gauges
264
264
  - Monitor/QC agent mode (stay active, loop)
265
265
  - Distribution: demo video, HN launch, Twitter content
266
-
267
- <!-- GROOVE:START -->
268
- ## GROOVE Orchestration (auto-injected)
269
- Active agents: 0
270
- See AGENTS_REGISTRY.md for full agent state.
271
- **Memory policy:** GROOVE manages project memory automatically. Do not read or write MEMORY.md or .groove/memory/ files directly.
272
- <!-- GROOVE:END -->
@@ -0,0 +1,228 @@
1
+ # Security Sweep Report
2
+
3
+ Date: 2026-04-16
4
+ Assessor: Security audit
5
+ Scope reviewed: `../packages/desktop`, `../packages/gui`, `../packages/daemon`, root dependency graph in `../package-lock.json`
6
+ Note: the working directory `./` only contained handoff artifacts, so the actual Electron app code audited lives in the parent monorepo `../`.
7
+
8
+ ## Executive Summary
9
+
10
+ Overall risk: **High**
11
+
12
+ The desktop app has several solid baseline controls in place — Electron windows run with `contextIsolation`, `sandbox`, and `nodeIntegration: false`; provider API keys are encrypted at rest; and file path handling includes symlink checks. However, I found **three high-impact architectural issues** that materially raise compromise risk:
13
+
14
+ 1. **Project-controlled active content is served from the app's trusted origin**, creating a same-origin path to the daemon API.
15
+ 2. **The daemon control plane is effectively unauthenticated**, with broad localhost trust and optional non-loopback binds.
16
+ 3. **Browser-mode marketplace tokens can be persisted in plaintext config and then exposed by `/api/config`**.
17
+
18
+ These issues mean that a malicious repository, a hostile local webpage on `localhost`, or a reachable peer when bound to Tailscale/LAN can potentially read project data, mutate files, spawn processes/agents, and access local app state.
19
+
20
+ ## Methodology
21
+
22
+ - Static review of Electron main/preload, renderer, and daemon code
23
+ - Targeted grep for OWASP-style sinks: XSS, injection, SSRF, auth bypass, unsafe file access, shell/process execution
24
+ - Review of secret storage and config persistence behavior
25
+ - Dependency check with `npm audit --json` and dependency resolution via `npm ls`
26
+ - Verification of packaging and ignore rules for runtime secrets/artifacts
27
+
28
+ ## Findings
29
+
30
+ ### 1) Critical — Untrusted project HTML/JS can execute with daemon origin
31
+
32
+ **Severity:** Critical
33
+ **Category:** XSS / trusted-origin content execution / privilege escalation
34
+ **Primary locations:**
35
+ - `../packages/gui/src/views/editor.jsx:165`
36
+ - `../packages/daemon/src/api.js:2274`
37
+ - `../packages/daemon/src/mimetypes.js:15`
38
+ - `../packages/daemon/src/mimetypes.js:17`
39
+
40
+ **What I found**
41
+ - The editor intentionally previews HTML files by loading `/api/files/raw?path=...` into an iframe: `../packages/gui/src/views/editor.jsx:165`.
42
+ - The daemon serves raw project files directly from the app origin and preserves executable MIME types such as `text/javascript` and `text/html`: `../packages/daemon/src/api.js:2274`, `../packages/daemon/src/mimetypes.js:15`, `../packages/daemon/src/mimetypes.js:17`.
43
+ - Because this content is delivered from the same origin as `/api/*`, any project-controlled HTML that gets rendered can execute JavaScript against the daemon API using the app's trusted origin.
44
+
45
+ **Why this matters**
46
+ A malicious repository can plant an `index.html` + `payload.js` pair. If the HTML preview path is reached, or if the raw file is otherwise navigated to from the trusted origin, the payload can:
47
+ - read or modify project files,
48
+ - spawn/kill agents,
49
+ - change daemon config,
50
+ - abuse local-only features exposed over HTTP/WS.
51
+
52
+ This is a classic Electron/browser trust-boundary failure: **workspace content should never run with the same origin as the privileged app UI**.
53
+
54
+ **Recommended fix**
55
+ - Do **not** serve active workspace content (`.html`, `.js`, `.mjs`, `.svg`, etc.) from the daemon's privileged origin.
56
+ - For previews, use one of these safer patterns:
57
+ - render sanitized HTML only,
58
+ - serve previews from a **separate origin** with no access to `/api/*`,
59
+ - use a dedicated isolated `BrowserWindow`/partition with no preload, no shared cookies/storage, and no privileged bridge,
60
+ - force dangerous types to download or be served as `text/plain`.
61
+ - Remove `allow-same-origin` from any preview iframe unless the content is fully trusted.
62
+ - Add regression tests that verify active content cannot call privileged API routes.
63
+
64
+ **Priority:** P0
65
+
66
+ ### 2) High — Daemon HTTP/WebSocket control plane has no real authentication
67
+
68
+ **Severity:** High
69
+ **Category:** Authentication / access control / CSRF-adjacent localhost trust
70
+ **Primary locations:**
71
+ - `../packages/daemon/src/api.js:68`
72
+ - `../packages/daemon/src/api.js:120`
73
+ - `../packages/daemon/src/api.js:2223`
74
+ - `../packages/daemon/src/index.js:155`
75
+ - `../packages/daemon/src/index.js:169`
76
+ - `../packages/daemon/src/index.js:250`
77
+ - `../packages/daemon/src/index.js:451`
78
+
79
+ **What I found**
80
+ - The REST API trusts requests with **no `Origin` header** and also allows **any localhost origin**: `../packages/daemon/src/api.js:68`.
81
+ - Sensitive routes such as agent spawning and file deletion are exposed without any per-request auth token: `../packages/daemon/src/api.js:120`, `../packages/daemon/src/api.js:2223`.
82
+ - WebSocket upgrades also allow missing `Origin`, and accepted clients can request terminal spawns/input: `../packages/daemon/src/index.js:155`, `../packages/daemon/src/index.js:169`, `../packages/daemon/src/index.js:250`.
83
+ - The daemon can bind to non-loopback hosts, including Tailscale/LAN addresses: `../packages/daemon/src/index.js:451`.
84
+
85
+ **Why this matters**
86
+ This turns the daemon into a local control plane with weak trust assumptions:
87
+ - Any hostile local webpage served from `http://localhost:*` can make authenticated-by-origin reads/writes against the daemon.
88
+ - Any non-browser client can omit `Origin` entirely and still connect.
89
+ - If the daemon is started on a reachable interface (for example Tailscale/LAN), remote peers can hit powerful endpoints unless another network control blocks them.
90
+
91
+ Impact includes agent/process control, file mutation, editor/terminal access, config changes, and broader project compromise.
92
+
93
+ **Recommended fix**
94
+ - Require a **random per-instance bearer token** (or equivalent secret) for **all** HTTP and WebSocket traffic.
95
+ - Reject requests lacking that token, even from localhost.
96
+ - Narrow CORS to the exact GUI origin instead of `any localhost port`.
97
+ - Treat missing `Origin` as untrusted for browser-like endpoints.
98
+ - For non-loopback binds, require explicit auth plus network ACLs/TLS.
99
+ - Consider Unix domain sockets or loopback-only ephemeral tokens for desktop mode.
100
+
101
+ **Priority:** P0
102
+
103
+ ### 3) High — Marketplace token can be stored plaintext and leaked through `/api/config`
104
+
105
+ **Severity:** High
106
+ **Category:** Secrets management / sensitive data exposure
107
+ **Primary locations:**
108
+ - `../packages/daemon/src/skills.js:47`
109
+ - `../packages/daemon/src/skills.js:80`
110
+ - `../packages/daemon/src/firstrun.js:145`
111
+ - `../packages/daemon/src/api.js:3438`
112
+ - `../packages/daemon/src/api.js:1115`
113
+
114
+ **What I found**
115
+ - The browser-style auth callback stores `marketplace.token` inside `daemon.config`: `../packages/daemon/src/api.js:1115`, `../packages/daemon/src/skills.js:80`.
116
+ - `saveConfig()` writes that config directly to `config.json` without encryption: `../packages/daemon/src/firstrun.js:145`.
117
+ - `/api/config` returns the full config object without redaction: `../packages/daemon/src/api.js:3438`.
118
+
119
+ **Why this matters**
120
+ Even though the Electron main process uses encrypted `safeStorage` for its own desktop token path, the daemon still contains a second browser-oriented auth flow that can persist a bearer token in plaintext config and then expose it over the local API.
121
+
122
+ If an attacker can hit the daemon API or read the `.groove` directory, they may recover the marketplace token and impersonate the user against Groove services.
123
+
124
+ **Recommended fix**
125
+ - Never persist auth/session tokens in `config.json`.
126
+ - Store marketplace/session tokens only in OS-backed secure storage.
127
+ - Redact sensitive config keys from `/api/config` before returning any response.
128
+ - Add a migration that strips existing `marketplace.token` values from historical configs.
129
+
130
+ **Priority:** P1
131
+
132
+ ### 4) Medium — Renderer can trigger arbitrary external URLs for integration OAuth
133
+
134
+ **Severity:** Medium
135
+ **Category:** URL handling / privilege amplification
136
+ **Primary location:**
137
+ - `../packages/desktop/main.js:1004`
138
+
139
+ **What I found**
140
+ `integration-oauth-start` accepts an arbitrary `oauthUrl` from the renderer and passes it straight to `shell.openExternal()`.
141
+
142
+ **Why this matters**
143
+ By itself this requires renderer compromise, but in Electron this is still an important defense-in-depth boundary. If the renderer is ever compromised, an attacker can launch:
144
+ - arbitrary external websites,
145
+ - custom protocol handlers,
146
+ - local application handlers registered on the OS.
147
+
148
+ **Recommended fix**
149
+ - Validate `oauthUrl` against a strict allowlist derived from the integration registry.
150
+ - Allow only `https:` and known IdP domains.
151
+ - Deny custom schemes and `file:`/`javascript:`/unexpected handlers.
152
+
153
+ **Priority:** P2
154
+
155
+ ### 5) Medium — Electron auto-approves all permissions for localhost content
156
+
157
+ **Severity:** Medium
158
+ **Category:** Least privilege / hardening gap
159
+ **Primary location:**
160
+ - `../packages/desktop/main.js:245`
161
+
162
+ **What I found**
163
+ The permission handlers explicitly allow microphone/media, but then also return `true` for any other permission as long as the requesting page is `localhost`/`127.0.0.1`.
164
+
165
+ **Why this matters**
166
+ Once renderer trust is broken, this widens impact by silently approving extra capabilities that were never intended for routine app operation.
167
+
168
+ **Recommended fix**
169
+ - Replace the implicit `allow all localhost permissions` behavior with an explicit allowlist.
170
+ - Deny by default.
171
+ - Log and review any denied permission requests during development.
172
+
173
+ **Priority:** P2
174
+
175
+ ### 6) Moderate dependency advisory — `follow-redirects` header leak
176
+
177
+ **Severity:** Moderate
178
+ **Category:** Dependency / third-party risk
179
+ **Location:** transitive dependency path `@groove-dev/daemon -> @slack/bolt -> axios -> follow-redirects@1.15.11`
180
+
181
+ **What I found**
182
+ A fresh `npm audit --json` run on 2026-04-16 reported one advisory:
183
+ - `follow-redirects <= 1.15.11` — leaks custom authentication headers to cross-domain redirect targets.
184
+
185
+ Dependency resolution shows this arrives through:
186
+ - `@slack/bolt@4.7.0`
187
+ - `axios@1.15.0`
188
+ - `follow-redirects@1.15.11`
189
+
190
+ **Recommended fix**
191
+ - Upgrade to a dependency chain that pulls a fixed `follow-redirects` release.
192
+ - If immediate upgrade is blocked, audit all Slack/axios usage for redirect-following with sensitive headers.
193
+
194
+ **Priority:** P2
195
+
196
+ ## Positive Controls Observed
197
+
198
+ These are good and worth keeping:
199
+
200
+ - Electron windows use `contextIsolation: true`, `nodeIntegration: false`, and `sandbox: true`: `../packages/desktop/main.js:227`
201
+ - Provider credentials are encrypted at rest with AES-256-GCM and `0600` file permissions: `../packages/daemon/src/credentials.js:1`
202
+ - File path validation checks traversal and resolves symlinks before access: `../packages/daemon/src/api.js:2021`
203
+ - The root ignore policy excludes `.env`, `.groove`, runtime bundles, and build artifacts from git: `../.gitignore:1`
204
+ - I did **not** find hardcoded secrets in tracked source during pattern scans.
205
+
206
+ ## Remediation Plan
207
+
208
+ ### Immediate (this week)
209
+ - Disable HTML preview or force dangerous file types to render as plain text/download.
210
+ - Add an auth token requirement to all daemon HTTP/WS endpoints.
211
+ - Redact `/api/config` and remove token persistence from daemon config.
212
+ - Patch the `follow-redirects` dependency chain.
213
+
214
+ ### Short term
215
+ - Serve untrusted previews from a separate origin/partition.
216
+ - Tighten `shell.openExternal()` to strict allowlists only.
217
+ - Replace localhost-wide CORS trust with an exact-origin model.
218
+ - Lock Electron permission handling to a minimal explicit list.
219
+
220
+ ### Verification to add after fixes
221
+ - Test that opening a repo-controlled HTML file cannot call `/api/agents`, `/api/files/*`, or `/api/config`.
222
+ - Test that requests without the daemon auth token fail for both REST and WS.
223
+ - Test that `/api/config` never returns secret-bearing fields.
224
+ - Re-run `npm audit --json` after dependency upgrades.
225
+
226
+ ## Bottom Line
227
+
228
+ The app already has several good Electron hardening defaults, but the **same-origin preview design** and the **unauthenticated daemon control surface** currently dominate the risk profile. I would treat Findings 1 and 2 as release-blocking for environments where untrusted repositories or shared/local-network access are realistic.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.7",
4
- "description": "GROOVE CLI \u2014 manage AI coding agents from your terminal",
3
+ "version": "0.27.27",
4
+ "description": "GROOVE CLI manage AI coding agents from your terminal",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
7
7
  "bin": {
@@ -13,4 +13,4 @@
13
13
  "chalk": "^5.3.0"
14
14
  },
15
15
  "private": true
16
- }
16
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/daemon",
3
- "version": "0.27.7",
3
+ "version": "0.27.27",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -14,7 +14,7 @@ export class Introducer {
14
14
  }
15
15
 
16
16
  generateContext(newAgent, options = {}) {
17
- const { taskNegotiation, hasTask } = options;
17
+ const { taskNegotiation, hasTask, isRotation } = options;
18
18
  const agents = this.daemon.registry.getAll();
19
19
  // Only include ACTIVE agents — not completed/killed ones from previous sessions
20
20
  // Completed agents' work is captured in the journalist's project map, not here
@@ -353,13 +353,13 @@ export class Introducer {
353
353
  parts.push(`### Constraints (read carefully)\n${constraints}`);
354
354
  }
355
355
 
356
- if (hasTask) {
357
- const discoveries = this.daemon.memory.getDiscoveriesMarkdown(newAgent.role, 15, 1000);
356
+ if (hasTask || isRotation) {
357
+ const discoveries = this.daemon.memory.getDiscoveriesMarkdown(newAgent.role, 8, 600, newAgent.scope);
358
358
  if (discoveries) {
359
359
  parts.push(`### Known Fixes for ${newAgent.role} Role\n${discoveries}`);
360
360
  }
361
361
 
362
- const handoffs = this.daemon.memory.getRecentHandoffMarkdown(newAgent.role, 2, 1000, newAgent.workingDir);
362
+ const handoffs = this.daemon.memory.getRecentHandoffMarkdown(newAgent.role, 2, 1000, newAgent.workingDir, newAgent.teamId);
363
363
  if (handoffs) {
364
364
  parts.push(`### Recent Handoff History\n${handoffs}`);
365
365
  }
@@ -367,9 +367,9 @@ export class Introducer {
367
367
 
368
368
  if (parts.length > 0) {
369
369
  memorySection = `\n## Project Memory (auto-generated)\n\n${parts.join('\n\n')}\n`;
370
- // Hard budget: 4K chars total
371
- if (memorySection.length > 4000) {
372
- memorySection = memorySection.slice(0, 3997) + '...';
370
+ // Hard budget: 3K chars total
371
+ if (memorySection.length > 3000) {
372
+ memorySection = memorySection.slice(0, 2997) + '...';
373
373
  }
374
374
  }
375
375
  }
@@ -6,8 +6,9 @@ import { resolve } from 'path';
6
6
  import { execFile, spawn as cpSpawn } from 'child_process';
7
7
  import { getProvider, getInstalledProviders } from './providers/index.js';
8
8
 
9
- const DEFAULT_INTERVAL = 120_000; // 2 minutes
9
+ const DEFAULT_INTERVAL = 300_000; // 5 minutes (safety-net fallback; event-driven triggers handle the normal case)
10
10
  const MAX_LOG_CHARS = 100_000; // ~25k tokens budget for synthesis input (captures 80-90% of recent activity)
11
+ const DEBOUNCE_MS = 10_000; // requestSynthesis debounce window
11
12
 
12
13
  export class Journalist {
13
14
  constructor(daemon) {
@@ -19,6 +20,8 @@ export class Journalist {
19
20
  this.synthesizing = false;
20
21
  this.lastSynthesis = null; // last synthesis result text
21
22
  this.history = []; // recent synthesis summaries
23
+ this._debounceTimer = null;
24
+ this._debounceReason = null;
22
25
  }
23
26
 
24
27
  start(intervalMs = DEFAULT_INTERVAL) {
@@ -51,6 +54,33 @@ export class Journalist {
51
54
  clearInterval(this.interval);
52
55
  this.interval = null;
53
56
  }
57
+ if (this._debounceTimer) {
58
+ clearTimeout(this._debounceTimer);
59
+ this._debounceTimer = null;
60
+ }
61
+ }
62
+
63
+ requestSynthesis(reason = 'unknown') {
64
+ if (this._debounceTimer) {
65
+ this._debounceReason = reason;
66
+ return;
67
+ }
68
+ this._debounceReason = reason;
69
+ this._debounceTimer = setTimeout(() => {
70
+ const r = this._debounceReason;
71
+ this._debounceTimer = null;
72
+ this._debounceReason = null;
73
+ this.cycle().catch((err) => {
74
+ console.error(` Journalist requestSynthesis(${r}) failed:`, err.message);
75
+ });
76
+ }, DEBOUNCE_MS);
77
+ }
78
+
79
+ async ensureFresh(maxAgeMs = 30000) {
80
+ if (this.lastCycleAt && (Date.now() - this.lastCycleAt) < maxAgeMs) {
81
+ return;
82
+ }
83
+ await this.cycle();
54
84
  }
55
85
 
56
86
  async cycle() {
@@ -454,11 +484,11 @@ export class Journalist {
454
484
  }
455
485
  const provider = getProvider(providerId);
456
486
 
457
- // Pick the lightest model for synthesis (cheapest/fastest)
458
- const lightModel = provider.constructor.models?.find((m) => m.tier === 'light')
459
- || provider.constructor.models?.find((m) => m.tier === 'medium')
487
+ // Pick medium tier for higher-quality synthesis (fewer but better cycles)
488
+ const selectedModel = provider.constructor.models?.find((m) => m.tier === 'medium')
489
+ || provider.constructor.models?.find((m) => m.tier === 'light')
460
490
  || provider.constructor.models?.[0];
461
- const modelId = lightModel?.id || null;
491
+ const modelId = selectedModel?.id || null;
462
492
 
463
493
  const headlessCmd = provider.buildHeadlessCommand(prompt, modelId);
464
494
  const { command, args, env, stdin: stdinData } = headlessCmd;
@@ -749,7 +779,7 @@ export class Journalist {
749
779
  // Pull recent rotation history from persistent memory (Layer 7).
750
780
  // Gives the new agent causal continuity: what the last 3 agents struggled
751
781
  // with, decided, and solved — not just what the current session did.
752
- const recentChain = this.daemon.memory?.getRecentHandoffMarkdown(agent.role, 3, 3000, agent.workingDir) || '';
782
+ const recentChain = this.daemon.memory?.getRecentHandoffMarkdown(agent.role, 3, 3000, agent.workingDir, agent.teamId) || '';
753
783
 
754
784
  // Pull the user's recent messages scoped to this agent
755
785
  const agentFeedback = this.getUserFeedback(agent.id).slice(-5);
@@ -12,6 +12,7 @@
12
12
  import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, appendFileSync, statSync } from 'fs';
13
13
  import { resolve, relative } from 'path';
14
14
  import { createHash } from 'crypto';
15
+ import { minimatch } from 'minimatch';
15
16
 
16
17
  const MAX_CONSTRAINTS = 50;
17
18
  const MAX_HANDOFF_ROTATIONS = 25;
@@ -141,7 +142,12 @@ export class MemoryStore {
141
142
  return safeName(rel);
142
143
  }
143
144
 
144
- _chainPath(role, workingDir) {
145
+ _chainPath(role, workingDir, teamId) {
146
+ if (teamId) {
147
+ const dir = resolve(this.handoffDir, safeName(teamId));
148
+ mkdirSync(dir, { recursive: true });
149
+ return resolve(dir, `${safeName(role)}.md`);
150
+ }
145
151
  const slug = this._workspaceSlug(workingDir);
146
152
  if (slug) {
147
153
  const dir = resolve(this.handoffDir, slug);
@@ -151,8 +157,8 @@ export class MemoryStore {
151
157
  return resolve(this.handoffDir, `${safeName(role)}.md`);
152
158
  }
153
159
 
154
- getHandoffChain(role, workingDir) {
155
- const path = this._chainPath(role, workingDir);
160
+ getHandoffChain(role, workingDir, teamId) {
161
+ const path = this._chainPath(role, workingDir, teamId);
156
162
  if (!existsSync(path)) return [];
157
163
  try {
158
164
  const content = readFileSync(path, 'utf8');
@@ -173,9 +179,9 @@ export class MemoryStore {
173
179
  }
174
180
  }
175
181
 
176
- appendHandoffBrief(role, entry, workingDir) {
182
+ appendHandoffBrief(role, entry, workingDir, teamId) {
177
183
  if (!role || !entry) return false;
178
- const chain = this.getHandoffChain(role, workingDir);
184
+ const chain = this.getHandoffChain(role, workingDir, teamId);
179
185
  const nextN = (chain[0]?.rotationN || 0) + 1;
180
186
 
181
187
  const block = [
@@ -203,15 +209,15 @@ export class MemoryStore {
203
209
  }
204
210
 
205
211
  try {
206
- writeFileSync(this._chainPath(role, workingDir), lines.join('\n'));
212
+ writeFileSync(this._chainPath(role, workingDir, teamId), lines.join('\n'));
207
213
  return true;
208
214
  } catch {
209
215
  return false;
210
216
  }
211
217
  }
212
218
 
213
- getRecentHandoffMarkdown(role, count = 3, maxChars = 4000, workingDir) {
214
- const chain = this.getHandoffChain(role, workingDir);
219
+ getRecentHandoffMarkdown(role, count = 3, maxChars = 4000, workingDir, teamId) {
220
+ const chain = this.getHandoffChain(role, workingDir, teamId);
215
221
  if (chain.length === 0) return '';
216
222
  const recent = chain.slice(0, count);
217
223
  const out = recent.map((e) => e.body || '').join('\n\n---\n\n');
@@ -300,9 +306,22 @@ export class MemoryStore {
300
306
  } catch { /* best-effort */ }
301
307
  }
302
308
 
303
- getDiscoveriesMarkdown(role, limit = 20, maxChars = 4000) {
304
- const entries = this.listDiscoveries({ role, limit });
309
+ getDiscoveriesMarkdown(role, limit = 20, maxChars = 4000, scope) {
310
+ let entries = this.listDiscoveries({ role, limit: limit * 3 });
305
311
  if (entries.length === 0) return '';
312
+
313
+ if (scope && Array.isArray(scope) && scope.length > 0) {
314
+ const filtered = entries.filter((d) => {
315
+ const file = d.fix || '';
316
+ const rel = file.startsWith(this.projectDir + '/') ? file.slice(this.projectDir.length + 1) : file;
317
+ return scope.some((pattern) => minimatch(rel, pattern, { dot: true }));
318
+ });
319
+ if (filtered.length >= 3) {
320
+ entries = filtered;
321
+ }
322
+ }
323
+
324
+ entries = entries.slice(0, limit);
306
325
  const lines = entries.map((d) => `- When \`${d.trigger}\` → fix: ${d.fix}`);
307
326
  return truncate(lines.join('\n'), maxChars);
308
327
  }
@@ -427,12 +427,18 @@ export class ProcessManager {
427
427
  taskNegotiation = await this.negotiateTaskSplit(agent, sameRole);
428
428
  }
429
429
 
430
- // Generate introduction context (team awareness + negotiation)
431
- // Always pass hasTask: true so Layer 7 discoveries and handoff history
432
- // are injected for ALL agents, not just those with explicit prompts.
433
- // Without this, first-generation agents spawned with just a role never
434
- // receive prior discoveries and repeat mistakes Layer 7 already captured.
435
- const introContext = introducer.generateContext(agent, { taskNegotiation, hasTask: true });
430
+ // Compute hasTask from actual prompt content — agents spawned without a
431
+ // prompt should NOT receive handoff history (prevents cross-team contamination).
432
+ // Discoveries + constraints are always injected (project knowledge).
433
+ // Handoffs are injected only when the agent has a real task or is a rotation.
434
+ const hasTask = !!(config.prompt && config.prompt.trim().length > 0);
435
+ const isRotation = !!(config.isRotation);
436
+ const introContext = introducer.generateContext(agent, { taskNegotiation, hasTask, isRotation });
437
+
438
+ // Ensure the project map is fresh before the new agent reads CLAUDE.md
439
+ if (this.daemon.journalist) {
440
+ await this.daemon.journalist.ensureFresh(30000);
441
+ }
436
442
 
437
443
  // Track cold-start savings — agent gets context from planner/journalist/team
438
444
  // instead of exploring the codebase from scratch
@@ -597,7 +603,7 @@ For normal file edits within your scope, proceed without review.
597
603
 
598
604
  this.daemon.broadcast({ type: 'agent:exit', agentId: agent.id, code: code || 0, signal, status });
599
605
  if (this.daemon.integrations) this.daemon.integrations.refreshMcpJson();
600
- if (status === 'completed' && this.daemon.journalist) this.daemon.journalist.cycle().catch(() => {});
606
+ if (status === 'completed' && this.daemon.journalist) this.daemon.journalist.requestSynthesis('completion');
601
607
  this._checkPhase2(agent.id);
602
608
 
603
609
  // Auto-trigger idle QC + process cross-scope handoffs
@@ -783,10 +789,9 @@ For normal file edits within your scope, proceed without review.
783
789
  }
784
790
  }
785
791
 
786
- // Trigger journalist synthesis immediately on completion so the project
787
- // map is fresh for the next agent that spawns (don't wait for 120s cycle)
792
+ // Trigger journalist synthesis on completion (event-driven, debounced)
788
793
  if (finalStatus === 'completed' && this.daemon.journalist) {
789
- this.daemon.journalist.cycle().catch(() => {});
794
+ this.daemon.journalist.requestSynthesis('completion');
790
795
  }
791
796
 
792
797
  // Phase 2 auto-spawn: check if all phase 1 agents for a team are done
@@ -1168,7 +1173,7 @@ For normal file edits within your scope, proceed without review.
1168
1173
  oldTokens: agentData?.tokensUsed || 0,
1169
1174
  contextUsage: agentData?.contextUsage || 0,
1170
1175
  brief: brief.slice(0, 4000),
1171
- }, agent.workingDir);
1176
+ }, agent.workingDir, agent.teamId);
1172
1177
  } catch { /* best-effort */ }
1173
1178
  }
1174
1179
 
@@ -1369,7 +1374,7 @@ For normal file edits within your scope, proceed without review.
1369
1374
  registry.update(newAgent.id, { status: finalStatus, pid: null });
1370
1375
  this.daemon.broadcast({ type: 'agent:exit', agentId: newAgent.id, code, signal, status: finalStatus });
1371
1376
  if (finalStatus === 'completed' && this.daemon.journalist) {
1372
- this.daemon.journalist.cycle().catch(() => {});
1377
+ this.daemon.journalist.requestSynthesis('completion');
1373
1378
  }
1374
1379
  });
1375
1380
 
@@ -95,6 +95,7 @@ export class CodexProvider extends Provider {
95
95
  if (agent.prompt) args.push(agent.prompt);
96
96
 
97
97
  this._currentModel = agent.model;
98
+ this._sessionInputTokens = 0;
98
99
 
99
100
  return {
100
101
  command: 'codex',
@@ -109,6 +110,11 @@ export class CodexProvider extends Provider {
109
110
  return { command: 'codex', args, env: {} };
110
111
  }
111
112
 
113
+ _getMaxContext() {
114
+ const model = CodexProvider.models.find((m) => m.id === this._currentModel);
115
+ return model?.maxContext || 200000;
116
+ }
117
+
112
118
  switchModel(agent, newModel) {
113
119
  return false; // Codex doesn't support mid-session model switch
114
120
  }
@@ -175,36 +181,48 @@ export class CodexProvider extends Provider {
175
181
 
176
182
  case 'item.completed': {
177
183
  const item = event.item || {};
184
+
185
+ // Accumulate usage for intermediate context estimation.
186
+ // Codex only reports full contextUsage at turn.completed — without this,
187
+ // the rotator sees stale contextUsage between turns and never triggers.
188
+ if (event.usage) {
189
+ this._sessionInputTokens += event.usage.input_tokens || 0;
190
+ }
191
+
192
+ let result = null;
178
193
  if (item.type === 'agent_message') {
179
- return {
194
+ result = {
180
195
  type: 'activity', subtype: 'assistant',
181
196
  data: [{ type: 'text', text: item.text || '' }],
182
197
  };
183
- }
184
- if (item.type === 'command_execution') {
198
+ } else if (item.type === 'command_execution') {
185
199
  const output = (item.aggregated_output || '').slice(0, 2000);
186
- return {
200
+ result = {
187
201
  type: 'activity', subtype: 'assistant',
188
202
  data: [
189
203
  { type: 'tool_use', id: item.id || 'exec', name: 'Bash', input: { command: item.command } },
190
204
  ...(output ? [{ type: 'text', text: output }] : []),
191
205
  ],
192
206
  };
193
- }
194
- if (item.type === 'todo_list') {
207
+ } else if (item.type === 'todo_list') {
195
208
  const steps = (item.items || []).map((s) => `${s.completed ? '✓' : '○'} ${s.text}`).join('\n');
196
- return {
209
+ result = {
197
210
  type: 'activity', subtype: 'assistant',
198
211
  data: [{ type: 'text', text: steps }],
199
212
  };
200
- }
201
- if (item.type === 'file_edit' || item.type === 'file_write' || item.type === 'file_read') {
202
- return {
213
+ } else if (item.type === 'file_edit' || item.type === 'file_write' || item.type === 'file_read') {
214
+ result = {
203
215
  type: 'activity', subtype: 'assistant',
204
216
  data: [{ type: 'tool_use', id: item.id || 'file', name: item.type === 'file_read' ? 'Read' : item.type === 'file_write' ? 'Write' : 'Edit', input: { path: item.path || item.file || '' } }],
205
217
  };
206
218
  }
207
- return null;
219
+
220
+ // Attach intermediate context estimate so all 7 layers see Codex progress
221
+ if (result && this._sessionInputTokens > 0) {
222
+ result.contextUsage = this._sessionInputTokens / this._getMaxContext();
223
+ }
224
+
225
+ return result;
208
226
  }
209
227
 
210
228
  case 'turn.completed': {
@@ -215,11 +233,15 @@ export class CodexProvider extends Provider {
215
233
  const outputTokens = usage.output_tokens || 0;
216
234
  const cachedTokens = usage.cached_input_tokens || 0;
217
235
  const totalTokens = inputTokens + outputTokens;
236
+ const cacheCreationTokens = cachedTokens > 0 ? Math.max(0, inputTokens - cachedTokens) : 0;
218
237
 
219
238
  const model = CodexProvider.models.find((m) => m.id === this._currentModel);
220
239
  const pricing = model?.pricing;
221
240
  const maxContext = model?.maxContext || 200000;
222
241
 
242
+ // Sync accumulator to actual cumulative value from turn completion
243
+ this._sessionInputTokens = inputTokens;
244
+
223
245
  let estimatedCostUsd = 0;
224
246
  if (pricing) {
225
247
  const newInput = inputTokens - cachedTokens;
@@ -235,6 +257,7 @@ export class CodexProvider extends Provider {
235
257
  inputTokens,
236
258
  outputTokens,
237
259
  cacheReadTokens: cachedTokens,
260
+ cacheCreationTokens,
238
261
  contextUsage: inputTokens / maxContext,
239
262
  estimatedCostUsd,
240
263
  costSource: pricing ? 'calculated' : 'estimated',