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/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 }