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 +0 -7
- package/SECURITY_SWEEP.md +228 -0
- package/node_modules/@groove-dev/cli/package.json +3 -3
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/introducer.js +7 -7
- package/node_modules/@groove-dev/daemon/src/journalist.js +36 -6
- package/node_modules/@groove-dev/daemon/src/memory.js +29 -10
- package/node_modules/@groove-dev/daemon/src/process.js +17 -12
- package/node_modules/@groove-dev/daemon/src/providers/codex.js +34 -11
- package/node_modules/@groove-dev/daemon/src/rotator.js +24 -1
- package/node_modules/@groove-dev/daemon/test/introducer.test.js +63 -0
- package/node_modules/@groove-dev/daemon/test/journalist.test.js +106 -0
- package/node_modules/@groove-dev/daemon/test/memory.test.js +49 -0
- package/node_modules/@groove-dev/daemon/test/rotator.test.js +99 -0
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/package.json +1 -1
- package/packages/cli/package.json +3 -3
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/introducer.js +7 -7
- package/packages/daemon/src/journalist.js +36 -6
- package/packages/daemon/src/memory.js +29 -10
- package/packages/daemon/src/process.js +17 -12
- package/packages/daemon/src/providers/codex.js +34 -11
- package/packages/daemon/src/rotator.js +24 -1
- package/packages/gui/package.json +1 -1
- package/MUST_FIX_ISSUES.md +0 -305
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.
|
|
4
|
-
"description": "GROOVE CLI
|
|
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
|
+
}
|
|
@@ -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,
|
|
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:
|
|
371
|
-
if (memorySection.length >
|
|
372
|
-
memorySection = memorySection.slice(0,
|
|
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 =
|
|
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
|
|
458
|
-
const
|
|
459
|
-
|| provider.constructor.models?.find((m) => m.tier === '
|
|
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 =
|
|
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
|
-
|
|
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
|
-
//
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
|
|
435
|
-
const
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
209
|
+
result = {
|
|
197
210
|
type: 'activity', subtype: 'assistant',
|
|
198
211
|
data: [{ type: 'text', text: steps }],
|
|
199
212
|
};
|
|
200
|
-
}
|
|
201
|
-
|
|
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
|
-
|
|
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',
|