crewforth 3.2.0-rc.1 → 3.2.0-rc.3
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/CHANGELOG.md +133 -3
- package/README.md +2 -0
- package/VERSION +1 -1
- package/adopt.sh +55 -6
- package/bin/cli.js +3 -2
- package/kit/CLAUDE.md +6 -6
- package/kit/agents/crew-review-agent.md +2 -0
- package/kit/eval/doctor.sh +37 -3
- package/kit/eval/gate-report.sh +2 -0
- package/kit/eval/lib/crew-map/narrative.mjs +42 -8
- package/kit/eval/lib/crew-map/project.mjs +3 -2
- package/kit/eval/lib/crew-map/rules/tech.json +35 -0
- package/kit/eval/lib/crew-map/scan.mjs +56 -4
- package/kit/eval/lib/crew-map/stacks.mjs +114 -8
- package/kit/eval/lib/crew-map/surface.mjs +62 -1
- package/kit/eval/lib/gate-log.sh +52 -0
- package/kit/eval/lib/json-string.awk +27 -0
- package/kit/eval/lib/preset.sh +109 -0
- package/kit/eval/lib/presets/default.json +7 -0
- package/kit/eval/lib/presets/solo.json +7 -0
- package/kit/eval/lib/presets/strict.json +11 -0
- package/kit/eval/lib/preview.sh +150 -0
- package/kit/eval/lib/review-bench/bench.mjs +273 -0
- package/kit/eval/lib/review-prepass/prepass.mjs +203 -0
- package/kit/eval/lib/review-prepass/prepass.sh +23 -0
- package/kit/eval/lib/settings-default.sh +36 -0
- package/kit/eval/lib/settings-json.awk +14 -4
- package/kit/eval/lib/statusline.sh +4 -19
- package/kit/eval/lib/verify-cmd.sh +17 -0
- package/kit/eval/lib/version.sh +41 -0
- package/kit/eval/review-bench.sh +26 -0
- package/kit/eval/smoke-test.sh +251 -21
- package/kit/hooks/agent-outcome.sh +20 -9
- package/kit/hooks/board-sync.sh +2 -23
- package/kit/hooks/guard-agent-model.sh +114 -15
- package/kit/hooks/guard-bash.sh +36 -21
- package/kit/hooks/guard-write.sh +3 -7
- package/kit/hooks/precompact-snapshot.sh +75 -0
- package/kit/hooks/session-rehydrate.sh +25 -2
- package/kit/hooks/session-update-check.sh +9 -8
- package/kit/settings.json +12 -0
- package/kit/skills/crew-code-review/SKILL.md +16 -0
- package/kit/skills/crew-map/SKILL.md +16 -5
- package/kit/skills/crew-review/SKILL.md +2 -1
- package/kit/studio/README.md +21 -10
- package/kit/studio/server/index.js +85 -22
- package/kit/studio/server/lib/code-stamp.js +35 -0
- package/kit/studio/server/lib/graph.js +9 -1
- package/kit/studio/server/lib/kit-telemetry.js +30 -5
- package/kit/studio/server/lib/lan.js +87 -0
- package/kit/studio/server/lib/owned-stream.js +56 -0
- package/kit/studio/server/lib/projects.js +11 -4
- package/kit/studio/server/lib/redact.js +60 -0
- package/kit/studio/server/lib/remote.js +104 -18
- package/kit/studio/server/lib/session.js +1 -1
- package/kit/studio/server/lib/terminal.js +51 -13
- package/kit/studio/server/lib/transcript.js +32 -0
- package/kit/studio/web/app.js +79 -11
- package/kit/studio/web/canvas.js +14 -2
- package/kit/studio/web/chat.js +114 -24
- package/kit/studio/web/graph-plan.js +3 -2
- package/kit/studio/web/liveness.js +14 -0
- package/kit/studio/web/map-plan.js +72 -3
- package/kit/studio/web/map.js +103 -10
- package/kit/studio/web/qr.js +200 -0
- package/kit/studio/web/style.css +40 -0
- package/kit/studio/web/tokens.css +6 -0
- package/package.json +1 -1
- package/start.sh +14 -1
|
@@ -27,18 +27,29 @@ the scan itself read, and a gate holds it to that. **Never commit.** Show the di
|
|
|
27
27
|
|
|
28
28
|
3. **Describe.** Read `.crewforth/crew-map.json`, and as much of the cited source as you need. Then edit that
|
|
29
29
|
file, changing ONLY these fields:
|
|
30
|
-
- each `module` node's `narrative`: `{ "text": "…", "cites": ["file:line", …]
|
|
31
|
-
at most two sentences. Every cite is a `file:line`
|
|
32
|
-
|
|
30
|
+
- each `module` node's `narrative`: `{ "text": "…", "cites": ["file:line", …], "layer": "…", "domain": "…" }`.
|
|
31
|
+
What the module is for, in at most two sentences. Every cite is a `file:line` the scan read for THAT node: its
|
|
32
|
+
`evidence`, its `entryPoint`, or its `about` (the first prose line of its README); cite at least one. `layer` is where it sits, one of `ui` (what a person uses: an app, a site),
|
|
33
|
+
`api` (serves requests to others), `service` (runs work: a worker, a job), `data` (holds or shapes data),
|
|
34
|
+
`infra` (deploys and runs the rest), `tool` (a script or CLI for the team), `library` (code other modules
|
|
35
|
+
import); `domain` is the part of the business it serves, a short phrase ("payments", "mail import"). Both
|
|
36
|
+
are optional and rest on the same cites: leave one out rather than guess. Leave a node `null` when its
|
|
37
|
+
evidence does not tell you what it is for: an honest gap beats a guess.
|
|
33
38
|
- the document's `overview`: the same shape, at most 2000 characters, for someone new to the project: the entry
|
|
34
39
|
points, where data goes, the risky places. Its cites may be any node's evidence.
|
|
40
|
+
- the document's `tour`: `[{ "node": "<id>", "text": "…", "cites": ["file:line"] }, …]`, one to eight stops a
|
|
41
|
+
newcomer reads in order, usually the entry point first and the riskiest place last. Each stop names a node of
|
|
42
|
+
the map, says in at most two sentences why to look there, and cites lines from that node's own evidence.
|
|
35
43
|
Describe what the cited lines show. A connection, a purpose or a risk the scan did not find is not added here:
|
|
36
44
|
say it to the user instead.
|
|
37
45
|
|
|
38
46
|
4. **Validate:** `bash MAP --validate` (Bash tool, not PowerShell). It refuses a description that cites a line
|
|
39
|
-
the scan did not read, cites nothing, or runs past two sentences
|
|
47
|
+
the scan did not read, cites nothing, or runs past two sentences; a layer not on the list or a domain written
|
|
48
|
+
as a sentence; a tour stop at no node, citing another node's lines, or at a node twice; and a map whose
|
|
49
|
+
scanner fields were changed.
|
|
40
50
|
Fix what it names, in the descriptions only, until it holds.
|
|
41
51
|
|
|
42
|
-
5. **Hand over:** the scan's summary line, how many modules you described and
|
|
52
|
+
5. **Hand over:** the scan's summary line, how many modules you described (and gave a layer) and how many you
|
|
53
|
+
left `null` and why, the tour's stops,
|
|
43
54
|
then `git diff --stat .crewforth/crew-map.json`. Studio's **Map** view (`/crew-studio`) draws the result.
|
|
44
55
|
Committing it is the user's call.
|
|
@@ -16,7 +16,8 @@ Run the change set through the review trio (read-only). **The audits go out in p
|
|
|
16
16
|
- @agent-crew-privacy-agent (`privacy-compliance`) — **when the diff touches personal data**: legal basis,
|
|
17
17
|
minimisation, retention, transfer.
|
|
18
18
|
2. (if SonarQube is in use) **sonarqube-check** — 0/0/0/0 gate (language-agnostic).
|
|
19
|
-
3. **Last, once 1-2 leave no blocker:** @agent-crew-review-agent (crew-code-review) —
|
|
19
|
+
3. **Last, once 1-2 leave no blocker:** @agent-crew-review-agent (crew-code-review) — it starts from the
|
|
20
|
+
deterministic pre-pass (`.crewforth/review-prepass.json`) and writes `.crewforth/review.json`; "does it improve overall
|
|
20
21
|
code health": the plan (what changed, the risks), then findings with a severity and a category. "Clean" means
|
|
21
22
|
what crew-code-review means by it: **no critical or high finding open**. A medium or low audit finding does not
|
|
22
23
|
hold the reviewer back; hand it over in the reviewer's prompt and keep it in the report. A critical or high one
|
package/kit/studio/README.md
CHANGED
|
@@ -201,14 +201,23 @@ loopback and the tunnel does the crossing.
|
|
|
201
201
|
## Remote viewers
|
|
202
202
|
|
|
203
203
|
A viewer is a second token that reads and changes nothing, for watching the
|
|
204
|
-
panel from
|
|
205
|
-
Machines tab; its link is shown once
|
|
206
|
-
request that is not a `GET`, and its
|
|
207
|
-
handler runs (`server/lib/remote.js`).
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
204
|
+
panel from a phone on the same Wi-Fi without handing it the panel. Add one in
|
|
205
|
+
the Machines tab; its link is shown once, as a QR code (`web/qr.js`, made in the
|
|
206
|
+
page). The server refuses a viewer's every request that is not a `GET`, and its
|
|
207
|
+
reads of `/api/remote`, with 403 before any handler runs (`server/lib/remote.js`).
|
|
208
|
+
A viewer lasts eight hours, then its link answers 401 and the page says Session
|
|
209
|
+
expired. Revoke ends the token at once and closes a stream it holds open.
|
|
210
|
+
Viewers live in memory and end with the server.
|
|
211
|
+
|
|
212
|
+
Transcripts are hidden unless the viewer was added with them shown: the routes
|
|
213
|
+
that are only transcript are refused, and every other answer, the event stream
|
|
214
|
+
included, passes through `server/lib/redact.js`, which drops every string whose
|
|
215
|
+
key is not on its allow-list.
|
|
216
|
+
|
|
217
|
+
While a viewer is live the server also listens on the machine's private Wi-Fi
|
|
218
|
+
address (`server/lib/lan.js`), and there it takes a viewer's token only, never
|
|
219
|
+
the panel's. With none, it is on loopback alone. Every viewer connection and
|
|
220
|
+
refusal is logged (`GET /api/remote`, and the server's output).
|
|
212
221
|
|
|
213
222
|
## What Crewforth already measured
|
|
214
223
|
|
|
@@ -261,8 +270,10 @@ channel that does not exist.
|
|
|
261
270
|
|
|
262
271
|
## Security
|
|
263
272
|
|
|
264
|
-
The server binds to `127.0.0.1
|
|
265
|
-
|
|
273
|
+
The server binds to `127.0.0.1`, and the host is deliberately not configurable —
|
|
274
|
+
it reads a developer's live sessions and can start new ones. The one exception is
|
|
275
|
+
the Wi-Fi listener above: open only while a viewer is live, for viewer tokens
|
|
276
|
+
only, read-only.
|
|
266
277
|
|
|
267
278
|
A token is always required for `/api/`. Supply one, or let the server generate
|
|
268
279
|
one and print it with the URL:
|
|
@@ -21,6 +21,8 @@ import { projectDir, listSessions, findSession, listProjects, sessionCwd, agentM
|
|
|
21
21
|
import { agentOrigins } from './lib/agent-origin.js';
|
|
22
22
|
import { buildGraph, agentDetail, conversation, STALE_MS } from './lib/graph.js';
|
|
23
23
|
import { cachedUsage } from './lib/usage.js';
|
|
24
|
+
import { streamOwned } from './lib/owned-stream.js';
|
|
25
|
+
import { stampAtStart } from './lib/code-stamp.js';
|
|
24
26
|
import { readModels, readRecord, readCosts, readAboveCosts } from './lib/model-outcomes.js';
|
|
25
27
|
import { readMap, behindOf, compareMaps, mapSummary, MAP_FILE } from './lib/crew-map.js';
|
|
26
28
|
|
|
@@ -29,19 +31,24 @@ import { palette } from './lib/palette.js';
|
|
|
29
31
|
import { latestVersion, latestVersionCached, kitStatus } from './lib/kit.js';
|
|
30
32
|
import { parsePeers, askAll, ask } from './lib/peers.js';
|
|
31
33
|
import {
|
|
32
|
-
createSession, getSession, listSessionsOwned, reap, stopAll, dropSpools, ALLOWED_MODES,
|
|
34
|
+
createSession, getSession, listSessionsOwned, reap, stopAll, dropSpools, ALLOWED_MODES, isHeldOpen,
|
|
33
35
|
} from './lib/session.js';
|
|
34
36
|
import { decide, pending, alwaysList, revoke, sweepLeft } from './lib/permissions.js';
|
|
35
|
-
import { open as openTerminal, plan as terminalPlan } from './lib/terminal.js';
|
|
37
|
+
import { open as openTerminal, plan as terminalPlan, desktopLine } from './lib/terminal.js';
|
|
36
38
|
import { gateLog, gateReport, sessionStats, board } from './lib/kit-telemetry.js';
|
|
37
39
|
import { writeState, clearStateSync, findRunning } from './lib/instance.js';
|
|
38
40
|
import { remoteRoster } from './lib/roster.js';
|
|
39
|
-
import { addViewer, listViewers, viewerByToken, revokeViewer, noteRequest, viewerMay, MAX_VIEWERS
|
|
41
|
+
import { addViewer, listViewers, viewerByToken, revokeViewer, noteRequest, noteRefusal, viewerMay, MAX_VIEWERS, VIEWER_TTL_MS,
|
|
42
|
+
liveCount, remoteLog, sweep as sweepViewers, onViewersChange, onLog } from './lib/remote.js';
|
|
43
|
+
import { redact, transcriptOnly } from './lib/redact.js';
|
|
44
|
+
import { lanListener } from './lib/lan.js';
|
|
40
45
|
import { randomUUID } from 'node:crypto';
|
|
41
46
|
import { spawn } from 'node:child_process';
|
|
42
47
|
|
|
43
48
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
44
49
|
const WEB_ROOT = path.resolve(HERE, '..', 'web');
|
|
50
|
+
// The server's own code as it was when it started: an update on disk since then is said to the page (code-stamp.js).
|
|
51
|
+
const codeChanged = stampAtStart(HERE);
|
|
45
52
|
const LOOPBACK = '127.0.0.1';
|
|
46
53
|
|
|
47
54
|
const MIME = {
|
|
@@ -94,8 +101,12 @@ function send(res, status, body, headers = {}) {
|
|
|
94
101
|
res.end(body);
|
|
95
102
|
}
|
|
96
103
|
|
|
104
|
+
// The answers that go to a viewer whose link hides transcripts: every one of them passes through redact() on its way
|
|
105
|
+
// out, here and in the event stream, so no route has to remember to.
|
|
106
|
+
const REDACTED = new WeakSet();
|
|
107
|
+
|
|
97
108
|
function sendJson(res, status, value) {
|
|
98
|
-
send(res, status, JSON.stringify(value, null, 2), {
|
|
109
|
+
send(res, status, JSON.stringify(REDACTED.has(res) ? redact(value) : value, null, 2), {
|
|
99
110
|
'content-type': 'application/json; charset=utf-8',
|
|
100
111
|
});
|
|
101
112
|
}
|
|
@@ -110,8 +121,12 @@ function sendJson(res, status, value) {
|
|
|
110
121
|
const TOKEN = process.env.CREW_STUDIO_TOKEN || randomUUID();
|
|
111
122
|
const TOKEN_GENERATED = !process.env.CREW_STUDIO_TOKEN;
|
|
112
123
|
|
|
124
|
+
// The Wi-Fi listener (lib/lan.js): set by main() once the port is known, and opened and closed with the number of
|
|
125
|
+
// live viewers. Null in the self-check unless it makes one.
|
|
126
|
+
let LAN = null;
|
|
127
|
+
|
|
113
128
|
/** For the self-check, which asks a real server as the owner and as a viewer. */
|
|
114
|
-
export const _internals = { token: TOKEN };
|
|
129
|
+
export const _internals = { token: TOKEN, setLan: (l) => { LAN = l; } };
|
|
115
130
|
|
|
116
131
|
// Peers are other machines running Studio, reached over a forwarded port. This
|
|
117
132
|
// server still listens on loopback only; a peer never widens that.
|
|
@@ -126,9 +141,12 @@ function whoAsks(req, url) {
|
|
|
126
141
|
const header = req.headers['authorization'];
|
|
127
142
|
const bearer = typeof header === 'string' && header.startsWith('Bearer ') ? header.slice(7) : null;
|
|
128
143
|
const given = [bearer, url.searchParams.get('token')].filter(Boolean);
|
|
129
|
-
|
|
144
|
+
// The panel's own token is accepted on loopback only. From the Wi-Fi it is not a token at all: a phone holding it
|
|
145
|
+
// would hold the panel, and nothing on the network should.
|
|
146
|
+
if (!req.crewLan && given.includes(TOKEN)) return { role: 'owner' };
|
|
130
147
|
for (const t of given) {
|
|
131
148
|
const viewer = viewerByToken(t);
|
|
149
|
+
if (viewer?.expired) return { role: 'expired', label: viewer.expired };
|
|
132
150
|
if (viewer) return { role: 'viewer', viewer };
|
|
133
151
|
}
|
|
134
152
|
return null;
|
|
@@ -243,14 +261,30 @@ export async function handle(req, res) {
|
|
|
243
261
|
// it for /style.css served an unstyled page and protected nothing: the shell
|
|
244
262
|
// holds no secrets, and every /api/ path below is still gated.
|
|
245
263
|
const who = url.pathname.startsWith('/api/') ? whoAsks(req, url) : null;
|
|
264
|
+
// From the Wi-Fi, no token (or the panel's own) is 401: the network is asked who it is. On loopback the answer
|
|
265
|
+
// stays 403, as it always was.
|
|
246
266
|
if (url.pathname.startsWith('/api/') && !who) {
|
|
247
|
-
|
|
267
|
+
if (req.crewLan) noteRefusal(req, { status: 401, reason: 'no viewer token' });
|
|
268
|
+
return sendJson(res, req.crewLan ? 401 : 403, { ok: false, reason: 'token required' });
|
|
269
|
+
}
|
|
270
|
+
if (who?.role === 'expired') {
|
|
271
|
+
noteRefusal(req, { viewer: who.label, status: 401, reason: 'expired' });
|
|
272
|
+
return sendJson(res, 401, { ok: false, expired: true, reason: 'this link has expired: viewer links last eight hours' });
|
|
248
273
|
}
|
|
249
274
|
// A viewer is refused HERE, before any handler: no route below has to remember that a viewer exists, and a
|
|
250
|
-
// route added tomorrow is read-only for a viewer without anyone writing a line for it.
|
|
275
|
+
// route added tomorrow is read-only for a viewer without anyone writing a line for it. The same holds for a
|
|
276
|
+
// link that hides transcripts: the routes that are nothing but transcript are refused here, and every other
|
|
277
|
+
// answer is filtered on its way out (REDACTED).
|
|
251
278
|
if (who?.role === 'viewer') {
|
|
252
279
|
const may = viewerMay(req.method, url.pathname);
|
|
253
|
-
if (!may.ok) return sendJson(res, 403, { ok: false, reason: may.reason });
|
|
280
|
+
if (!may.ok) { noteRefusal(req, { viewer: who.viewer.label, status: 403, reason: may.reason }); return sendJson(res, 403, { ok: false, reason: may.reason }); }
|
|
281
|
+
if (!who.viewer.transcripts) {
|
|
282
|
+
if (transcriptOnly(url.pathname)) {
|
|
283
|
+
noteRefusal(req, { viewer: who.viewer.label, status: 403, reason: 'transcripts hidden' });
|
|
284
|
+
return sendJson(res, 403, { ok: false, transcriptsHidden: true, reason: 'transcripts are hidden on this link' });
|
|
285
|
+
}
|
|
286
|
+
REDACTED.add(res);
|
|
287
|
+
}
|
|
254
288
|
noteRequest(who.viewer, req, res);
|
|
255
289
|
}
|
|
256
290
|
|
|
@@ -258,9 +292,13 @@ export async function handle(req, res) {
|
|
|
258
292
|
return sendJson(res, 200, {
|
|
259
293
|
ok: true,
|
|
260
294
|
role: who.role,
|
|
295
|
+
...(who.role === 'viewer' ? { transcripts: who.viewer.transcripts, expiresAt: who.viewer.expiresAt } : {}),
|
|
261
296
|
node: process.version,
|
|
262
297
|
pid: process.pid,
|
|
263
298
|
uptimeMs: Math.round(process.uptime() * 1000),
|
|
299
|
+
// `changed`: the server's files on disk are not the ones it started with. A page that finds no `code` at all
|
|
300
|
+
// is talking to a server older than this field, which is the same thing.
|
|
301
|
+
code: codeChanged(),
|
|
264
302
|
});
|
|
265
303
|
}
|
|
266
304
|
|
|
@@ -495,8 +533,12 @@ export async function handle(req, res) {
|
|
|
495
533
|
const cwd = url.searchParams.get('cwd') || (session ? await sessionCwd(session.file, session.bytes) : null);
|
|
496
534
|
|
|
497
535
|
if (req.method === 'GET') {
|
|
498
|
-
// What WOULD run, so the panel can show it before anything is launched
|
|
499
|
-
|
|
536
|
+
// What WOULD run, so the panel can show it before anything is launched; and, before Continue here is pressed,
|
|
537
|
+
// what it will do: continue this same session (nothing else holds it), or start a copy (something does). The
|
|
538
|
+
// answer is the one createSession acts on, asked the same way.
|
|
539
|
+
const held = await isHeldOpen(id);
|
|
540
|
+
return sendJson(res, 200, { measured: true, cwd, plan: cwd ? terminalPlan({ cwd, sessionId: id }) : null,
|
|
541
|
+
continueAs: held ? 'copy' : 'same', desktop: cwd ? desktopLine({ cwd, sessionId: id }) : null });
|
|
500
542
|
}
|
|
501
543
|
|
|
502
544
|
const gate = writeAllowed(req);
|
|
@@ -508,7 +550,6 @@ export async function handle(req, res) {
|
|
|
508
550
|
const out = openTerminal({
|
|
509
551
|
cwd: typeof body.cwd === 'string' ? body.cwd : cwd,
|
|
510
552
|
sessionId: id,
|
|
511
|
-
command: typeof body.command === 'string' ? body.command : undefined,
|
|
512
553
|
});
|
|
513
554
|
return sendJson(res, out.ok ? 200 : 400, out);
|
|
514
555
|
}
|
|
@@ -522,7 +563,8 @@ export async function handle(req, res) {
|
|
|
522
563
|
the owner reaches this far; a viewer was refused above. */
|
|
523
564
|
|
|
524
565
|
if (url.pathname === '/api/remote' && req.method === 'GET') {
|
|
525
|
-
return sendJson(res, 200, { measured: true, viewers: listViewers(), max: MAX_VIEWERS, now: Date.now()
|
|
566
|
+
return sendJson(res, 200, { measured: true, viewers: listViewers(), max: MAX_VIEWERS, ttlMs: VIEWER_TTL_MS, now: Date.now(),
|
|
567
|
+
lan: LAN ? LAN.state() : { listening: false, addresses: [], errors: [] }, log: remoteLog(30) });
|
|
526
568
|
}
|
|
527
569
|
|
|
528
570
|
const remoteMatch = url.pathname.match(/^\/api\/remote(?:\/([^/]+))?$/);
|
|
@@ -532,11 +574,16 @@ export async function handle(req, res) {
|
|
|
532
574
|
if (req.method === 'POST' && !remoteMatch[1]) {
|
|
533
575
|
const body = await readBody(req);
|
|
534
576
|
if (!body) return sendJson(res, 400, { ok: false, reason: 'body was not JSON' });
|
|
535
|
-
const made = addViewer(body.label);
|
|
536
|
-
|
|
577
|
+
const made = addViewer(body.label, { transcripts: body.transcripts === true });
|
|
578
|
+
// The Wi-Fi listener opens with the first viewer; its links are known once it listens.
|
|
579
|
+
if (made.ok && LAN) await LAN.sync(liveCount());
|
|
580
|
+
const lanUrls = made.ok && LAN ? LAN.urls(made.viewer.token) : [];
|
|
581
|
+
return sendJson(res, made.ok ? 201 : 409, { ...made, lanUrls, lan: LAN ? LAN.state() : null, viewers: listViewers() });
|
|
537
582
|
}
|
|
538
583
|
if (req.method === 'DELETE' && remoteMatch[1]) {
|
|
539
|
-
|
|
584
|
+
const out = revokeViewer(decodeURIComponent(remoteMatch[1]));
|
|
585
|
+
if (LAN) await LAN.sync(liveCount());
|
|
586
|
+
return sendJson(res, 200, { ...out, lan: LAN ? LAN.state() : null, viewers: listViewers() });
|
|
540
587
|
}
|
|
541
588
|
return sendJson(res, 405, { ok: false, reason: 'POST to add a viewer, or DELETE a viewer id' });
|
|
542
589
|
}
|
|
@@ -603,6 +650,10 @@ export async function handle(req, res) {
|
|
|
603
650
|
return sendJson(res, out.ok ? 200 : 400, out);
|
|
604
651
|
}
|
|
605
652
|
|
|
653
|
+
// Every open conversation on ONE connection (lib/owned-stream.js). Reading, so it sits beside /events, not
|
|
654
|
+
// behind the write gate.
|
|
655
|
+
if (url.pathname === '/api/owned-events') return streamOwned(req, res, url, { getSession, stateAfter: STATE_AFTER });
|
|
656
|
+
|
|
606
657
|
const ownedMatch = url.pathname.match(/^\/api\/owned\/([^/]+)(?:\/(message|stop|events|mode))?$/);
|
|
607
658
|
if (ownedMatch) {
|
|
608
659
|
const s = getSession(decodeURIComponent(ownedMatch[1]));
|
|
@@ -733,7 +784,7 @@ async function stream(req, res, url) {
|
|
|
733
784
|
});
|
|
734
785
|
|
|
735
786
|
const send = (event, data) => {
|
|
736
|
-
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
787
|
+
res.write(`event: ${event}\ndata: ${JSON.stringify(REDACTED.has(res) ? redact(data) : data)}\n\n`);
|
|
737
788
|
};
|
|
738
789
|
|
|
739
790
|
// A session the panel just started has no transcript until its first turn
|
|
@@ -977,9 +1028,11 @@ async function main() {
|
|
|
977
1028
|
' --help this text\n\n' +
|
|
978
1029
|
'Set CREW_STUDIO_TOKEN to require a bearer token, CREW_STUDIO_PEERS for a\n' +
|
|
979
1030
|
'comma-separated peer list.\n\n' +
|
|
980
|
-
'To watch from
|
|
981
|
-
'and
|
|
982
|
-
'
|
|
1031
|
+
'To watch from a phone on the same Wi-Fi, add a viewer under Machines in\n' +
|
|
1032
|
+
'the panel and scan its QR code. A viewer reads and changes nothing, its\n' +
|
|
1033
|
+
'link lasts eight hours, and transcripts stay hidden unless you show them.\n' +
|
|
1034
|
+
'While a viewer is live the server also listens on this machine\'s Wi-Fi\n' +
|
|
1035
|
+
'address, for viewer links only; with none it is on loopback alone.\n\n' +
|
|
983
1036
|
'Peers are normally reached over a forwarded port, which keeps every\n' +
|
|
984
1037
|
'Studio on loopback:\n' +
|
|
985
1038
|
' ssh -N -L 7778:127.0.0.1:7777 other-machine\n' +
|
|
@@ -996,12 +1049,21 @@ async function main() {
|
|
|
996
1049
|
// Only a server that is about to run does this; asking for --help or --selftest removes nothing.
|
|
997
1050
|
sweepLeft();
|
|
998
1051
|
|
|
999
|
-
const
|
|
1052
|
+
const handler = (req, res) => {
|
|
1000
1053
|
handle(req, res).catch((e) => {
|
|
1001
1054
|
process.stderr.write(`crewforth-studio: unhandled: ${e?.stack ?? e}\n`);
|
|
1002
1055
|
if (!res.headersSent) send(res, 500, 'internal error');
|
|
1003
1056
|
});
|
|
1004
|
-
}
|
|
1057
|
+
};
|
|
1058
|
+
const server = http.createServer(handler);
|
|
1059
|
+
|
|
1060
|
+
// Viewers on the same Wi-Fi: a second listener on this machine's private address, open only while a viewer is
|
|
1061
|
+
// live (lib/lan.js). It closes when the last one is revoked or expires; the sweep ends expired ones even when no
|
|
1062
|
+
// request comes to notice them.
|
|
1063
|
+
LAN = lanListener({ handler, port: args.port });
|
|
1064
|
+
onViewersChange((n) => { LAN.sync(n); });
|
|
1065
|
+
onLog((e) => process.stdout.write(` viewer ${e.event}: ${e.viewer ?? 'unknown'}${e.ip ? ` from ${e.ip}` : ''}${e.over ? ` (${e.over})` : ''}${e.result ? ` — ${e.result}` : ''}\n`));
|
|
1066
|
+
setInterval(sweepViewers, 60 * 1000).unref();
|
|
1005
1067
|
|
|
1006
1068
|
// A BUSY PORT IS NOT AUTOMATICALLY A PROBLEM. Before 2.10.2 this printed "try --port n+1" and exited 1, which
|
|
1007
1069
|
// is the right answer only when a stranger holds the port. When the holder is our OWN panel -- the common case,
|
|
@@ -1082,6 +1144,7 @@ async function main() {
|
|
|
1082
1144
|
// leaks running sessions is worse than one that never started them.
|
|
1083
1145
|
stopAll();
|
|
1084
1146
|
dropSpools();
|
|
1147
|
+
LAN?.close();
|
|
1085
1148
|
server.close(() => process.exit(0));
|
|
1086
1149
|
setTimeout(() => process.exit(0), 2000).unref();
|
|
1087
1150
|
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// code-stamp.js — is the code this server runs still the code on disk?
|
|
2
|
+
//
|
|
3
|
+
// A server keeps the code it started with. An update replaces the files on disk, the page is served fresh from
|
|
4
|
+
// them, and the two disagree: measured in a real project, a server started before 3.2.0 answered the new Map
|
|
5
|
+
// view's /api/map with 404, which the view showed as "Not read — HTTP 404". The stamp is the size and mtime of
|
|
6
|
+
// every file of the server, taken at start and again when asked; when they differ, the page says to restart.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
/** Size and mtime of every .js/.mjs/.sh file under `dir` (the server's own code), as one string. */
|
|
11
|
+
export function codeStamp(dir) {
|
|
12
|
+
const parts = [];
|
|
13
|
+
const walk = (d) => {
|
|
14
|
+
let entries;
|
|
15
|
+
try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
|
|
16
|
+
for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
|
|
17
|
+
const p = path.join(d, e.name);
|
|
18
|
+
if (e.isDirectory()) { if (e.name !== 'node_modules') walk(p); continue; }
|
|
19
|
+
if (!/\.(m?js|sh)$/.test(e.name)) continue;
|
|
20
|
+
try { const st = fs.statSync(p); parts.push(`${path.relative(dir, p)}:${st.size}:${Math.round(st.mtimeMs)}`); } catch { /* gone */ }
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
walk(dir);
|
|
24
|
+
return parts.join('|');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Stamp the code now, and answer later whether it has changed on disk (re-read at most every `everyMs`). */
|
|
28
|
+
export function stampAtStart(dir, { everyMs = 5000, now = () => Date.now() } = {}) {
|
|
29
|
+
const boot = codeStamp(dir);
|
|
30
|
+
let last = boot; let at = now();
|
|
31
|
+
return () => {
|
|
32
|
+
if (now() - at >= everyMs) { last = codeStamp(dir); at = now(); }
|
|
33
|
+
return { changed: last !== boot };
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import fsp from 'node:fs/promises';
|
|
18
18
|
import path from 'node:path';
|
|
19
19
|
|
|
20
|
-
import { readAll, contextFill } from './transcript.js';
|
|
20
|
+
import { readAll, contextFill, contextMix } from './transcript.js';
|
|
21
21
|
import { agentMetaFiles } from './projects.js';
|
|
22
22
|
import { Usage, withTimes } from './usage.js';
|
|
23
23
|
import { detailOf } from './permissions.js';
|
|
@@ -139,6 +139,7 @@ export function linkRetries(calls) {
|
|
|
139
139
|
if (!c.refused) return;
|
|
140
140
|
const again = list.slice(i + 1).find((n) => n.subagentType === c.subagentType && n.description === c.description);
|
|
141
141
|
if (!again) return;
|
|
142
|
+
c.retried = true;
|
|
142
143
|
// A call refused twice keeps the first thing that was asked.
|
|
143
144
|
again.firstTry = c.firstTry ?? { asked: c.model, floor: c.refused.floor, text: c.refused.text };
|
|
144
145
|
});
|
|
@@ -456,7 +457,14 @@ export async function buildGraph(session, { staleMs = STALE_MS } = {}) {
|
|
|
456
457
|
version: main.version,
|
|
457
458
|
status: 'session',
|
|
458
459
|
turns: main.userTurns,
|
|
460
|
+
// Agent calls the model gate refused and the session has not made again: no agent started, so nothing else on
|
|
461
|
+
// the canvas says they happened (seen on a real project: two refused calls, an empty canvas, "2 failed" only in
|
|
462
|
+
// the session's own view). A refused call made again is shown on the call that ran (`firstTry`).
|
|
463
|
+
refusedCalls: [...main.agentCalls.values()].filter((c) => c.refused && !c.retried)
|
|
464
|
+
.map((c) => ({ agent: c.subagentType ?? null, model: c.model ?? null, text: c.refused.text })),
|
|
459
465
|
tokens: contextFill(records),
|
|
466
|
+
// What that context is made of: the tool-output share since the last compaction, and the compactions so far.
|
|
467
|
+
context: contextMix(records),
|
|
460
468
|
startedAt: main.startedAt,
|
|
461
469
|
endedAt: main.updatedAt,
|
|
462
470
|
usage: withTimes(usage, records),
|
|
@@ -52,11 +52,36 @@ function kitPaths(cwd) {
|
|
|
52
52
|
* empty unless CREW_GATE_LOG_CMD=1 was set, and there is no timestamp at all —
|
|
53
53
|
* both facts are returned rather than papered over.
|
|
54
54
|
*/
|
|
55
|
+
/**
|
|
56
|
+
* Where the gate log is, by the rule the gates write by (eval/lib/gate-log.sh): .claude/gate-log.tsv, else the
|
|
57
|
+
* repository's own .git/crewforth-gate-log.tsv (a project that tracks .claude/ has it there), searched from the
|
|
58
|
+
* project root up. null when there is none yet.
|
|
59
|
+
*/
|
|
60
|
+
export function gateLogPath(cwd) {
|
|
61
|
+
const own = path.join(cwd, '.claude', 'gate-log.tsv');
|
|
62
|
+
if (fs.existsSync(own)) return own;
|
|
63
|
+
let d = cwd;
|
|
64
|
+
for (let n = 0; n < 12; n += 1) {
|
|
65
|
+
const g = path.join(d, '.git');
|
|
66
|
+
let st = null;
|
|
67
|
+
try { st = fs.statSync(g); } catch { /* not here */ }
|
|
68
|
+
if (st?.isDirectory()) { const f = path.join(g, 'crewforth-gate-log.tsv'); return fs.existsSync(f) ? f : null; }
|
|
69
|
+
if (st?.isFile()) {
|
|
70
|
+
const m = /^gitdir: (.+)$/m.exec(fs.readFileSync(g, 'utf8'));
|
|
71
|
+
if (!m) return null;
|
|
72
|
+
const gd = path.resolve(d, m[1].trim()); const f = path.join(gd, 'crewforth-gate-log.tsv');
|
|
73
|
+
return fs.existsSync(f) ? f : null;
|
|
74
|
+
}
|
|
75
|
+
const up = path.dirname(d); if (up === d) return null; d = up;
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
55
80
|
export function gateLog(cwd, { limit = 200 } = {}) {
|
|
56
|
-
const file =
|
|
81
|
+
const file = gateLogPath(cwd);
|
|
57
82
|
let st;
|
|
58
|
-
try { st = fs.statSync(file); } catch {
|
|
59
|
-
return { measured: false, reason: 'no .claude/gate-log.tsv
|
|
83
|
+
try { st = fs.statSync(file ?? ''); } catch {
|
|
84
|
+
return { measured: false, reason: 'no gate log in this project yet (.claude/gate-log.tsv, or .git/crewforth-gate-log.tsv where .claude/ is tracked)', entries: [] };
|
|
60
85
|
}
|
|
61
86
|
|
|
62
87
|
// Only the tail: this file reached 21,730 lines in Crewforth's own checkout and
|
|
@@ -119,9 +144,9 @@ export async function gateReport(cwd) {
|
|
|
119
144
|
// should read is one level up. Both layouts are given what they expect rather
|
|
120
145
|
// than the script being asked to guess.
|
|
121
146
|
const from = kit.kind === 'installed' ? cwd : path.dirname(kit.hooks);
|
|
122
|
-
const logPath =
|
|
147
|
+
const logPath = gateLogPath(cwd);
|
|
123
148
|
const env = { ...process.env };
|
|
124
|
-
if (
|
|
149
|
+
if (logPath) env.CREW_GATE_LOG = logPath;
|
|
125
150
|
|
|
126
151
|
const r = await run('bash', [script, '--json'], { cwd: from, env });
|
|
127
152
|
// 3 means there is no log to read, which the script calls "not an error"; 4
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// lan.js — the listener on this machine's Wi-Fi address, for viewers on the same network.
|
|
2
|
+
//
|
|
3
|
+
// It exists only while a viewer does: index.js calls sync() with the number of live viewers, and with none it is
|
|
4
|
+
// closed and every connection on it is ended, so nothing beyond 127.0.0.1 is listening. Every request it takes is
|
|
5
|
+
// marked `crewLan`, and the request gate (index.js) answers such a request only for a viewer's token — the panel's
|
|
6
|
+
// own token is not accepted from the network.
|
|
7
|
+
//
|
|
8
|
+
// Which address: the private IPv4 addresses of this machine's interfaces (10/8, 172.16/12, 192.168/16): the ranges a
|
|
9
|
+
// home or office Wi-Fi hands out. A public, link-local or carrier-grade address is not listened on. There is no
|
|
10
|
+
// tunnel here and no way to reach the panel from outside the network.
|
|
11
|
+
import http from 'node:http';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
|
|
14
|
+
const PRIVATE = (a) => /^10\./.test(a) || /^192\.168\./.test(a) || /^172\.(1[6-9]|2\d|3[01])\./.test(a);
|
|
15
|
+
|
|
16
|
+
/** This machine's private IPv4 addresses. CREW_STUDIO_LAN_ADDR names one instead (the self-check uses loopback). */
|
|
17
|
+
export function lanAddresses(env = process.env, ifaces = os.networkInterfaces()) {
|
|
18
|
+
if (env.CREW_STUDIO_LAN_ADDR) return [env.CREW_STUDIO_LAN_ADDR];
|
|
19
|
+
const out = [];
|
|
20
|
+
for (const list of Object.values(ifaces ?? {})) {
|
|
21
|
+
for (const i of list ?? []) {
|
|
22
|
+
const v4 = i.family === 'IPv4' || i.family === 4;
|
|
23
|
+
if (v4 && !i.internal && PRIVATE(i.address) && !out.includes(i.address)) out.push(i.address);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The listener, opened and closed by the number of live viewers.
|
|
31
|
+
* @param handler the server's own request handler; a request is marked `req.crewLan = true` before it is called
|
|
32
|
+
* @param port the panel's port (CREW_STUDIO_LAN_PORT overrides it, for the self-check)
|
|
33
|
+
*/
|
|
34
|
+
export function lanListener({ handler, port, env = process.env, addresses = null }) {
|
|
35
|
+
let servers = [];
|
|
36
|
+
let errors = [];
|
|
37
|
+
let pending = null;
|
|
38
|
+
const lanPort = env.CREW_STUDIO_LAN_PORT !== undefined ? Number(env.CREW_STUDIO_LAN_PORT) : port;
|
|
39
|
+
// The port actually listened on: the asked one, or the one the system picked for 0 (the self-check).
|
|
40
|
+
const portOf = (e) => e.server.address()?.port ?? lanPort;
|
|
41
|
+
|
|
42
|
+
const open = () => {
|
|
43
|
+
const addrs = addresses ?? lanAddresses(env);
|
|
44
|
+
errors = [];
|
|
45
|
+
servers = addrs.map((addr) => {
|
|
46
|
+
const sockets = new Set();
|
|
47
|
+
const s = http.createServer((req, res) => { req.crewLan = true; handler(req, res); });
|
|
48
|
+
s.on('connection', (c) => { sockets.add(c); c.on('close', () => sockets.delete(c)); });
|
|
49
|
+
const entry = { addr, server: s, sockets, listening: false };
|
|
50
|
+
s.on('error', (e) => { errors.push(`${addr}: ${e.code ?? e.message}`); entry.listening = false; });
|
|
51
|
+
s.listen(lanPort, addr, () => { entry.listening = true; });
|
|
52
|
+
return entry;
|
|
53
|
+
});
|
|
54
|
+
return new Promise((resolve) => { let n = servers.length; if (!n) resolve(); for (const e of servers) { e.server.once('listening', () => --n || resolve()); e.server.once('error', () => --n || resolve()); } });
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const close = () => {
|
|
58
|
+
const was = servers; servers = [];
|
|
59
|
+
return Promise.all(was.map((e) => new Promise((resolve) => {
|
|
60
|
+
for (const c of e.sockets) c.destroy();
|
|
61
|
+
if (!e.listening) return resolve();
|
|
62
|
+
e.server.close(() => resolve());
|
|
63
|
+
})));
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
/** Open with at least one live viewer, close with none. Resolves once the change has happened. */
|
|
68
|
+
sync(liveViewers) {
|
|
69
|
+
pending = (pending ?? Promise.resolve()).then(() => {
|
|
70
|
+
if (liveViewers > 0 && !servers.length) return open();
|
|
71
|
+
if (liveViewers === 0 && servers.length) return close();
|
|
72
|
+
return undefined;
|
|
73
|
+
});
|
|
74
|
+
return pending;
|
|
75
|
+
},
|
|
76
|
+
state() {
|
|
77
|
+
const on = servers.filter((e) => e.listening);
|
|
78
|
+
return { listening: on.length > 0, port: on.length ? portOf(on[0]) : lanPort,
|
|
79
|
+
addresses: servers.filter((e) => e.listening).map((e) => e.addr), errors: [...errors] };
|
|
80
|
+
},
|
|
81
|
+
/** The link a viewer opens on the phone, one per address listened on. */
|
|
82
|
+
urls(token) {
|
|
83
|
+
return servers.filter((e) => e.listening).map((e) => `http://${e.addr}:${portOf(e)}/?token=${encodeURIComponent(token)}`);
|
|
84
|
+
},
|
|
85
|
+
close,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// owned-stream.js — the conversations of several owned sessions, replayed then followed, on ONE connection.
|
|
2
|
+
//
|
|
3
|
+
// A browser holds six connections to one host over HTTP/1.1, and a pane per stream reached them at five panes
|
|
4
|
+
// beside the page's own: the panel kept painting, but a message sent and every request the page made itself waited
|
|
5
|
+
// until a pane was closed (measured on Windows, Claude Code 2.1.301: a send did not arrive in 35 s, then in 8 s once
|
|
6
|
+
// two panes closed). Each event and state carries the session id it belongs to.
|
|
7
|
+
// ids=a,b,c the sessions (at most MAX_STREAM_IDS; an unknown id is answered with `gone`)
|
|
8
|
+
// after=a:12,b:3 resume each where the client last was, so a reconnect neither replays nor drops
|
|
9
|
+
|
|
10
|
+
export const MAX_STREAM_IDS = 32;
|
|
11
|
+
|
|
12
|
+
/** Read `ids` and `after` from a URL: the ids, de-duplicated and capped, and each one's resume point. */
|
|
13
|
+
export function parseOwnedQuery(url) {
|
|
14
|
+
const ids = [...new Set((url.searchParams.get('ids') ?? '').split(',').filter(Boolean))].slice(0, MAX_STREAM_IDS);
|
|
15
|
+
const after = new Map();
|
|
16
|
+
for (const part of (url.searchParams.get('after') ?? '').split(',')) {
|
|
17
|
+
const at = part.lastIndexOf(':');
|
|
18
|
+
if (at > 0) after.set(part.slice(0, at), Number(part.slice(at + 1)) || 0);
|
|
19
|
+
}
|
|
20
|
+
return { ids, after };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param getSession id -> an owned session ({ summary(), subscribe(fn, after) }) or null
|
|
25
|
+
* @param stateAfter the record types after which a session's summary is sent again
|
|
26
|
+
* @param beatMs how often every summary is sent regardless
|
|
27
|
+
*/
|
|
28
|
+
export function streamOwned(req, res, url, { getSession, stateAfter = new Set(), beatMs = 15000 }) {
|
|
29
|
+
const { ids, after } = parseOwnedQuery(url);
|
|
30
|
+
res.writeHead(200, {
|
|
31
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
32
|
+
'cache-control': 'no-store',
|
|
33
|
+
connection: 'keep-alive',
|
|
34
|
+
'x-accel-buffering': 'no',
|
|
35
|
+
});
|
|
36
|
+
const write = (event, data) => {
|
|
37
|
+
try { res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); } catch { /* client gone */ }
|
|
38
|
+
};
|
|
39
|
+
const unsubscribes = [];
|
|
40
|
+
for (const id of ids) {
|
|
41
|
+
const s = getSession(id);
|
|
42
|
+
if (!s) { write('gone', { id }); continue; }
|
|
43
|
+
write('state', { id, session: s.summary() });
|
|
44
|
+
unsubscribes.push(s.subscribe((ev) => {
|
|
45
|
+
write('event', { id, ev });
|
|
46
|
+
if (stateAfter.has(ev.rec?.type)) write('state', { id, session: s.summary() });
|
|
47
|
+
}, after.get(id) ?? 0));
|
|
48
|
+
}
|
|
49
|
+
const beat = setInterval(() => {
|
|
50
|
+
for (const id of ids) { const s = getSession(id); if (s) write('state', { id, session: s.summary() }); }
|
|
51
|
+
}, beatMs);
|
|
52
|
+
const stop = () => { clearInterval(beat); for (const u of unsubscribes) u(); try { res.end(); } catch { /* gone */ } };
|
|
53
|
+
req.on('close', stop);
|
|
54
|
+
req.on('error', stop);
|
|
55
|
+
return stop;
|
|
56
|
+
}
|
|
@@ -24,10 +24,17 @@ import fsp from 'node:fs/promises';
|
|
|
24
24
|
import os from 'node:os';
|
|
25
25
|
import path from 'node:path';
|
|
26
26
|
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
// Where Claude Code writes its transcripts: <config dir>/projects, the config dir being CLAUDE_CONFIG_DIR when it is
|
|
28
|
+
// set and ~/.claude otherwise. Reading ~/.claude alone, sessions started under a moved config dir (Studio's own
|
|
29
|
+
// included) were not listed and read a cost of 0 (measured on Windows). The studio gate hook reads it the same way.
|
|
30
|
+
// CREW_STUDIO_PROJECTS_ROOT still wins: a second instance pointed at a different store, which is also the only way to
|
|
31
|
+
// exercise the peer relay on one machine.
|
|
32
|
+
export function projectsRoot(env = process.env, home = os.homedir()) {
|
|
33
|
+
if (env.CREW_STUDIO_PROJECTS_ROOT) return env.CREW_STUDIO_PROJECTS_ROOT;
|
|
34
|
+
const config = env.CLAUDE_CONFIG_DIR ? path.resolve(env.CLAUDE_CONFIG_DIR) : path.join(home, '.claude');
|
|
35
|
+
return path.join(config, 'projects');
|
|
36
|
+
}
|
|
37
|
+
export const PROJECTS_ROOT = projectsRoot();
|
|
31
38
|
|
|
32
39
|
/** Fold a working directory into the directory name Claude Code uses. */
|
|
33
40
|
export function encodeCwd(cwd) {
|