wicked-crew 0.7.28 → 0.7.29
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/README.md +9 -1
- package/dist/api/chat-scope.d.ts +210 -0
- package/dist/api/chat-scope.d.ts.map +1 -0
- package/dist/api/chat-scope.js +724 -0
- package/dist/api/chat-scope.js.map +1 -0
- package/dist/api/diagnostics.d.ts +30 -2
- package/dist/api/diagnostics.d.ts.map +1 -1
- package/dist/api/diagnostics.js +73 -5
- package/dist/api/diagnostics.js.map +1 -1
- package/dist/api/governance-health.d.ts +163 -0
- package/dist/api/governance-health.d.ts.map +1 -0
- package/dist/api/governance-health.js +348 -0
- package/dist/api/governance-health.js.map +1 -0
- package/dist/api/open-path.d.ts.map +1 -1
- package/dist/api/open-path.js +2 -1
- package/dist/api/open-path.js.map +1 -1
- package/dist/api/post-hoc-deliver.d.ts +9 -5
- package/dist/api/post-hoc-deliver.d.ts.map +1 -1
- package/dist/api/post-hoc-deliver.js +21 -7
- package/dist/api/post-hoc-deliver.js.map +1 -1
- package/dist/api/routes.d.ts +48 -12
- package/dist/api/routes.d.ts.map +1 -1
- package/dist/api/routes.js +309 -48
- package/dist/api/routes.js.map +1 -1
- package/dist/api/seat-health.d.ts.map +1 -1
- package/dist/api/seat-health.js +25 -5
- package/dist/api/seat-health.js.map +1 -1
- package/dist/api/seat-signin.d.ts.map +1 -1
- package/dist/api/seat-signin.js +49 -23
- package/dist/api/seat-signin.js.map +1 -1
- package/dist/api/server.d.ts.map +1 -1
- package/dist/api/server.js +20 -0
- package/dist/api/server.js.map +1 -1
- package/dist/campaigns/routes.d.ts +4 -4
- package/dist/cli/governance.d.ts +131 -0
- package/dist/cli/governance.d.ts.map +1 -0
- package/dist/cli/governance.js +354 -0
- package/dist/cli/governance.js.map +1 -0
- package/dist/cli/index.js +69 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/core/adapter.d.ts +89 -1
- package/dist/core/adapter.d.ts.map +1 -1
- package/dist/core/adapter.js +123 -7
- package/dist/core/adapter.js.map +1 -1
- package/dist/core/bridge-reaper.d.ts.map +1 -1
- package/dist/core/bridge-reaper.js +4 -1
- package/dist/core/bridge-reaper.js.map +1 -1
- package/dist/core/deliver-text.d.ts +184 -0
- package/dist/core/deliver-text.d.ts.map +1 -0
- package/dist/core/deliver-text.js +434 -0
- package/dist/core/deliver-text.js.map +1 -0
- package/dist/core/deliver-triage.d.ts +90 -0
- package/dist/core/deliver-triage.d.ts.map +1 -0
- package/dist/core/deliver-triage.js +106 -0
- package/dist/core/deliver-triage.js.map +1 -0
- package/dist/core/deliver.d.ts +103 -9
- package/dist/core/deliver.d.ts.map +1 -1
- package/dist/core/deliver.js +244 -46
- package/dist/core/deliver.js.map +1 -1
- package/dist/core/estate-mcp-client.d.ts.map +1 -1
- package/dist/core/estate-mcp-client.js +7 -2
- package/dist/core/estate-mcp-client.js.map +1 -1
- package/dist/core/exec.d.ts.map +1 -1
- package/dist/core/exec.js +11 -1
- package/dist/core/exec.js.map +1 -1
- package/dist/core/governance-store.d.ts +197 -0
- package/dist/core/governance-store.d.ts.map +1 -0
- package/dist/core/governance-store.js +283 -0
- package/dist/core/governance-store.js.map +1 -0
- package/dist/core/repoPaths.d.ts +36 -5
- package/dist/core/repoPaths.d.ts.map +1 -1
- package/dist/core/repoPaths.js +46 -4
- package/dist/core/repoPaths.js.map +1 -1
- package/dist/interactive/bridge-pool.d.ts.map +1 -1
- package/dist/interactive/bridge-pool.js +9 -4
- package/dist/interactive/bridge-pool.js.map +1 -1
- package/dist/projects/graph-paths.d.ts +4 -2
- package/dist/projects/graph-paths.d.ts.map +1 -1
- package/dist/projects/graph-paths.js +4 -2
- package/dist/projects/graph-paths.js.map +1 -1
- package/dist/projects/graph.d.ts +5 -4
- package/dist/projects/graph.d.ts.map +1 -1
- package/dist/projects/graph.js +22 -5
- package/dist/projects/graph.js.map +1 -1
- package/dist/projects/routes.d.ts.map +1 -1
- package/dist/projects/routes.js +18 -9
- package/dist/projects/routes.js.map +1 -1
- package/dist/skills/plugin-source.d.ts.map +1 -1
- package/dist/skills/plugin-source.js +3 -0
- package/dist/skills/plugin-source.js.map +1 -1
- package/dist/studio/assets/index-CeJTutxP.js +540 -0
- package/dist/studio/index.html +1 -1
- package/dist/studio/testid-inventory.json +212 -6
- package/endpoint-manifest.json +60 -12
- package/package.json +3 -3
- package/dist/studio/assets/index-CdmCMh2f.js +0 -539
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat scope (crew#502, acceptance finding F-067): WHAT a chat's seats can see, decided up front
|
|
3
|
+
* and stated — to the seats and to the caller.
|
|
4
|
+
*
|
|
5
|
+
* `POST /chats` used to set the seats' cwd only when `repoRef` was sent, and the engine fell back
|
|
6
|
+
* to `current_dir()` — the DAEMON's working directory. Studio's GroupChat never sent `repoRef`, so
|
|
7
|
+
* every chat explored wherever `wicked-crew serve` had been started from (a customer's `$HOME`),
|
|
8
|
+
* with no estate MCP, no repo list and no statement of the scope; "chat with all 9 repos" worked
|
|
9
|
+
* only because the acceptance rig started the daemon from the parent of `repos/`, and the seats
|
|
10
|
+
* browsed the rig's other clones and the INSTALLED package's `.d.ts` files as if they were sources.
|
|
11
|
+
*
|
|
12
|
+
* The scope is a first-class input now: a PROJECT (default: every registered `crew.repo` member)
|
|
13
|
+
* or an explicit repo list. From it this module derives the three things the engine is handed
|
|
14
|
+
* (`wicked-core-ts chatOpen(chatId, clis, cwd, scopeJson)`):
|
|
15
|
+
*
|
|
16
|
+
* - `cwd` — a PRIVATE SCRATCH ROOT of the chat's own, under THIS daemon's per-process
|
|
17
|
+
* namespace (`<os tmp>/wicked-crew-chats/<pid>-<random>/<chatId>`, see
|
|
18
|
+
* {@link chatScratchBase}), never a repo and never the daemon's cwd. Relative
|
|
19
|
+
* writes land there and the
|
|
20
|
+
* OS sandbox, where the seat's config arms it, contains writes to it — the
|
|
21
|
+
* repos are READ. Deliberately NOT under the state home: the engine's worker
|
|
22
|
+
* fence denies `Read/Edit/Write(<state home>/**)` to every seat (wicked-core
|
|
23
|
+
* `execute_wrapped::deny_rules`), so a seat whose cwd sat there could not read
|
|
24
|
+
* its own working directory — and a new state-home store needs core's registry
|
|
25
|
+
* (`tests/fixtures/state-home-subtrees.json`) released first.
|
|
26
|
+
* - `readRoots` — the scoped repos' REGISTERED root paths (the run worktree read-roots idea,
|
|
27
|
+
* applied to live repos): advertised to a claude seat as the SDK's
|
|
28
|
+
* `additionalDirectories`, recorded on every seat (`GET /chats`).
|
|
29
|
+
* - `codeGraphDb` — the graph the seats' READ-ONLY estate MCP is bound to: the project's
|
|
30
|
+
* co-located graph (`resolveProjectGraphBinding`, DES-GROUNDING-001 — the SAME
|
|
31
|
+
* seam governed runs get) when the chat is filed into a project; a single
|
|
32
|
+
* repo's own graph otherwise; none for several repos with no project.
|
|
33
|
+
*
|
|
34
|
+
* and STATES it: an `AGENTS.md` + `CLAUDE.md` in the scratch root (every seat CLI reads its cwd's
|
|
35
|
+
* instructions file — claude `CLAUDE.md`; codex, pi, opencode and copilot `AGENTS.md`) names the
|
|
36
|
+
* repos, their paths, the read-only rule and the grounding, so a seat that starts in an empty
|
|
37
|
+
* directory knows exactly what it was pointed at; the same {@link ChatScope} rides the
|
|
38
|
+
* `POST /chats` response and `GET /chats/:id` for the UI (studio's New Chat).
|
|
39
|
+
*/
|
|
40
|
+
import { randomBytes } from 'node:crypto';
|
|
41
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
42
|
+
import { tmpdir } from 'node:os';
|
|
43
|
+
import { basename, dirname, join, resolve, sep } from 'node:path';
|
|
44
|
+
import { codeGraphDb } from '../core/repoPaths.js';
|
|
45
|
+
import { resolveProjectGraphBinding } from '../projects/graph.js';
|
|
46
|
+
let processScratchBase;
|
|
47
|
+
/**
|
|
48
|
+
* `<os tmp>/wicked-crew-chats/<pid>-<random>` — THIS daemon's own namespace, minted once per
|
|
49
|
+
* process (Copilot, #518): a shared `<os tmp>/wicked-crew-chats/<chatId>` could already exist from
|
|
50
|
+
* another daemon running as the same OS user, which would pass every ownership check, overwrite the
|
|
51
|
+
* live chat's statement and remove its root. Chats do not survive a daemon restart (the engine's pool
|
|
52
|
+
* is in memory), so a stale namespace left by a crashed daemon holds nothing live. See the module
|
|
53
|
+
* doc for why the base is not the state home.
|
|
54
|
+
*/
|
|
55
|
+
export function chatScratchBase() {
|
|
56
|
+
processScratchBase ??= join(tmpdir(), 'wicked-crew-chats', `${process.pid}-${randomBytes(4).toString('hex')}`);
|
|
57
|
+
return processScratchBase;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The real path of `p` even when `p` does not exist yet: its longest EXISTING ancestor is
|
|
61
|
+
* canonicalized and the remaining segments re-appended — so a path under a symlinked temp dir
|
|
62
|
+
* (macOS: `/var/folders/…` → `/private/var/folders/…`) compares equal to one that was resolved
|
|
63
|
+
* through the link, whether or not the leaf has been created.
|
|
64
|
+
*/
|
|
65
|
+
function realish(p) {
|
|
66
|
+
let cur = resolve(p);
|
|
67
|
+
const tail = [];
|
|
68
|
+
for (;;) {
|
|
69
|
+
try {
|
|
70
|
+
const real = realpathSync(cur);
|
|
71
|
+
return tail.length === 0 ? real : join(real, ...tail.reverse());
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
// Only a MISSING component walks up (hardening, W7): EACCES, ELOOP, ENOTDIR and friends mean
|
|
75
|
+
// the path exists but cannot be resolved — the caller decides whether that refuses the open.
|
|
76
|
+
if (err.code !== 'ENOENT')
|
|
77
|
+
throw err;
|
|
78
|
+
const parent = dirname(cur);
|
|
79
|
+
if (parent === cur)
|
|
80
|
+
return resolve(p);
|
|
81
|
+
tail.push(basename(cur));
|
|
82
|
+
cur = parent;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Lexical containment/equality of two resolved-but-not-canonicalized paths. */
|
|
87
|
+
function lexicallyOverlaps(a, b) {
|
|
88
|
+
const ra = resolve(a);
|
|
89
|
+
const rb = resolve(b);
|
|
90
|
+
return ra === rb || ra.startsWith(rb + sep) || rb.startsWith(ra + sep);
|
|
91
|
+
}
|
|
92
|
+
/** Whether `a` and `b` are the same directory or one contains the other, judged on real paths. */
|
|
93
|
+
function overlaps(a, b) {
|
|
94
|
+
const ra = realish(a);
|
|
95
|
+
const rb = realish(b);
|
|
96
|
+
return ra === rb || ra.startsWith(rb + sep) || rb.startsWith(ra + sep);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* ANY registered root that contains, or sits inside, the scratch base breaks the "private, never a
|
|
100
|
+
* repo" guarantee (Copilot, #518): the seat's cwd would be part of a live repository — whether or
|
|
101
|
+
* not that repository is in this chat's scope — and, when it is, that read root would expose every
|
|
102
|
+
* sibling chat's scratch directory. Judged over EVERY registered repo, on every branch (scoped or
|
|
103
|
+
* not). Refused as a 409 — it is an operator setup conflict (a repo registered under the OS temp
|
|
104
|
+
* dir, or a daemon whose TMPDIR sits under a checkout), not a caller error.
|
|
105
|
+
*/
|
|
106
|
+
function refuseOverlap(repos, base, inScope, log) {
|
|
107
|
+
for (const r of repos) {
|
|
108
|
+
let clash;
|
|
109
|
+
try {
|
|
110
|
+
clash = overlaps(r.root_path, base);
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
// A root that EXISTS but cannot be resolved (EACCES, ELOOP, an unmounted volume …). NARROWED
|
|
114
|
+
// (independent review, W7): it refuses the open only when it is IN this chat's scope — the
|
|
115
|
+
// seats would be pointed at a root nobody can prove safe — or when its lexical spelling
|
|
116
|
+
// already overlaps the base; an unrelated unresolvable repo is noted and does not block
|
|
117
|
+
// every chat on the daemon.
|
|
118
|
+
if (inScope.has(r.id) || lexicallyOverlaps(r.root_path, base)) {
|
|
119
|
+
return {
|
|
120
|
+
ok: false,
|
|
121
|
+
status: 409,
|
|
122
|
+
error: `repo '${r.name}' at ${r.root_path} cannot be resolved (${message(err)}), so the chat ` +
|
|
123
|
+
'scratch base cannot be proven outside it; fix the registration or the mount first.',
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
log(`chat scope: registered repo '${r.name}' at ${r.root_path} cannot be resolved (${message(err)}); not in scope, continuing`);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (clash) {
|
|
130
|
+
return {
|
|
131
|
+
ok: false,
|
|
132
|
+
status: 409,
|
|
133
|
+
error: `repo '${r.name}' is registered at ${r.root_path}, which overlaps this daemon's chat ` +
|
|
134
|
+
`scratch base ${base}; a chat's scratch root can never sit inside a repository (nor a ` +
|
|
135
|
+
'repository inside the scratch base) — register the repo elsewhere or set TMPDIR for the daemon.',
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
/** The production deps over a live adapter. */
|
|
142
|
+
export function chatScopeDeps(adapter) {
|
|
143
|
+
return {
|
|
144
|
+
listRepos: () => adapter.listRepos(),
|
|
145
|
+
projectRepoRefs: async (projectId) => (await adapter.projectMembers(projectId))
|
|
146
|
+
.filter((m) => m.member_kind === 'crew.repo')
|
|
147
|
+
.map((m) => m.member_ref),
|
|
148
|
+
bindProjectGraph: (projectId, repoRef) => resolveProjectGraphBinding(adapter, projectId, repoRef),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/** The route's own id rule, re-applied here: the id becomes ONE path segment under the base. */
|
|
152
|
+
const CHAT_ID = /^[A-Za-z0-9._-]+$/;
|
|
153
|
+
function message(err) {
|
|
154
|
+
return err instanceof Error ? err.message : String(err);
|
|
155
|
+
}
|
|
156
|
+
function row(r) {
|
|
157
|
+
return { id: r.id, name: r.name, rootPath: r.root_path };
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Decide a chat's scope. Never throws on a caller error: an unknown repo is a 404 naming EVERY
|
|
161
|
+
* missing ref (not just the first), a chat id that cannot name a scratch directory is a 400. A
|
|
162
|
+
* graph that cannot be bound degrades to "no graph" WITH the reason — the same posture as a run
|
|
163
|
+
* launch (`resolveProjectGraphBinding` never indexes; a refresh is an explicit act).
|
|
164
|
+
*/
|
|
165
|
+
export async function resolveChatScope(req, deps) {
|
|
166
|
+
const base = resolve(deps.scratchBase ?? chatScratchBase());
|
|
167
|
+
const cwd = resolve(base, req.chatId);
|
|
168
|
+
// `..` and `.` pass the route's character class; a resolved cwd that is not strictly INSIDE the
|
|
169
|
+
// base (one segment down) is refused before anything is created.
|
|
170
|
+
if (!CHAT_ID.test(req.chatId) ||
|
|
171
|
+
!cwd.startsWith(base + sep) ||
|
|
172
|
+
cwd.slice(base.length + 1).includes(sep)) {
|
|
173
|
+
return {
|
|
174
|
+
ok: false,
|
|
175
|
+
status: 400,
|
|
176
|
+
error: `chatId ${JSON.stringify(req.chatId)} cannot name a chat scratch directory`,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
const repos = await deps.listRepos();
|
|
180
|
+
const log = deps.log ?? (() => undefined);
|
|
181
|
+
const refs = [...new Set(req.repoRefs)];
|
|
182
|
+
if (refs.length > 0) {
|
|
183
|
+
const found = [];
|
|
184
|
+
const missing = [];
|
|
185
|
+
for (const ref of refs) {
|
|
186
|
+
const byId = repos.find((r) => r.id === ref);
|
|
187
|
+
if (byId !== undefined) {
|
|
188
|
+
if (!found.some((f) => f.id === byId.id))
|
|
189
|
+
found.push(byId);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
// A NAME is a human spelling, not an identifier: two checkouts can share one (Copilot,
|
|
193
|
+
// #518). One match resolves; several are refused naming the ids — the same rule the
|
|
194
|
+
// interactive grounding resolver applies — never "whichever row came first".
|
|
195
|
+
const byName = repos.filter((r) => r.name === ref);
|
|
196
|
+
if (byName.length > 1) {
|
|
197
|
+
return {
|
|
198
|
+
ok: false,
|
|
199
|
+
status: 400,
|
|
200
|
+
error: `repoRef '${ref}' is ambiguous — ${byName.length} registered repos share that name ` +
|
|
201
|
+
`(${byName.map((r) => `'${r.id}' at ${r.root_path}`).join(', ')}); name the repo by id.`,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
if (byName.length === 1) {
|
|
205
|
+
if (!found.some((f) => f.id === byName[0].id))
|
|
206
|
+
found.push(byName[0]);
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
missing.push(ref);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (missing.length > 0) {
|
|
213
|
+
return {
|
|
214
|
+
ok: false,
|
|
215
|
+
status: 404,
|
|
216
|
+
error: `Repo ${missing.map((m) => `'${m}'`).join(', ')} not found`,
|
|
217
|
+
missing,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
// Every registered repo, with THIS chat's repos in scope: the base must not be part of any.
|
|
221
|
+
const overlap = refuseOverlap(repos, base, new Set(found.map((r) => r.id)), log);
|
|
222
|
+
if (overlap !== null)
|
|
223
|
+
return overlap;
|
|
224
|
+
const graph = await graphForRepos(found, req.projectId, deps);
|
|
225
|
+
return ok({
|
|
226
|
+
kind: 'repos',
|
|
227
|
+
...(req.projectId !== undefined ? { projectId: req.projectId } : {}),
|
|
228
|
+
repos: found.map(row),
|
|
229
|
+
cwd,
|
|
230
|
+
graph: graph.wire,
|
|
231
|
+
dangling: [],
|
|
232
|
+
}, graph.dbPath, found);
|
|
233
|
+
}
|
|
234
|
+
if (req.projectId !== undefined) {
|
|
235
|
+
const memberRefs = await deps.projectRepoRefs(req.projectId);
|
|
236
|
+
const found = [];
|
|
237
|
+
const dangling = [];
|
|
238
|
+
for (const ref of memberRefs) {
|
|
239
|
+
const repo = repos.find((r) => r.id === ref);
|
|
240
|
+
if (repo === undefined)
|
|
241
|
+
dangling.push(ref);
|
|
242
|
+
else
|
|
243
|
+
found.push(repo);
|
|
244
|
+
}
|
|
245
|
+
const overlap = refuseOverlap(repos, base, new Set(found.map((r) => r.id)), log);
|
|
246
|
+
if (overlap !== null)
|
|
247
|
+
return overlap;
|
|
248
|
+
const decision = await bindOrExplain(deps, req.projectId, undefined);
|
|
249
|
+
return ok({
|
|
250
|
+
kind: 'project',
|
|
251
|
+
projectId: req.projectId,
|
|
252
|
+
repos: found.map(row),
|
|
253
|
+
cwd,
|
|
254
|
+
graph: graphWire(decision),
|
|
255
|
+
dangling,
|
|
256
|
+
}, decision.binding?.dbPath ?? null, found);
|
|
257
|
+
}
|
|
258
|
+
const overlap = refuseOverlap(repos, base, new Set(), log);
|
|
259
|
+
if (overlap !== null)
|
|
260
|
+
return overlap;
|
|
261
|
+
return ok({
|
|
262
|
+
kind: 'none',
|
|
263
|
+
repos: [],
|
|
264
|
+
cwd,
|
|
265
|
+
graph: {
|
|
266
|
+
bound: false,
|
|
267
|
+
reason: 'the chat names no project and no repos, so its seats see only their own scratch root ' +
|
|
268
|
+
'and no code graph; pass projectId or repoRefs to scope it.',
|
|
269
|
+
},
|
|
270
|
+
dangling: [],
|
|
271
|
+
}, null, []);
|
|
272
|
+
}
|
|
273
|
+
function ok(scope, dbPath, repos) {
|
|
274
|
+
return {
|
|
275
|
+
ok: true,
|
|
276
|
+
scope,
|
|
277
|
+
// Read roots ride RESOLVED (independent review, item 5): a registry spelling with a trailing
|
|
278
|
+
// separator or a `.` segment would otherwise reach the engine's validator verbatim.
|
|
279
|
+
engine: { cwd: scope.cwd, codeGraphDb: dbPath, readRoots: repos.map((r) => resolve(r.root_path)) },
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
async function bindOrExplain(deps, projectId, repoRef) {
|
|
283
|
+
try {
|
|
284
|
+
return await deps.bindProjectGraph(projectId, repoRef);
|
|
285
|
+
}
|
|
286
|
+
catch (err) {
|
|
287
|
+
// Resolving the binding is an ENHANCEMENT to the chat; a failure is stated, never fatal.
|
|
288
|
+
return {
|
|
289
|
+
binding: null,
|
|
290
|
+
reason: `the project graph binding could not be resolved (${message(err)}). ` +
|
|
291
|
+
'This chat gets no code graph.',
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* The wire half of a binding decision. `repoLabel` (the estate label the project graph indexes a
|
|
297
|
+
* repo under — set by a REPO-BOUND decision) rides along for the UI and the statement; the engine
|
|
298
|
+
* side needs only `dbPath`: a run hands the label so the engine can confirm the graph holds ITS
|
|
299
|
+
* worktree, and a chat has no worktree — the decision already confirmed the graph holds the repo
|
|
300
|
+
* (Copilot, #518).
|
|
301
|
+
*/
|
|
302
|
+
function graphWire(decision) {
|
|
303
|
+
return {
|
|
304
|
+
bound: decision.binding !== null,
|
|
305
|
+
reason: decision.reason,
|
|
306
|
+
...(decision.binding?.repoLabel !== undefined ? { repoLabel: decision.binding.repoLabel } : {}),
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
async function graphForRepos(repos, projectId, deps) {
|
|
310
|
+
if (projectId !== undefined) {
|
|
311
|
+
// Filed into a project: the project's co-located graph, repo-bound when there is exactly one
|
|
312
|
+
// repo (so the binding confirms the graph HOLDS it, as a repo-bound run does).
|
|
313
|
+
const decision = await bindOrExplain(deps, projectId, repos.length === 1 ? repos[0].id : undefined);
|
|
314
|
+
if (decision.binding !== null || repos.length !== 1) {
|
|
315
|
+
return { wire: graphWire(decision), dbPath: decision.binding?.dbPath ?? null };
|
|
316
|
+
}
|
|
317
|
+
// The project declined to bind ONE repo (not a member, not indexed yet, …): its reason says
|
|
318
|
+
// "uses its own repo's code graph", so DO that — the same existence-checked fallback the
|
|
319
|
+
// no-project path takes — rather than hand the engine no graph behind a reason that promises
|
|
320
|
+
// one (Copilot, #518). Both reasons are kept: why the project graph was declined, and what
|
|
321
|
+
// the chat actually got.
|
|
322
|
+
const own = ownGraph(repos[0]);
|
|
323
|
+
return {
|
|
324
|
+
wire: { bound: own.wire.bound, reason: `${decision.reason} ${own.wire.reason}` },
|
|
325
|
+
dbPath: own.dbPath,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
if (repos.length === 1) {
|
|
329
|
+
return ownGraph(repos[0]);
|
|
330
|
+
}
|
|
331
|
+
return {
|
|
332
|
+
wire: {
|
|
333
|
+
bound: false,
|
|
334
|
+
reason: `${repos.length} repos and no project: there is no co-located graph to bind. File the ` +
|
|
335
|
+
'chat into a project (projectId) whose graph is refreshed to ground it across repos; ' +
|
|
336
|
+
'this chat gets no code graph.',
|
|
337
|
+
},
|
|
338
|
+
dbPath: null,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
/** A single repo's OWN graph — bound only when the registered graph file exists. */
|
|
342
|
+
function ownGraph(only) {
|
|
343
|
+
try {
|
|
344
|
+
const dbPath = codeGraphDb(only);
|
|
345
|
+
// The registry field says WHERE the repo's graph lives, not that it was ever built
|
|
346
|
+
// (Copilot, #518): a repo indexed by nothing would otherwise be reported as grounded while
|
|
347
|
+
// the estate MCP answers "not found" about everything. Existence is the honest floor here
|
|
348
|
+
// (the project path gets the same from `projectGraphStatus`).
|
|
349
|
+
if (!existsSync(dbPath)) {
|
|
350
|
+
return {
|
|
351
|
+
wire: {
|
|
352
|
+
bound: false,
|
|
353
|
+
reason: `'${only.name}' has no code graph built yet (nothing at its registered graph path); ` +
|
|
354
|
+
'index the repo (wicked-estate index / onboarding) to ground this chat. This chat gets none.',
|
|
355
|
+
},
|
|
356
|
+
dbPath: null,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
return {
|
|
360
|
+
wire: { bound: true, reason: `bound to '${only.name}'s own code graph.` },
|
|
361
|
+
dbPath,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
catch (err) {
|
|
365
|
+
return {
|
|
366
|
+
wire: {
|
|
367
|
+
bound: false,
|
|
368
|
+
reason: `'${only.name}' has no resolvable code graph (${message(err)}); this chat gets none.`,
|
|
369
|
+
},
|
|
370
|
+
dbPath: null,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* The statement every seat reads on start — written as BOTH `AGENTS.md` (codex, pi, opencode,
|
|
376
|
+
* copilot) and `CLAUDE.md` (claude) into the scratch root. Plain prose, repo paths verbatim: the
|
|
377
|
+
* seat is in an empty directory and this is how it learns what it was pointed at.
|
|
378
|
+
*/
|
|
379
|
+
export function chatScopeStatement(chatId, scope) {
|
|
380
|
+
const lines = [
|
|
381
|
+
'# Chat scope',
|
|
382
|
+
'',
|
|
383
|
+
`This directory is the scratch root of wicked-crew chat \`${chatId}\`. It is the ONLY place you may write.`,
|
|
384
|
+
'',
|
|
385
|
+
];
|
|
386
|
+
if (scope.repos.length === 0 && scope.kind === 'project') {
|
|
387
|
+
// A project whose every `crew.repo` member is dangling (or that has none): filed, but nothing
|
|
388
|
+
// readable — say exactly that, never "opened without a project" (Copilot, #518).
|
|
389
|
+
lines.push('## Repositories in scope', '', `None readable. This chat is filed into project \`${scope.projectId ?? ''}\`, but no registered`, 'repository of that project could be resolved' +
|
|
390
|
+
(scope.dangling.length > 0
|
|
391
|
+
? ` (members whose repository is no longer registered: ${scope.dangling.map((d) => `\`${d}\``).join(', ')}).`
|
|
392
|
+
: ' (it has no `crew.repo` member).'), 'Answer from general knowledge, and say plainly that no repository is readable if asked about code.', '');
|
|
393
|
+
}
|
|
394
|
+
else if (scope.repos.length === 0) {
|
|
395
|
+
lines.push('## Repositories in scope', '', 'None. This chat was opened without a project or repo scope: answer from general knowledge,', 'and say plainly that no repository is in scope if asked about code.', '');
|
|
396
|
+
}
|
|
397
|
+
else {
|
|
398
|
+
lines.push('## Repositories in scope (READ-ONLY)', '', ...scope.repos.map((r) => `- **${r.name}** (\`${r.id}\`): \`${r.rootPath}\``), '', 'Explore and answer questions about these repositories by reading them at the paths above.', 'Cite files by their full path. Do NOT modify, create or delete anything inside them — this', 'chat is read-only exploration; write scratch files only under this directory. Nothing', 'outside these repositories and this directory is in scope.', '');
|
|
399
|
+
}
|
|
400
|
+
if (scope.dangling.length > 0 && scope.repos.length > 0) {
|
|
401
|
+
lines.push(`Project members whose repository is no longer registered (not readable): ${scope.dangling
|
|
402
|
+
.map((d) => `\`${d}\``)
|
|
403
|
+
.join(', ')}.`, '');
|
|
404
|
+
}
|
|
405
|
+
lines.push('## Code graph', '');
|
|
406
|
+
lines.push(scope.graph.bound
|
|
407
|
+
? 'A READ-ONLY wicked-estate MCP server over the code graph is attached to this session: use its ' +
|
|
408
|
+
'search / blast-radius / lineage tools to find symbols and relationships before grepping. ' +
|
|
409
|
+
(scope.graph.repoLabel !== undefined
|
|
410
|
+
? `The repository is indexed under the label \`${scope.graph.repoLabel}\`. `
|
|
411
|
+
: '') +
|
|
412
|
+
`(${scope.graph.reason})`
|
|
413
|
+
: `No code graph is attached: ${scope.graph.reason}`, '');
|
|
414
|
+
if (scope.projectId !== undefined) {
|
|
415
|
+
lines.push('## Project', '', `This chat is filed into project \`${scope.projectId}\`.`, '');
|
|
416
|
+
}
|
|
417
|
+
return `${lines.join('\n')}\n`;
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Create the chat's scratch root (private) and write the scope statement into it. Throws on an
|
|
421
|
+
* unwritable temp — the route reports that rather than opening seats with no working directory.
|
|
422
|
+
*
|
|
423
|
+
* The root lives under the shared system temp dir, where another local user can pre-place an
|
|
424
|
+
* entry under a predictable name (Copilot, #518) — so nothing here follows a link or trusts an
|
|
425
|
+
* existing entry: the base and the root must be REAL directories (never symlinks), an existing
|
|
426
|
+
* root must be OWNED by this process's user (POSIX), the mode is ENFORCED with `chmod` (mkdir's
|
|
427
|
+
* `mode` applies only on creation), and each instruction file is unlinked before it is written
|
|
428
|
+
* exclusively (`wx`) so a planted link cannot redirect the write.
|
|
429
|
+
*/
|
|
430
|
+
export function prepareChatScratch(chatId, scope) {
|
|
431
|
+
const base = resolve(scope.cwd, '..');
|
|
432
|
+
// The WHOLE chain below the OS temp dir is created and validated one segment at a time (Copilot,
|
|
433
|
+
// #518): a recursive mkdir would follow a planted symlink at `<tmp>/wicked-crew-chats` (the
|
|
434
|
+
// parent every daemon's namespace shares) and create the "private" root inside its target. The
|
|
435
|
+
// OS temp dir itself is trusted as the platform's (macOS's `/var -> /private/var` is root-owned).
|
|
436
|
+
const stop = resolve(tmpdir());
|
|
437
|
+
const chain = [];
|
|
438
|
+
for (let dir = base; dir !== stop && dirname(dir) !== dir; dir = dirname(dir))
|
|
439
|
+
chain.unshift(dir);
|
|
440
|
+
if (chain.length === 0 || !base.startsWith(stop + sep)) {
|
|
441
|
+
throw new Error(`refusing chat scratch base ${base}: not below the OS temp dir ${stop}`);
|
|
442
|
+
}
|
|
443
|
+
for (const dir of chain) {
|
|
444
|
+
// Non-recursive on purpose (a recursive mkdir resolves the whole parent chain through any
|
|
445
|
+
// link): an existing segment is fine — `EEXIST` alone is ignored (independent review, W3) —
|
|
446
|
+
// and EVERY segment, existing or not, must then be a real directory owned by this user.
|
|
447
|
+
try {
|
|
448
|
+
mkdirSync(dir, { recursive: false, mode: 0o700 });
|
|
449
|
+
}
|
|
450
|
+
catch (err) {
|
|
451
|
+
if (err.code !== 'EEXIST')
|
|
452
|
+
throw err;
|
|
453
|
+
}
|
|
454
|
+
assertRealOwnedDirectory(dir, 'chat scratch base');
|
|
455
|
+
}
|
|
456
|
+
// Only a root THIS call created is removed on a later failure — a pre-existing entry (a planted
|
|
457
|
+
// directory the ownership check refuses) is never deleted on its planter's behalf (Copilot,
|
|
458
|
+
// #518). `created` is the non-recursive mkdir's OWN verdict (hardening, W-TOCTOU): a separate
|
|
459
|
+
// `existsSync` left a window in which another same-user process could create the root between
|
|
460
|
+
// the check and the mkdir and then lose it to this invocation's cleanup.
|
|
461
|
+
let created = false;
|
|
462
|
+
try {
|
|
463
|
+
mkdirSync(scope.cwd, { recursive: false, mode: 0o700 });
|
|
464
|
+
created = true;
|
|
465
|
+
}
|
|
466
|
+
catch (err) {
|
|
467
|
+
if (err.code !== 'EEXIST')
|
|
468
|
+
throw err;
|
|
469
|
+
}
|
|
470
|
+
try {
|
|
471
|
+
assertRealOwnedDirectory(scope.cwd, 'chat scratch root');
|
|
472
|
+
chmodSync(scope.cwd, 0o700);
|
|
473
|
+
const statement = chatScopeStatement(chatId, scope);
|
|
474
|
+
for (const name of ['AGENTS.md', 'CLAUDE.md']) {
|
|
475
|
+
const file = join(scope.cwd, name);
|
|
476
|
+
rmSync(file, { force: true }); // removes a planted LINK itself, never its target
|
|
477
|
+
writeFileSync(file, statement, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
catch (err) {
|
|
481
|
+
if (created)
|
|
482
|
+
removeChatScratch(scope.cwd, base);
|
|
483
|
+
throw err;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
/** `path` must be a real directory (no link), owned by this user where the platform can tell. */
|
|
487
|
+
function assertRealOwnedDirectory(path, what) {
|
|
488
|
+
const meta = lstatSync(path);
|
|
489
|
+
if (meta.isSymbolicLink()) {
|
|
490
|
+
throw new Error(`refusing ${what} ${path}: it is a symlink`);
|
|
491
|
+
}
|
|
492
|
+
if (!meta.isDirectory()) {
|
|
493
|
+
throw new Error(`refusing ${what} ${path}: not a directory`);
|
|
494
|
+
}
|
|
495
|
+
if (process.platform !== 'win32' && typeof process.getuid === 'function') {
|
|
496
|
+
const uid = statSync(path).uid;
|
|
497
|
+
if (uid !== process.getuid()) {
|
|
498
|
+
throw new Error(`refusing ${what} ${path}: owned by uid ${uid}, not this daemon's user`);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Reap the scratch namespaces of DEAD daemons (independent review, W6): every sibling
|
|
504
|
+
* `<pid>-<hex>` directory under the shared parent whose pid no longer exists is removed — judged
|
|
505
|
+
* with the same checks as {@link removeChatScratch} (a real directory, never a link, owned by this
|
|
506
|
+
* user). This daemon's own namespace, a live pid, a pid this user may not signal (another user's
|
|
507
|
+
* daemon), and anything not shaped like a namespace are left alone. Never throws; returns the
|
|
508
|
+
* removed namespaces. Called once at daemon boot.
|
|
509
|
+
*/
|
|
510
|
+
export function reapStaleChatNamespaces(parent = dirname(chatScratchBase())) {
|
|
511
|
+
const removed = [];
|
|
512
|
+
let entries;
|
|
513
|
+
try {
|
|
514
|
+
if (lstatSync(parent).isSymbolicLink())
|
|
515
|
+
return removed;
|
|
516
|
+
entries = readdirSync(parent);
|
|
517
|
+
}
|
|
518
|
+
catch {
|
|
519
|
+
return removed;
|
|
520
|
+
}
|
|
521
|
+
for (const name of entries) {
|
|
522
|
+
const m = /^(\d+)-[0-9a-f]+$/.exec(name);
|
|
523
|
+
if (m === null)
|
|
524
|
+
continue;
|
|
525
|
+
const pid = Number(m[1]);
|
|
526
|
+
if (!Number.isSafeInteger(pid) || pid <= 0 || pid === process.pid)
|
|
527
|
+
continue;
|
|
528
|
+
try {
|
|
529
|
+
process.kill(pid, 0); // alive (or at least present): keep
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
catch (err) {
|
|
533
|
+
if (err.code !== 'ESRCH')
|
|
534
|
+
continue; // EPERM: someone else's; keep
|
|
535
|
+
}
|
|
536
|
+
const dir = join(parent, name);
|
|
537
|
+
try {
|
|
538
|
+
const meta = lstatSync(dir);
|
|
539
|
+
if (meta.isSymbolicLink() || !meta.isDirectory())
|
|
540
|
+
continue;
|
|
541
|
+
if (process.platform !== 'win32' && typeof process.getuid === 'function' && statSync(dir).uid !== process.getuid()) {
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
rmSync(dir, { recursive: true, force: true, maxRetries: 3 });
|
|
545
|
+
removed.push(dir);
|
|
546
|
+
}
|
|
547
|
+
catch {
|
|
548
|
+
// vanished or unreadable meanwhile: leave it
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
return removed;
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Remove a chat's scratch root — fail closed (Copilot, #518): the base must be a REAL directory
|
|
555
|
+
* (a planted `<tmp>/wicked-crew-chats` link would make a lexical prefix test pass and the
|
|
556
|
+
* recursive removal follow it), the target must sit DIRECTLY under the base by realpath, must not
|
|
557
|
+
* itself be a link, and must be owned by this user. Anything else is left alone: a wrong guess
|
|
558
|
+
* here deletes someone else's files, so the policy is the same as `prepareChatScratch`'s.
|
|
559
|
+
*/
|
|
560
|
+
export function removeChatScratch(cwd, base = chatScratchBase()) {
|
|
561
|
+
// Every check AND the removal sit under one catch-all (Copilot, #518): a filesystem race between
|
|
562
|
+
// the checks and the removal (the entry replaced or gone) must leave the path untouched and the
|
|
563
|
+
// caller's lifecycle intact — never throw out of a `DELETE` or an engine `chatClosed`.
|
|
564
|
+
try {
|
|
565
|
+
if (lstatSync(base).isSymbolicLink())
|
|
566
|
+
return;
|
|
567
|
+
const baseReal = realpathSync(base);
|
|
568
|
+
const target = resolve(cwd);
|
|
569
|
+
const meta = lstatSync(target);
|
|
570
|
+
if (meta.isSymbolicLink() || !meta.isDirectory())
|
|
571
|
+
return;
|
|
572
|
+
if (realpathSync(dirname(target)) !== baseReal)
|
|
573
|
+
return;
|
|
574
|
+
if (process.platform !== 'win32' && typeof process.getuid === 'function' && statSync(target).uid !== process.getuid()) {
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
rmSync(target, { recursive: true, force: true, maxRetries: 3 });
|
|
578
|
+
}
|
|
579
|
+
catch {
|
|
580
|
+
// no base, already gone, or unresolvable: nothing of ours is provably here — leave it
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* The daemon's live chat scopes, keyed by chat id — in memory, like the engine's own chat pool (a
|
|
585
|
+
* daemon restart loses both). A small state machine (Copilot, #518) rather than a map, because the
|
|
586
|
+
* open is asynchronous and the engine's close is an event:
|
|
587
|
+
*
|
|
588
|
+
* - `reserve` claims an id SYNCHRONOUSLY (before the route's first `await`) and hands back a
|
|
589
|
+
* token; `set` publishes the scope only if THAT reservation is still standing — an engine
|
|
590
|
+
* `chatClosed` or a `DELETE` that cancelled it in the meantime makes `set` return `false`, and
|
|
591
|
+
* the route tears the just-opened chat down instead of registering a scope for a closed one.
|
|
592
|
+
* - `beginClose` (DELETE) removes the root at once and parks the id as `closing`; `closed` (the
|
|
593
|
+
* engine's `chatClosed`) frees it. Until then a re-open of the same id is refused, so the
|
|
594
|
+
* engine's close — which may be delivered after `DELETE` returns — can never delete a newer
|
|
595
|
+
* chat's root, and a `chatClosed` for a merely reserved id cancels that open.
|
|
596
|
+
* - a `closing` slot whose event never arrives (an engine without the event, a lost relay) is
|
|
597
|
+
* freed after `closeGraceMs`; a late event after THAT is the documented residual (the engine's
|
|
598
|
+
* `chatClosed` carries no per-open token).
|
|
599
|
+
*/
|
|
600
|
+
export class ChatScopeIndex {
|
|
601
|
+
base;
|
|
602
|
+
closeGraceMs;
|
|
603
|
+
slots = new Map();
|
|
604
|
+
nextToken = 1;
|
|
605
|
+
/** `base`: where this daemon's chat scratch roots live — the resolver is handed the SAME base. */
|
|
606
|
+
constructor(base = chatScratchBase(), closeGraceMs = 5000) {
|
|
607
|
+
this.base = base;
|
|
608
|
+
this.closeGraceMs = closeGraceMs;
|
|
609
|
+
}
|
|
610
|
+
/** Live, reserved or closing — every state in which the id is not free. */
|
|
611
|
+
has(chatId) {
|
|
612
|
+
return this.slots.has(chatId);
|
|
613
|
+
}
|
|
614
|
+
/** The state the id is in, for the route's refusal wording. */
|
|
615
|
+
stateOf(chatId) {
|
|
616
|
+
return this.slots.get(chatId)?.state ?? 'free';
|
|
617
|
+
}
|
|
618
|
+
/** Reserve `chatId` for an open in flight; `null` when it is live, reserved or closing. */
|
|
619
|
+
reserve(chatId) {
|
|
620
|
+
if (this.slots.has(chatId))
|
|
621
|
+
return null;
|
|
622
|
+
const token = this.nextToken++;
|
|
623
|
+
this.slots.set(chatId, { state: 'reserved', token });
|
|
624
|
+
return token;
|
|
625
|
+
}
|
|
626
|
+
/** Give a reservation back (the open failed before `set`) — only the holder's own. */
|
|
627
|
+
release(chatId, token) {
|
|
628
|
+
const slot = this.slots.get(chatId);
|
|
629
|
+
if (slot?.state === 'reserved' && slot.token === token)
|
|
630
|
+
this.slots.delete(chatId);
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Abort an open that already reached the ENGINE (the route closed the engine chat again): the
|
|
634
|
+
* engine's `chatClosed` for this id is still on its way, so the id is parked as `closing` — not
|
|
635
|
+
* released — until that event (or the grace) frees it; a reuse in between would otherwise have
|
|
636
|
+
* its root removed by the late event (Copilot, #518). Only the holder's own reservation.
|
|
637
|
+
*/
|
|
638
|
+
abortToClosing(chatId, token) {
|
|
639
|
+
const slot = this.slots.get(chatId);
|
|
640
|
+
if (slot?.state !== 'reserved' || slot.token !== token)
|
|
641
|
+
return;
|
|
642
|
+
const timer = setTimeout(() => {
|
|
643
|
+
if (this.slots.get(chatId)?.state === 'closing')
|
|
644
|
+
this.slots.delete(chatId);
|
|
645
|
+
}, this.closeGraceMs);
|
|
646
|
+
timer.unref?.();
|
|
647
|
+
this.slots.set(chatId, { state: 'closing', timer });
|
|
648
|
+
}
|
|
649
|
+
/** Publish the scope of a finished open. `false` when the reservation is gone (cancelled by a
|
|
650
|
+
* close in the meantime): the caller must tear the chat down, nothing was recorded. */
|
|
651
|
+
set(chatId, scope, token) {
|
|
652
|
+
const slot = this.slots.get(chatId);
|
|
653
|
+
if (slot?.state !== 'reserved' || slot.token !== token)
|
|
654
|
+
return false;
|
|
655
|
+
this.slots.set(chatId, { state: 'live', scope });
|
|
656
|
+
return true;
|
|
657
|
+
}
|
|
658
|
+
/** The scope of a LIVE chat; `undefined` while reserved, closing or unknown. */
|
|
659
|
+
get(chatId) {
|
|
660
|
+
const slot = this.slots.get(chatId);
|
|
661
|
+
return slot?.state === 'live' ? slot.scope : undefined;
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* `DELETE /chats/:id`: remove the root now and park the id until the engine's `chatClosed` (or
|
|
665
|
+
* the grace) frees it. A reserved id is cancelled instead (the in-flight open cleans up on its
|
|
666
|
+
* failed `set`). Returns the scope that was live, if any.
|
|
667
|
+
*/
|
|
668
|
+
beginClose(chatId) {
|
|
669
|
+
const slot = this.slots.get(chatId);
|
|
670
|
+
if (slot === undefined || slot.state === 'closing')
|
|
671
|
+
return undefined;
|
|
672
|
+
if (slot.state === 'live')
|
|
673
|
+
removeChatScratch(slot.scope.cwd, this.base);
|
|
674
|
+
// A RESERVED id is parked too, not freed (Copilot, #518): the in-flight open will find its
|
|
675
|
+
// reservation gone, tear the engine chat down and that close's `chatClosed` is still to come —
|
|
676
|
+
// a reuse before then would lose its own chat to it. The grace covers an open that never
|
|
677
|
+
// reached the engine (no event will ever come).
|
|
678
|
+
const timer = setTimeout(() => {
|
|
679
|
+
if (this.slots.get(chatId)?.state === 'closing')
|
|
680
|
+
this.slots.delete(chatId);
|
|
681
|
+
}, this.closeGraceMs);
|
|
682
|
+
timer.unref?.();
|
|
683
|
+
this.slots.set(chatId, { state: 'closing', timer });
|
|
684
|
+
return slot.state === 'live' ? slot.scope : undefined;
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* The engine's `chatClosed` for `chatId` (any reason): frees a closing id, removes and frees a
|
|
688
|
+
* live one (an engine-side reap), cancels a reservation (the open in flight tears down). Idempotent.
|
|
689
|
+
*/
|
|
690
|
+
closed(chatId) {
|
|
691
|
+
const slot = this.slots.get(chatId);
|
|
692
|
+
if (slot === undefined)
|
|
693
|
+
return;
|
|
694
|
+
if (slot.state === 'reserved') {
|
|
695
|
+
// An open is in flight: its `set` will now fail and it tears its engine chat down, whose own
|
|
696
|
+
// `chatClosed` is still to come — park the id (as `beginClose` does) rather than free it, so a
|
|
697
|
+
// reuse in between cannot lose its chat to that teardown (hardening, Copilot seventh pass).
|
|
698
|
+
const timer = setTimeout(() => {
|
|
699
|
+
if (this.slots.get(chatId)?.state === 'closing')
|
|
700
|
+
this.slots.delete(chatId);
|
|
701
|
+
}, this.closeGraceMs);
|
|
702
|
+
timer.unref?.();
|
|
703
|
+
this.slots.set(chatId, { state: 'closing', timer });
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
if (slot.state === 'closing')
|
|
707
|
+
clearTimeout(slot.timer);
|
|
708
|
+
if (slot.state === 'live')
|
|
709
|
+
removeChatScratch(slot.scope.cwd, this.base);
|
|
710
|
+
this.slots.delete(chatId);
|
|
711
|
+
}
|
|
712
|
+
/** Forget the chat and remove its scratch root at once — no closing window (tests, teardown). */
|
|
713
|
+
delete(chatId) {
|
|
714
|
+
const slot = this.slots.get(chatId);
|
|
715
|
+
const scope = slot?.state === 'live' ? slot.scope : undefined;
|
|
716
|
+
if (slot?.state === 'closing')
|
|
717
|
+
clearTimeout(slot.timer);
|
|
718
|
+
this.slots.delete(chatId);
|
|
719
|
+
if (scope !== undefined)
|
|
720
|
+
removeChatScratch(scope.cwd, this.base);
|
|
721
|
+
return scope;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
//# sourceMappingURL=chat-scope.js.map
|