dsh-graft-plugin 0.1.0
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/LICENSE +21 -0
- package/README.md +129 -0
- package/cordis.patch.yml +24 -0
- package/package.json +79 -0
- package/screenshots.json +5 -0
- package/scripts/postinstall.mjs +109 -0
- package/scripts/test-autosync.mjs +117 -0
- package/scripts/test-command.mjs +104 -0
- package/scripts/test-harness.mjs +240 -0
- package/scripts/test-tools.mjs +53 -0
- package/src/auto-sync.js +262 -0
- package/src/client.js +843 -0
- package/src/command.js +147 -0
- package/src/graft-tools.js +346 -0
- package/src/index.js +701 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* graft-status — show which graft graph this dsh is actually serving.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS
|
|
5
|
+
*
|
|
6
|
+
* The `mcp-graft` row in the web profile spawns `graft mcp` once per dsh boot,
|
|
7
|
+
* with no directory argument, so graft resolves "nearest ancestor with a graft/
|
|
8
|
+
* index" from dsh's working directory. That is invisible from the UI: the
|
|
9
|
+
* composer shows the model and the workspace, but nothing says which repo's
|
|
10
|
+
* graph the graft tools answer from — or whether that graph still matches the
|
|
11
|
+
* code.
|
|
12
|
+
*
|
|
13
|
+
* Getting this wrong is not a small confusion. An earlier version of the MCP
|
|
14
|
+
* row pinned graft to ~/.dsh; a session opened on another project silently got
|
|
15
|
+
* answers about the harness instead, and the only symptom was a freshness
|
|
16
|
+
* check reporting a node count that belonged to a different repo.
|
|
17
|
+
*
|
|
18
|
+
* WHICH DIRECTORY IT RESOLVES FROM
|
|
19
|
+
*
|
|
20
|
+
* The SESSION's cwd (`session.header.cwd`), falling back to `process.cwd()`
|
|
21
|
+
* only when a session has none.
|
|
22
|
+
*
|
|
23
|
+
* An earlier version used `process.cwd()` alone, reasoning that it matched what
|
|
24
|
+
* `graft mcp` resolves and so could never disagree with the tools. In practice
|
|
25
|
+
* that made it agree with the tools about the wrong repo: dsh launched from a
|
|
26
|
+
* home directory while the session's workspace was a project elsewhere, and the
|
|
27
|
+
* chip reported "no graft index above C:\\Users\\adith" for a workspace that has
|
|
28
|
+
* a perfectly good index. Matching a stale answer is not consistency.
|
|
29
|
+
*
|
|
30
|
+
* The MCP server cannot follow suit — one stdio child is spawned per dsh boot,
|
|
31
|
+
* so its root is fixed at launch. When the two disagree, this one is right
|
|
32
|
+
* about the workspace and the MCP tools are answering about wherever dsh was
|
|
33
|
+
* started. Launching dsh from the repo makes both agree.
|
|
34
|
+
*
|
|
35
|
+
* WHAT IT NEVER DOES
|
|
36
|
+
*
|
|
37
|
+
* Pure reads of files graft already writes. It never runs `graft`, never
|
|
38
|
+
* writes to the index, and never makes a network request — so it cannot make
|
|
39
|
+
* a graph stale, and it costs nothing when graft is absent.
|
|
40
|
+
*
|
|
41
|
+
* @module graft-status
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
|
45
|
+
import { spawn } from 'node:child_process'
|
|
46
|
+
import { access, mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises'
|
|
47
|
+
import { dirname, join } from 'node:path'
|
|
48
|
+
|
|
49
|
+
import { createAutoSync } from './auto-sync.js'
|
|
50
|
+
import { createGraftCommand } from './command.js'
|
|
51
|
+
import { graftTools, resolveGraftCommand } from './graft-tools.js'
|
|
52
|
+
|
|
53
|
+
export const name = 'graft-status'
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The tool registry, for the model-facing graft tools.
|
|
57
|
+
*
|
|
58
|
+
* Everything else this plugin reads is a file on disk, deliberately, so a
|
|
59
|
+
* missing service can never take the chip down.
|
|
60
|
+
*/
|
|
61
|
+
export const inject = ['tools']
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* How long a computed status is reused before the files are read again.
|
|
65
|
+
*
|
|
66
|
+
* The dock re-renders on every session switch and the component polls, so
|
|
67
|
+
* without this the same three files would be read many times a second. Five
|
|
68
|
+
* seconds is under the interval at which graft's own hooks rewrite the cache,
|
|
69
|
+
* so the chip still moves promptly when the graph does.
|
|
70
|
+
*/
|
|
71
|
+
const CACHE_MS = 5000
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The repository root graft would serve from `start`.
|
|
75
|
+
*
|
|
76
|
+
* Mirrors `graft mcp`'s own rule — nearest ancestor containing a `graft/`
|
|
77
|
+
* directory — by testing for the artifacts graft writes rather than the
|
|
78
|
+
* directory alone, because an empty `graft/` folder left behind by a removed
|
|
79
|
+
* index would otherwise shadow a real one higher up.
|
|
80
|
+
*/
|
|
81
|
+
export async function findGraftRoot(start, exists) {
|
|
82
|
+
let dir = start
|
|
83
|
+
for (;;) {
|
|
84
|
+
for (const probe of ['.cache/stats.json', '.graph/wiring.json', 'INDEX.md']) {
|
|
85
|
+
if (await exists(join(dir, 'graft', probe))) return dir
|
|
86
|
+
}
|
|
87
|
+
const parent = dirname(dir)
|
|
88
|
+
// `dirname` of a filesystem root returns the root itself; that fixed point
|
|
89
|
+
// is the only reliable stop signal across platforms, and it is why this
|
|
90
|
+
// walks with `dirname` rather than counting separators.
|
|
91
|
+
if (parent === dir) return undefined
|
|
92
|
+
dir = parent
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The directory to resolve a graft root from, for one session.
|
|
98
|
+
*
|
|
99
|
+
* `session.header.cwd` is what dsh recorded as that session's workspace — the
|
|
100
|
+
* same value its entry in the session list shows — so it is right even when dsh
|
|
101
|
+
* itself was launched somewhere else entirely. That case is not hypothetical:
|
|
102
|
+
* dsh started from a home directory reported "no graft index above
|
|
103
|
+
* C:\\Users\\adith" for a session whose workspace had a perfectly good one.
|
|
104
|
+
*
|
|
105
|
+
* `fallback` (the process cwd) is the last resort, for a session with no
|
|
106
|
+
* recorded cwd or before any session exists.
|
|
107
|
+
*/
|
|
108
|
+
export function cwdForSession(sessions, sessionId, fallback) {
|
|
109
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) return fallback
|
|
110
|
+
try {
|
|
111
|
+
const cwd = sessions?.get?.(sessionId)?.header?.cwd
|
|
112
|
+
return typeof cwd === 'string' && cwd.length > 0 ? cwd : fallback
|
|
113
|
+
} catch {
|
|
114
|
+
// No sessions service, or an id it does not know.
|
|
115
|
+
return fallback
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Freshness as one short word.
|
|
121
|
+
*
|
|
122
|
+
* `syncing` outranks `dirty` because it is the more specific state: a sync in
|
|
123
|
+
* flight is always over a dirty graph, and reporting the dirt would tell the
|
|
124
|
+
* reader to do the thing already happening.
|
|
125
|
+
*/
|
|
126
|
+
export function freshnessOf(stats) {
|
|
127
|
+
if (stats === null || typeof stats !== 'object') return 'unknown'
|
|
128
|
+
if (stats.syncing === true) return 'syncing'
|
|
129
|
+
if (stats.dirty === true) return 'stale'
|
|
130
|
+
if (typeof stats.staleCount === 'number' && stats.staleCount > 0) return 'stale'
|
|
131
|
+
return 'synced'
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Node and edge counts read straight off a wiring graph, when no cache exists. */
|
|
135
|
+
export function countWiring(wiring) {
|
|
136
|
+
if (wiring === null || typeof wiring !== 'object') return undefined
|
|
137
|
+
const nodes = wiring.nodes
|
|
138
|
+
const edges = wiring.edges
|
|
139
|
+
if (!Array.isArray(nodes)) return undefined
|
|
140
|
+
return {
|
|
141
|
+
nodeCount: nodes.length,
|
|
142
|
+
edgeCount: Array.isArray(edges) ? edges.length : 0,
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** JSON from a file, or undefined when it is missing or unparseable. */
|
|
147
|
+
async function readJson(path) {
|
|
148
|
+
try {
|
|
149
|
+
return JSON.parse(await readFile(path, 'utf8'))
|
|
150
|
+
} catch {
|
|
151
|
+
// Absent, unreadable, or half-written by a concurrent graft sync. All three
|
|
152
|
+
// mean the same thing here: no data this time round.
|
|
153
|
+
return undefined
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Tokens graft's own session files claim to have saved in this repo.
|
|
159
|
+
*
|
|
160
|
+
* Summed across sessions rather than read from one, because the id the harness
|
|
161
|
+
* uses is not the id graft filed its state under. A repo graft has never been
|
|
162
|
+
* queried in has no session directory at all, which reads as zero — correct,
|
|
163
|
+
* and the reason the chip hides the figure rather than showing "0".
|
|
164
|
+
*/
|
|
165
|
+
async function readSavedTokens(root) {
|
|
166
|
+
const dir = join(root, 'graft', '.cache', 'session')
|
|
167
|
+
let entries
|
|
168
|
+
try {
|
|
169
|
+
entries = await readdir(dir)
|
|
170
|
+
} catch {
|
|
171
|
+
return 0
|
|
172
|
+
}
|
|
173
|
+
let total = 0
|
|
174
|
+
for (const entry of entries) {
|
|
175
|
+
if (!entry.endsWith('.json')) continue
|
|
176
|
+
const session = await readJson(join(dir, entry))
|
|
177
|
+
const saved = session?.savedTokens
|
|
178
|
+
if (typeof saved === 'number' && Number.isFinite(saved) && saved > 0) total += saved
|
|
179
|
+
}
|
|
180
|
+
return Math.round(total)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The standing instruction to prefer graft over grep/read.
|
|
185
|
+
*
|
|
186
|
+
* WHY THIS IS NOT IN THE DEPLOYMENT PERSONA
|
|
187
|
+
*
|
|
188
|
+
* It was, and it never reached the model. The web profile mounts agent presets,
|
|
189
|
+
* and the shipped `standard` preset carries its own persona row:
|
|
190
|
+
*
|
|
191
|
+
* # The preset's own persona, shadowing the deployment default for this agent.
|
|
192
|
+
* - id: persona
|
|
193
|
+
* name: '@deepseek-ai/dsh-persona'
|
|
194
|
+
* config: { text: 'You are a coding agent powered by {{model}} …' }
|
|
195
|
+
*
|
|
196
|
+
* Both sides name the same section, `deployment:persona`, and that is exactly
|
|
197
|
+
* what makes the preset REPLACE it rather than add to it. Anything written into
|
|
198
|
+
* the deployment persona is silently dropped for every session that joins a
|
|
199
|
+
* preset — which, on this surface, is all of them.
|
|
200
|
+
*
|
|
201
|
+
* A section of this plugin's own has no such conflict: it is registered on the
|
|
202
|
+
* agent's own context under its own name, so it survives whichever preset the
|
|
203
|
+
* session happens to use, and it travels with the tools it describes instead of
|
|
204
|
+
* living in a config file that has to remember they exist.
|
|
205
|
+
*
|
|
206
|
+
* WHY IT READS AS A PREFERENCE
|
|
207
|
+
*
|
|
208
|
+
* A hard "always use graft" is how the old graft-context skill caused a real
|
|
209
|
+
* failure: it said to run `graft check` first, the model obeyed past the point
|
|
210
|
+
* of sense, read "NO GRAPH" from the one tool that needs a deep build, and gave
|
|
211
|
+
* up without trying the five that worked.
|
|
212
|
+
*/
|
|
213
|
+
const GRAFT_GUIDANCE = [
|
|
214
|
+
'This workspace has a graft index: a prebuilt graph of every symbol, its',
|
|
215
|
+
'file:line span, and who calls what.',
|
|
216
|
+
'',
|
|
217
|
+
'Prefer the graft_* tools over reading or grepping files:',
|
|
218
|
+
'- graft_ask to find code or understand how something works — one call usually',
|
|
219
|
+
' replaces several file reads.',
|
|
220
|
+
'- graft_callers before renaming or changing a symbol, to see what depends on it.',
|
|
221
|
+
'- graft_skeleton instead of reading a whole file when signatures are enough.',
|
|
222
|
+
'- graft_map to orient in an unfamiliar part of the tree.',
|
|
223
|
+
'',
|
|
224
|
+
'Fall back to read/grep when graft returns nothing useful, and say so rather',
|
|
225
|
+
'than searching twice in silence. The graph rebuilds itself after edits, so it',
|
|
226
|
+
'is normally current; `/graft` rebuilds it on demand.',
|
|
227
|
+
].join('\n')
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Where the guidance sits in the assembled prompt.
|
|
231
|
+
*
|
|
232
|
+
* Between FILE_REFERENCE (900) and the per-tool sections (TOOL_BASH is 1000),
|
|
233
|
+
* so a rule about which tools to prefer is read immediately before the tools
|
|
234
|
+
* themselves are described.
|
|
235
|
+
*/
|
|
236
|
+
const GUIDANCE_ORDER = 950
|
|
237
|
+
|
|
238
|
+
/** Where this plugin keeps its own running totals. */
|
|
239
|
+
const SAVINGS_PATH = join(
|
|
240
|
+
process.env.DSH_HOME ?? join(process.env.USERPROFILE ?? process.env.HOME ?? '.', '.dsh'),
|
|
241
|
+
'storages',
|
|
242
|
+
'graft-status.json',
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Tokens the dsh tools have saved in one repo, added to graft's own tally.
|
|
247
|
+
*
|
|
248
|
+
* The two are summed rather than merged because they count disjoint work:
|
|
249
|
+
* graft's per-session files are written by ITS hooks, which only run under
|
|
250
|
+
* Claude Code, while this file records calls made through dsh. Neither sees
|
|
251
|
+
* the other's, so the sum is the honest total for the repo — which is what the
|
|
252
|
+
* chip claims it is.
|
|
253
|
+
*/
|
|
254
|
+
export function totalSaved(graftOwn, ours) {
|
|
255
|
+
const a = typeof graftOwn === 'number' && Number.isFinite(graftOwn) && graftOwn > 0 ? graftOwn : 0
|
|
256
|
+
const b = typeof ours === 'number' && Number.isFinite(ours) && ours > 0 ? ours : 0
|
|
257
|
+
return Math.round(a + b)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The whole status for one repo root.
|
|
262
|
+
*
|
|
263
|
+
* The cache is preferred over the graph because only the cache carries live
|
|
264
|
+
* dirty/stale state; the graph is the fallback that keeps a freshly built repo
|
|
265
|
+
* from reading as "not built" until something writes the cache. That ordering
|
|
266
|
+
* is graft's own, kept identical so this chip and graft's statusline cannot
|
|
267
|
+
* disagree about the same repo.
|
|
268
|
+
*/
|
|
269
|
+
export async function computeStatus(root, io) {
|
|
270
|
+
const stats = await io.readJson(join(root, 'graft', '.cache', 'stats.json'))
|
|
271
|
+
const base = stats !== undefined && typeof stats.nodeCount === 'number' && stats.nodeCount > 0
|
|
272
|
+
? { nodeCount: stats.nodeCount, edgeCount: stats.edgeCount ?? 0, freshness: freshnessOf(stats), syncedAt: stats.syncedAt ?? null }
|
|
273
|
+
: undefined
|
|
274
|
+
|
|
275
|
+
if (base !== undefined) {
|
|
276
|
+
return { ...base, root, savedTokens: totalSaved(await io.readSavedTokens(root), io.ourSavings?.(root)), source: 'cache' }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const counted = countWiring(await io.readJson(join(root, 'graft', '.graph', 'wiring.json')))
|
|
280
|
+
if (counted === undefined) return { root, ok: false, reason: 'no graph' }
|
|
281
|
+
return {
|
|
282
|
+
...counted,
|
|
283
|
+
root,
|
|
284
|
+
// The graph carries no drift signal of its own, so this reads as synced
|
|
285
|
+
// until something repopulates the cache — the same concession graft's
|
|
286
|
+
// statusline documents for this exact fallback.
|
|
287
|
+
freshness: 'synced',
|
|
288
|
+
syncedAt: null,
|
|
289
|
+
savedTokens: totalSaved(await io.readSavedTokens(root), io.ourSavings?.(root)),
|
|
290
|
+
source: 'graph',
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/* ========================================================================== *
|
|
295
|
+
* The visualiser
|
|
296
|
+
* ========================================================================== */
|
|
297
|
+
|
|
298
|
+
/** graft viz's own default. Kept so a URL a user already has still works. */
|
|
299
|
+
const VIZ_PORT = 4400
|
|
300
|
+
|
|
301
|
+
/** Is something already answering on this port? */
|
|
302
|
+
async function vizAlive(port) {
|
|
303
|
+
try {
|
|
304
|
+
const response = await fetch(`http://127.0.0.1:${port}/`, {
|
|
305
|
+
signal: AbortSignal.timeout(1200),
|
|
306
|
+
})
|
|
307
|
+
return response.ok
|
|
308
|
+
} catch {
|
|
309
|
+
// Refused, timed out, or nothing there. All mean "not serving".
|
|
310
|
+
return false
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The first port from `base` that nothing is answering on.
|
|
316
|
+
*
|
|
317
|
+
* One `graft viz` serves exactly one repo, so two sessions on two repos need
|
|
318
|
+
* two servers. An earlier version adopted whatever was already listening on the
|
|
319
|
+
* fixed port, which is wrong the moment a second repo is involved: the adopted
|
|
320
|
+
* server's repo is unknowable from the outside, so the tab would confidently
|
|
321
|
+
* show a graph of something else. Picking a free port instead means every
|
|
322
|
+
* server this plugin uses is one it started, for a root it knows.
|
|
323
|
+
*/
|
|
324
|
+
export async function pickPort(base, deps, span = 20) {
|
|
325
|
+
for (let port = base; port < base + span; port += 1) {
|
|
326
|
+
if (!(await deps.alive(port))) return port
|
|
327
|
+
}
|
|
328
|
+
return undefined
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Start `graft viz` for one repo and wait until it is actually accepting.
|
|
333
|
+
*
|
|
334
|
+
* @param deps - injected so the harness can drive this without a real graft.
|
|
335
|
+
*/
|
|
336
|
+
export async function startViz(root, port, deps) {
|
|
337
|
+
let child
|
|
338
|
+
try {
|
|
339
|
+
child = deps.spawn(root, port)
|
|
340
|
+
} catch (error) {
|
|
341
|
+
return { ok: false, reason: String(error?.message ?? error) }
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Poll rather than trust the process: `graft viz` prints its banner before
|
|
345
|
+
// the socket is necessarily accepting, and the iframe that follows gets one
|
|
346
|
+
// chance to load. Ten seconds covers a cold graph read on a large repo.
|
|
347
|
+
const deadline = Date.now() + 10000
|
|
348
|
+
for (;;) {
|
|
349
|
+
if (await deps.alive(port)) return { ok: true, url: `http://127.0.0.1:${port}/`, child }
|
|
350
|
+
if (child.exitCode !== null || Date.now() > deadline) {
|
|
351
|
+
return {
|
|
352
|
+
ok: false,
|
|
353
|
+
reason: child.exitCode !== null
|
|
354
|
+
? `graft viz exited with code ${child.exitCode}`
|
|
355
|
+
: `graft viz did not start listening on ${port} within 10s`,
|
|
356
|
+
child,
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
await deps.wait(250)
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/* ========================================================================== *
|
|
364
|
+
* Browser-facing operations
|
|
365
|
+
* ========================================================================== */
|
|
366
|
+
|
|
367
|
+
let liveOps = null
|
|
368
|
+
|
|
369
|
+
class GraftStatusRemote extends TypertRemoteService {
|
|
370
|
+
constructor(ownerCtx) {
|
|
371
|
+
super(ownerCtx, 'graftStatus')
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Counts, freshness, and the repo root resolved for one session. */
|
|
375
|
+
status(sessionId) {
|
|
376
|
+
if (liveOps === null) throw new Error('graft-status is not ready')
|
|
377
|
+
return liveOps.status(sessionId)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Ensure `graft viz` is serving that session's repo, and answer with its URL. */
|
|
381
|
+
viz(sessionId) {
|
|
382
|
+
if (liveOps === null) throw new Error('graft-status is not ready')
|
|
383
|
+
return liveOps.viz(sessionId)
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Emulate the @Remote decorator without decorator syntax. */
|
|
388
|
+
function markRemoteMethod(prototype, method) {
|
|
389
|
+
Remote(method)(prototype[method], {
|
|
390
|
+
kind: 'method',
|
|
391
|
+
name: method,
|
|
392
|
+
static: false,
|
|
393
|
+
private: false,
|
|
394
|
+
access: {
|
|
395
|
+
has() {
|
|
396
|
+
return false
|
|
397
|
+
},
|
|
398
|
+
get() {
|
|
399
|
+
return undefined
|
|
400
|
+
},
|
|
401
|
+
},
|
|
402
|
+
addInitializer(fn) {
|
|
403
|
+
fn.call(Object.create(prototype))
|
|
404
|
+
},
|
|
405
|
+
})
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
markRemoteMethod(GraftStatusRemote.prototype, 'status')
|
|
409
|
+
markRemoteMethod(GraftStatusRemote.prototype, 'viz')
|
|
410
|
+
|
|
411
|
+
export function apply(ctx, config = {}) {
|
|
412
|
+
const fallbackFrom = typeof config.cwd === 'string' && config.cwd.length > 0 ? config.cwd : process.cwd()
|
|
413
|
+
const cacheMs = Number.isInteger(config.cacheMs) && config.cacheMs >= 0 ? config.cacheMs : CACHE_MS
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* root -> tokens this plugin's tools have saved there.
|
|
417
|
+
*
|
|
418
|
+
* Loaded once and written back lazily. Kept out of graft's own session files
|
|
419
|
+
* on purpose: those belong to graft's hooks, and a second writer racing them
|
|
420
|
+
* would corrupt a tally this plugin only wants to read.
|
|
421
|
+
*/
|
|
422
|
+
const savings = new Map()
|
|
423
|
+
let savingsLoaded = false
|
|
424
|
+
let savingsDirty = false
|
|
425
|
+
|
|
426
|
+
const loadSavings = async () => {
|
|
427
|
+
if (savingsLoaded) return
|
|
428
|
+
savingsLoaded = true
|
|
429
|
+
const stored = await readJson(SAVINGS_PATH)
|
|
430
|
+
const rows = stored?.savedTokens
|
|
431
|
+
if (rows !== null && typeof rows === 'object') {
|
|
432
|
+
for (const [root, value] of Object.entries(rows)) {
|
|
433
|
+
if (typeof value === 'number' && Number.isFinite(value) && value > 0) savings.set(root, value)
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const flushSavings = async () => {
|
|
439
|
+
if (!savingsDirty) return
|
|
440
|
+
savingsDirty = false
|
|
441
|
+
try {
|
|
442
|
+
await mkdir(dirname(SAVINGS_PATH), { recursive: true })
|
|
443
|
+
const temporary = `${SAVINGS_PATH}.${process.pid}.tmp`
|
|
444
|
+
const body = { version: 1, savedTokens: Object.fromEntries(savings) }
|
|
445
|
+
await writeFile(temporary, JSON.stringify(body, null, 2), 'utf8')
|
|
446
|
+
await rename(temporary, SAVINGS_PATH)
|
|
447
|
+
} catch {
|
|
448
|
+
// A lost tally is a cosmetic loss on one chip; never worth an error path.
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const io = {
|
|
453
|
+
readJson,
|
|
454
|
+
readSavedTokens,
|
|
455
|
+
ourSavings: (root) => savings.get(root) ?? 0,
|
|
456
|
+
exists: async (path) => {
|
|
457
|
+
try {
|
|
458
|
+
await access(path)
|
|
459
|
+
return true
|
|
460
|
+
} catch {
|
|
461
|
+
return false
|
|
462
|
+
}
|
|
463
|
+
},
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const vizPort = Number.isInteger(config.vizPort) && config.vizPort > 0 && config.vizPort < 65536
|
|
467
|
+
? config.vizPort
|
|
468
|
+
: VIZ_PORT
|
|
469
|
+
|
|
470
|
+
// `ctx.get` rather than `ctx.sessions`: the latter throws without a declared
|
|
471
|
+
// inject, and this plugin deliberately declares none so a missing service can
|
|
472
|
+
// never take the chip down with it.
|
|
473
|
+
const cwdFor = (sessionId) => cwdForSession(ctx.get?.('sessions'), sessionId, fallbackFrom)
|
|
474
|
+
|
|
475
|
+
/** root -> cached status, so two sessions on two repos do not share one answer. */
|
|
476
|
+
const cache = new Map()
|
|
477
|
+
/** root -> { port, child, url } for every viz server this plugin started. */
|
|
478
|
+
const vizByRoot = new Map()
|
|
479
|
+
/** root -> in-flight start, so a double click cannot race two spawns. */
|
|
480
|
+
const vizPending = new Map()
|
|
481
|
+
|
|
482
|
+
const vizDeps = {
|
|
483
|
+
alive: vizAlive,
|
|
484
|
+
wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
485
|
+
spawn: (root, port) => {
|
|
486
|
+
// Same shell-free invocation the tools use: running graft's own cli.js
|
|
487
|
+
// with this Node means the child IS graft, not a cmd.exe wrapping it —
|
|
488
|
+
// so an ordinary kill() reaches it and no process-tree surgery is needed.
|
|
489
|
+
const how = resolveGraftCommand()
|
|
490
|
+
const child = spawn(how.command, [...how.prefix, 'viz', '--port', String(port), '--no-open'], {
|
|
491
|
+
cwd: root,
|
|
492
|
+
shell: how.shell,
|
|
493
|
+
stdio: 'ignore',
|
|
494
|
+
// Not detached: the visualiser is a child of this dsh, and should not
|
|
495
|
+
// outlive it as an orphan holding its port against the next run.
|
|
496
|
+
windowsHide: true,
|
|
497
|
+
})
|
|
498
|
+
// MANDATORY, not defensive. A ChildProcess that fails to spawn emits
|
|
499
|
+
// 'error' asynchronously, and an unhandled 'error' event does not
|
|
500
|
+
// reject or throw where it can be caught — it takes the whole process
|
|
501
|
+
// down. Without this listener a missing binary crashed dsh instead of
|
|
502
|
+
// reporting that the visualiser could not start.
|
|
503
|
+
child.on('error', () => {})
|
|
504
|
+
return child
|
|
505
|
+
},
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
liveOps = {
|
|
509
|
+
status: async (sessionId) => {
|
|
510
|
+
const from = cwdFor(sessionId)
|
|
511
|
+
let root
|
|
512
|
+
try {
|
|
513
|
+
root = await findGraftRoot(from, io.exists)
|
|
514
|
+
} catch (error) {
|
|
515
|
+
return { ok: false, reason: String(error?.message ?? error), from }
|
|
516
|
+
}
|
|
517
|
+
if (root === undefined) return { ok: false, reason: 'no graft index above ' + from, from }
|
|
518
|
+
|
|
519
|
+
const now = Date.now()
|
|
520
|
+
const hit = cache.get(root)
|
|
521
|
+
if (hit !== undefined && now - hit.at < cacheMs) return hit.value
|
|
522
|
+
|
|
523
|
+
let value
|
|
524
|
+
try {
|
|
525
|
+
value = { ok: true, ...await computeStatus(root, io) }
|
|
526
|
+
} catch (error) {
|
|
527
|
+
// A status chip must never be the thing that breaks the composer.
|
|
528
|
+
value = { ok: false, reason: String(error?.message ?? error), from }
|
|
529
|
+
}
|
|
530
|
+
cache.set(root, { value, at: now })
|
|
531
|
+
return value
|
|
532
|
+
},
|
|
533
|
+
|
|
534
|
+
viz: async (sessionId) => {
|
|
535
|
+
const from = cwdFor(sessionId)
|
|
536
|
+
const root = await findGraftRoot(from, io.exists)
|
|
537
|
+
if (root === undefined) return { ok: false, reason: 'no graft index above ' + from, from }
|
|
538
|
+
|
|
539
|
+
// Already serving this repo: reuse it. Checked before the pending map so
|
|
540
|
+
// a reopened tab costs nothing at all.
|
|
541
|
+
const live = vizByRoot.get(root)
|
|
542
|
+
if (live !== undefined && live.child.exitCode === null) {
|
|
543
|
+
return { ok: true, url: live.url, root }
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const inFlight = vizPending.get(root)
|
|
547
|
+
if (inFlight !== undefined) return inFlight
|
|
548
|
+
|
|
549
|
+
const start = (async () => {
|
|
550
|
+
const port = await pickPort(vizPort, vizDeps)
|
|
551
|
+
if (port === undefined) {
|
|
552
|
+
return { ok: false, reason: `no free port for graft viz near ${vizPort}`, root }
|
|
553
|
+
}
|
|
554
|
+
const started = await startViz(root, port, vizDeps)
|
|
555
|
+
if (started.ok && started.child !== undefined) {
|
|
556
|
+
vizByRoot.set(root, { port, child: started.child, url: started.url })
|
|
557
|
+
}
|
|
558
|
+
return started.ok
|
|
559
|
+
? { ok: true, url: started.url, root }
|
|
560
|
+
: { ok: false, reason: started.reason, root }
|
|
561
|
+
})()
|
|
562
|
+
|
|
563
|
+
vizPending.set(root, start)
|
|
564
|
+
try {
|
|
565
|
+
return await start
|
|
566
|
+
} finally {
|
|
567
|
+
vizPending.delete(root)
|
|
568
|
+
}
|
|
569
|
+
},
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
new GraftStatusRemote(ctx)
|
|
573
|
+
|
|
574
|
+
// The model-facing half. Registered unless turned off, so a profile that
|
|
575
|
+
// would rather keep the MCP server can set `tools: false` and lose nothing
|
|
576
|
+
// else this plugin does.
|
|
577
|
+
if (config.tools !== false) {
|
|
578
|
+
const toolDeps = {
|
|
579
|
+
fallbackCwd: fallbackFrom,
|
|
580
|
+
findRoot: (start) => findGraftRoot(start, io.exists),
|
|
581
|
+
recordSavings: (root, saved) => {
|
|
582
|
+
savings.set(root, (savings.get(root) ?? 0) + saved)
|
|
583
|
+
savingsDirty = true
|
|
584
|
+
// The status cache would otherwise hold the pre-call figure for its
|
|
585
|
+
// five seconds, so a tally that just changed would not show until the
|
|
586
|
+
// chip happened to poll again.
|
|
587
|
+
cache.delete(root)
|
|
588
|
+
void flushSavings()
|
|
589
|
+
},
|
|
590
|
+
}
|
|
591
|
+
for (const tool of graftTools(toolDeps)) {
|
|
592
|
+
ctx.effect(() => ctx.tools.register(tool), `graft-status: ${tool.name}`)
|
|
593
|
+
}
|
|
594
|
+
void loadSavings()
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// `/graft`. Soft-injected like the sidebar tab: a profile without the command
|
|
598
|
+
// runtime still gets the chip, the tools and the auto-rebuild.
|
|
599
|
+
if (config.command !== false) {
|
|
600
|
+
ctx.inject(['commands'], (scope) => {
|
|
601
|
+
scope.effect(
|
|
602
|
+
() =>
|
|
603
|
+
scope.commands.register(
|
|
604
|
+
createGraftCommand({
|
|
605
|
+
fallbackCwd: fallbackFrom,
|
|
606
|
+
findRoot: (start) => findGraftRoot(start, io.exists),
|
|
607
|
+
readJson,
|
|
608
|
+
countWiring,
|
|
609
|
+
}),
|
|
610
|
+
),
|
|
611
|
+
'graft-status: /graft command',
|
|
612
|
+
)
|
|
613
|
+
})
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// The standing instruction, registered on each agent's OWN context so no
|
|
617
|
+
// preset can shadow it (see GRAFT_GUIDANCE for why the persona could not).
|
|
618
|
+
if (config.promptSection !== false) {
|
|
619
|
+
/** Agent contexts already carrying the section; a duplicate name throws. */
|
|
620
|
+
const briefed = new WeakSet()
|
|
621
|
+
|
|
622
|
+
ctx.on('agent/session-start', (payload) => {
|
|
623
|
+
const agentCtx = payload?.agent?.ctx
|
|
624
|
+
if (agentCtx === undefined || agentCtx === null || briefed.has(agentCtx)) return
|
|
625
|
+
briefed.add(agentCtx)
|
|
626
|
+
try {
|
|
627
|
+
// `get` rather than `agentCtx.systemPrompt`: the property accessor
|
|
628
|
+
// throws without a declared inject, and this plugin declares none for
|
|
629
|
+
// it on purpose — a deployment without the service should lose the
|
|
630
|
+
// sentence, not the chip and the tools.
|
|
631
|
+
const prompt = agentCtx.get?.('systemPrompt')
|
|
632
|
+
if (typeof prompt?.section !== 'function') return
|
|
633
|
+
agentCtx.effect?.(
|
|
634
|
+
() => prompt.section({ name: 'graft-status:usage', order: GUIDANCE_ORDER, text: GRAFT_GUIDANCE }),
|
|
635
|
+
'graft-status: prompt guidance',
|
|
636
|
+
)
|
|
637
|
+
} catch {
|
|
638
|
+
// A section that fails to register costs a nudge, never the session.
|
|
639
|
+
}
|
|
640
|
+
})
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Auto-rebuild: graft's two Claude Code hooks, on dsh's own seams.
|
|
644
|
+
if (config.autoBuild !== false) {
|
|
645
|
+
const autoSync = createAutoSync({
|
|
646
|
+
cliPath: resolveGraftCommand().prefix[0],
|
|
647
|
+
minIntervalMs: config.autoBuildMinIntervalMs,
|
|
648
|
+
writeTools: config.autoBuildTools,
|
|
649
|
+
})
|
|
650
|
+
|
|
651
|
+
ctx.on('tools/execute', async (exec, next) => {
|
|
652
|
+
const result = await next()
|
|
653
|
+
// Only a call that SUCCEEDED can have moved the tree, and a failed edit
|
|
654
|
+
// marking the graph dirty would trigger a rebuild that finds nothing.
|
|
655
|
+
if (result?.isError !== true && autoSync.touches(exec?.name)) {
|
|
656
|
+
// Straight off the call's own agent, the same source graft_ask uses.
|
|
657
|
+
// Going via the sessions registry would be a lookup to reach a value
|
|
658
|
+
// already in hand, and would differ if the two ever disagreed.
|
|
659
|
+
const from = exec?.agent?.session?.header?.cwd ?? fallbackFrom
|
|
660
|
+
const root = await findGraftRoot(from, io.exists)
|
|
661
|
+
if (root !== undefined) void autoSync.markDirty(root)
|
|
662
|
+
}
|
|
663
|
+
return result
|
|
664
|
+
})
|
|
665
|
+
|
|
666
|
+
ctx.effect(() => () => autoSync.dispose(), 'graft-status: auto-sync timers')
|
|
667
|
+
|
|
668
|
+
ctx.on('agent/turn-stopping', async (payload) => {
|
|
669
|
+
const cwd = payload?.agent?.session?.header?.cwd ?? fallbackFrom
|
|
670
|
+
const root = await findGraftRoot(cwd, io.exists)
|
|
671
|
+
if (root === undefined) return
|
|
672
|
+
const outcome = await autoSync.syncIfDirty(root)
|
|
673
|
+
if (outcome === 'started') {
|
|
674
|
+
// Dropped so the chip re-reads once the rebuild lands, rather than
|
|
675
|
+
// showing the pre-build counts for the rest of the cache window.
|
|
676
|
+
cache.delete(root)
|
|
677
|
+
ctx.logger?.info?.(`graft-status: rebuilding the graft graph for ${root}`)
|
|
678
|
+
}
|
|
679
|
+
})
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
ctx.effect(() => () => {
|
|
683
|
+
liveOps = null
|
|
684
|
+
for (const { child } of vizByRoot.values()) {
|
|
685
|
+
try {
|
|
686
|
+
// A plain kill, because the child is graft itself. An earlier version
|
|
687
|
+
// shelled out to `taskkill /T` to reach through a cmd.exe wrapper, and
|
|
688
|
+
// that spawn ITSELF threw ENOENT wherever taskkill was not on PATH —
|
|
689
|
+
// an unhandled 'error' event that crashed dsh during shutdown. The
|
|
690
|
+
// wrapper is gone, so the reason for taskkill is gone with it.
|
|
691
|
+
child.kill()
|
|
692
|
+
} catch {
|
|
693
|
+
// Teardown must not throw. A survivor costs one held port, which the
|
|
694
|
+
// next start steps over when it probes for a free one.
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
vizByRoot.clear()
|
|
698
|
+
}, 'graft-status: release live operations')
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
export default { apply, inject, name }
|