@pugi/cli 0.1.0-beta.5 → 0.1.0-beta.51

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.
Files changed (264) hide show
  1. package/THIRD_PARTY_NOTICES.md +40 -0
  2. package/assets/pugi-mascot.ansi +15 -25
  3. package/assets/pugi-prozr2-mascot.ansi +9 -0
  4. package/bin/run.js +33 -1
  5. package/dist/commands/jobs-watch.js +201 -0
  6. package/dist/commands/jobs.js +15 -0
  7. package/dist/commands/smoke.js +133 -0
  8. package/dist/core/agent-progress/cleanup.js +134 -0
  9. package/dist/core/agent-progress/schema.js +144 -0
  10. package/dist/core/agent-progress/writer.js +101 -0
  11. package/dist/core/artifact-chain/dispatcher.js +148 -0
  12. package/dist/core/artifact-chain/exporter.js +164 -0
  13. package/dist/core/artifact-chain/state.js +243 -0
  14. package/dist/core/artifact-chain/steps.js +169 -0
  15. package/dist/core/auth/ensure-authenticated.js +129 -0
  16. package/dist/core/auth/env-provider.js +238 -0
  17. package/dist/core/auto-update/channels.js +122 -0
  18. package/dist/core/auto-update/checker.js +241 -0
  19. package/dist/core/auto-update/state.js +235 -0
  20. package/dist/core/bare-mode/index.js +107 -0
  21. package/dist/core/bash-classifier.js +400 -4
  22. package/dist/core/checkpoint/resumer.js +149 -0
  23. package/dist/core/checkpoint/rewinder.js +291 -0
  24. package/dist/core/codegraph/decision-store.js +248 -0
  25. package/dist/core/codegraph/detect-repo.js +459 -0
  26. package/dist/core/codegraph/install.js +134 -0
  27. package/dist/core/codegraph/offer-hook.js +220 -0
  28. package/dist/core/compact/auto-trigger.js +96 -0
  29. package/dist/core/compact/buffer-rewriter.js +115 -0
  30. package/dist/core/compact/summarizer.js +208 -0
  31. package/dist/core/compact/token-counter.js +108 -0
  32. package/dist/core/consensus/diff-capture.js +112 -3
  33. package/dist/core/context/index.js +7 -0
  34. package/dist/core/context/markdown-traverse.js +255 -0
  35. package/dist/core/cost/rate-card.js +129 -0
  36. package/dist/core/cost/tracker.js +221 -0
  37. package/dist/core/denial-tracking/index.js +8 -0
  38. package/dist/core/denial-tracking/state.js +264 -0
  39. package/dist/core/diagnostics/probe-runner.js +93 -0
  40. package/dist/core/diagnostics/probes/api.js +46 -0
  41. package/dist/core/diagnostics/probes/auth.js +86 -0
  42. package/dist/core/diagnostics/probes/bare-mode.js +42 -0
  43. package/dist/core/diagnostics/probes/cli-version.js +127 -0
  44. package/dist/core/diagnostics/probes/config.js +72 -0
  45. package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
  46. package/dist/core/diagnostics/probes/disk.js +81 -0
  47. package/dist/core/diagnostics/probes/git.js +65 -0
  48. package/dist/core/diagnostics/probes/hooks.js +118 -0
  49. package/dist/core/diagnostics/probes/mcp.js +75 -0
  50. package/dist/core/diagnostics/probes/node.js +59 -0
  51. package/dist/core/diagnostics/probes/pnpm.js +36 -0
  52. package/dist/core/diagnostics/probes/pugi-md.js +89 -0
  53. package/dist/core/diagnostics/probes/sandbox.js +40 -0
  54. package/dist/core/diagnostics/probes/session.js +74 -0
  55. package/dist/core/diagnostics/probes/status-snapshot.js +488 -0
  56. package/dist/core/diagnostics/probes/workspace.js +63 -0
  57. package/dist/core/diagnostics/types.js +70 -0
  58. package/dist/core/dispatch/cache-cleanup.js +197 -0
  59. package/dist/core/dispatch/cache-handoff.js +295 -0
  60. package/dist/core/edits/dispatch.js +218 -2
  61. package/dist/core/edits/journal.js +199 -0
  62. package/dist/core/edits/layer-d-ast.js +557 -14
  63. package/dist/core/edits/verify-hook.js +273 -0
  64. package/dist/core/edits/worktree.js +322 -0
  65. package/dist/core/engine/anvil-client.js +115 -5
  66. package/dist/core/engine/auto-compact.js +179 -0
  67. package/dist/core/engine/budgets.js +155 -0
  68. package/dist/core/engine/context-prefix.js +155 -0
  69. package/dist/core/engine/intent.js +260 -0
  70. package/dist/core/engine/native-pugi.js +897 -211
  71. package/dist/core/engine/prompts.js +88 -2
  72. package/dist/core/engine/strip-internal-fields.js +124 -0
  73. package/dist/core/engine/tool-bridge.js +1045 -36
  74. package/dist/core/feedback/queue.js +177 -0
  75. package/dist/core/feedback/submitter.js +145 -0
  76. package/dist/core/file-cache.js +113 -1
  77. package/dist/core/hooks/events.js +44 -0
  78. package/dist/core/hooks/index.js +15 -0
  79. package/dist/core/hooks/registry.js +213 -0
  80. package/dist/core/hooks/runner.js +236 -0
  81. package/dist/core/hooks/v2/event-emitter.js +115 -0
  82. package/dist/core/hooks/v2/executor.js +282 -0
  83. package/dist/core/hooks/v2/index.js +25 -0
  84. package/dist/core/hooks/v2/lifecycle.js +104 -0
  85. package/dist/core/hooks/v2/loader.js +216 -0
  86. package/dist/core/hooks/v2/matcher.js +125 -0
  87. package/dist/core/hooks/v2/trust.js +143 -0
  88. package/dist/core/hooks/v2/types.js +86 -0
  89. package/dist/core/lsp/cache.js +105 -0
  90. package/dist/core/lsp/client.js +776 -0
  91. package/dist/core/lsp/language-detect.js +66 -0
  92. package/dist/core/lsp/post-edit-diagnostics.js +171 -0
  93. package/dist/core/mcp/client.js +75 -6
  94. package/dist/core/mcp/http-server.js +553 -0
  95. package/dist/core/mcp/orchestrator-tools.js +662 -0
  96. package/dist/core/mcp/permission.js +190 -0
  97. package/dist/core/mcp/registry.js +24 -2
  98. package/dist/core/mcp/server-tools.js +219 -0
  99. package/dist/core/mcp/server.js +397 -0
  100. package/dist/core/memory/dual-write.js +416 -0
  101. package/dist/core/memory/phase1-kinds.js +20 -0
  102. package/dist/core/memory-sync/queue.js +158 -0
  103. package/dist/core/onboarding/ensure-initialized.js +133 -0
  104. package/dist/core/onboarding/marker.js +111 -0
  105. package/dist/core/onboarding/telemetry-state.js +108 -0
  106. package/dist/core/output-style/presets.js +176 -0
  107. package/dist/core/output-style/state.js +185 -0
  108. package/dist/core/path-security.js +284 -2
  109. package/dist/core/permissions/auto-classifier.js +124 -0
  110. package/dist/core/permissions/circuit-breaker.js +83 -0
  111. package/dist/core/permissions/gate.js +278 -0
  112. package/dist/core/permissions/index.js +20 -0
  113. package/dist/core/permissions/mode.js +174 -0
  114. package/dist/core/permissions/state.js +241 -0
  115. package/dist/core/permissions/tool-class.js +93 -0
  116. package/dist/core/prd-check/parser.js +215 -0
  117. package/dist/core/prd-check/reporter.js +127 -0
  118. package/dist/core/prd-check/session-review.js +557 -0
  119. package/dist/core/prd-check/verifiers.js +223 -0
  120. package/dist/core/pugi-md/context-injector.js +76 -0
  121. package/dist/core/pugi-md/walk-up.js +207 -0
  122. package/dist/core/release-notes/parser.js +241 -0
  123. package/dist/core/release-notes/state.js +116 -0
  124. package/dist/core/repl/history.js +11 -1
  125. package/dist/core/repl/model-pricing.js +135 -0
  126. package/dist/core/repl/session.js +1897 -37
  127. package/dist/core/repl/slash-commands.js +430 -15
  128. package/dist/core/repl/store/session-store.js +31 -2
  129. package/dist/core/repl/workspace-context.js +22 -0
  130. package/dist/core/repo-map/build.js +125 -0
  131. package/dist/core/repo-map/cache.js +185 -0
  132. package/dist/core/repo-map/extractor.js +254 -0
  133. package/dist/core/repo-map/formatter.js +145 -0
  134. package/dist/core/repo-map/scanner.js +211 -0
  135. package/dist/core/retry-budget/budget.js +284 -0
  136. package/dist/core/retry-budget/index.js +5 -0
  137. package/dist/core/session.js +92 -0
  138. package/dist/core/settings.js +80 -0
  139. package/dist/core/share/formatter.js +271 -0
  140. package/dist/core/share/redactor.js +221 -0
  141. package/dist/core/share/uploader.js +267 -0
  142. package/dist/core/skills/defaults.js +457 -0
  143. package/dist/core/smoke/headless-driver.js +174 -0
  144. package/dist/core/smoke/orchestrator.js +194 -0
  145. package/dist/core/smoke/runner.js +238 -0
  146. package/dist/core/smoke/scenario-parser.js +316 -0
  147. package/dist/core/subagents/dispatcher-real.js +600 -0
  148. package/dist/core/subagents/dispatcher.js +113 -24
  149. package/dist/core/subagents/index.js +18 -5
  150. package/dist/core/subagents/isolation-matrix.js +213 -0
  151. package/dist/core/subagents/spawn.js +19 -4
  152. package/dist/core/telemetry/emitter.js +229 -0
  153. package/dist/core/telemetry/queue.js +251 -0
  154. package/dist/core/theme/context.js +91 -0
  155. package/dist/core/theme/presets.js +228 -0
  156. package/dist/core/theme/state.js +181 -0
  157. package/dist/core/todos/invariant.js +10 -0
  158. package/dist/core/todos/state.js +177 -0
  159. package/dist/core/transport/version-interceptor.js +166 -0
  160. package/dist/core/vim/keymap.js +288 -0
  161. package/dist/core/vim/state.js +92 -0
  162. package/dist/core/worktree-manager/cleanup.js +123 -0
  163. package/dist/core/worktree-manager/manager.js +303 -0
  164. package/dist/index.js +28 -0
  165. package/dist/runtime/bootstrap.js +190 -0
  166. package/dist/runtime/cli.js +3241 -343
  167. package/dist/runtime/commands/cancel.js +231 -0
  168. package/dist/runtime/commands/chain.js +489 -0
  169. package/dist/runtime/commands/codegraph-status.js +227 -0
  170. package/dist/runtime/commands/compact.js +297 -0
  171. package/dist/runtime/commands/cost.js +199 -0
  172. package/dist/runtime/commands/delegate.js +242 -11
  173. package/dist/runtime/commands/dispatch.js +126 -0
  174. package/dist/runtime/commands/doctor.js +412 -0
  175. package/dist/runtime/commands/feedback.js +184 -0
  176. package/dist/runtime/commands/hooks.js +184 -0
  177. package/dist/runtime/commands/lsp.js +368 -0
  178. package/dist/runtime/commands/mcp.js +879 -0
  179. package/dist/runtime/commands/memory.js +508 -0
  180. package/dist/runtime/commands/model.js +237 -0
  181. package/dist/runtime/commands/onboarding.js +275 -0
  182. package/dist/runtime/commands/patch.js +128 -0
  183. package/dist/runtime/commands/permissions.js +112 -0
  184. package/dist/runtime/commands/plan.js +143 -0
  185. package/dist/runtime/commands/prd-check.js +285 -0
  186. package/dist/runtime/commands/redo-blob-store.js +92 -0
  187. package/dist/runtime/commands/redo.js +361 -0
  188. package/dist/runtime/commands/release-notes.js +229 -0
  189. package/dist/runtime/commands/repo-map.js +95 -0
  190. package/dist/runtime/commands/report.js +299 -0
  191. package/dist/runtime/commands/resume.js +118 -0
  192. package/dist/runtime/commands/review-consensus.js +17 -2
  193. package/dist/runtime/commands/rewind.js +333 -0
  194. package/dist/runtime/commands/sessions.js +163 -0
  195. package/dist/runtime/commands/share.js +316 -0
  196. package/dist/runtime/commands/status.js +186 -0
  197. package/dist/runtime/commands/stickers.js +82 -0
  198. package/dist/runtime/commands/style.js +194 -0
  199. package/dist/runtime/commands/theme.js +196 -0
  200. package/dist/runtime/commands/undo.js +32 -0
  201. package/dist/runtime/commands/update.js +289 -0
  202. package/dist/runtime/commands/vim.js +140 -0
  203. package/dist/runtime/commands/worktree.js +177 -0
  204. package/dist/runtime/commands/worktrees.js +155 -0
  205. package/dist/runtime/headless-repl.js +195 -0
  206. package/dist/runtime/headless.js +543 -0
  207. package/dist/runtime/load-hooks-or-exit.js +71 -0
  208. package/dist/runtime/plan-decompose.js +531 -0
  209. package/dist/runtime/version.js +65 -0
  210. package/dist/tools/agent-tool.js +229 -0
  211. package/dist/tools/apply-patch.js +556 -0
  212. package/dist/tools/ask-user-question.js +213 -0
  213. package/dist/tools/ask-user.js +115 -0
  214. package/dist/tools/bash.js +203 -4
  215. package/dist/tools/file-tools.js +85 -14
  216. package/dist/tools/lsp-tools.js +189 -0
  217. package/dist/tools/mcp-tool.js +260 -0
  218. package/dist/tools/multi-edit.js +361 -0
  219. package/dist/tools/powershell.js +268 -0
  220. package/dist/tools/registry.js +51 -0
  221. package/dist/tools/skill-tool.js +96 -0
  222. package/dist/tools/tasks.js +208 -0
  223. package/dist/tools/todo-write.js +184 -0
  224. package/dist/tools/web-fetch.js +147 -2
  225. package/dist/tools/web-search.js +458 -0
  226. package/dist/tui/agent-progress-card.js +111 -0
  227. package/dist/tui/agent-tree.js +10 -0
  228. package/dist/tui/ask-modal.js +2 -2
  229. package/dist/tui/ask-user-question-prompt.js +192 -0
  230. package/dist/tui/compact-banner.js +81 -0
  231. package/dist/tui/conversation-pane.js +82 -8
  232. package/dist/tui/cost-table.js +111 -0
  233. package/dist/tui/doctor-table.js +46 -0
  234. package/dist/tui/feedback-prompt.js +156 -0
  235. package/dist/tui/input-box.js +218 -3
  236. package/dist/tui/markdown-render.js +4 -4
  237. package/dist/tui/onboarding-wizard.js +240 -0
  238. package/dist/tui/permissions-picker.js +86 -0
  239. package/dist/tui/render.js +35 -0
  240. package/dist/tui/repl-render.js +313 -35
  241. package/dist/tui/repl-splash-art.js +1 -1
  242. package/dist/tui/repl-splash-mascot.js +32 -8
  243. package/dist/tui/repl-splash.js +2 -2
  244. package/dist/tui/repl.js +85 -5
  245. package/dist/tui/splash.js +1 -1
  246. package/dist/tui/status-bar.js +94 -16
  247. package/dist/tui/status-table.js +7 -0
  248. package/dist/tui/stickers-art.js +136 -0
  249. package/dist/tui/style-table.js +28 -0
  250. package/dist/tui/theme-table.js +29 -0
  251. package/dist/tui/thinking-spinner.js +123 -0
  252. package/dist/tui/tool-stream-pane.js +52 -3
  253. package/dist/tui/update-banner.js +27 -2
  254. package/dist/tui/vim-input.js +267 -0
  255. package/dist/tui/welcome-banner.js +107 -0
  256. package/dist/tui/welcome-data.js +293 -0
  257. package/docs/examples/codegraph.mcp.json +10 -0
  258. package/package.json +13 -7
  259. package/test/scenarios/codegen-create-file.scenario.txt +13 -0
  260. package/test/scenarios/compact-force.scenario.txt +11 -0
  261. package/test/scenarios/identity.scenario.txt +11 -0
  262. package/test/scenarios/persona-handoff.scenario.txt +11 -0
  263. package/test/scenarios/walkback.scenario.txt +12 -0
  264. package/dist/core/engine/compaction-hook.js +0 -154
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Fork-subagent cache-ref cleanup (Leak L10 — 2026-05-27).
3
+ *
4
+ * GC pass for `.pugi/cache-refs/`. Two surfaces use this:
5
+ *
6
+ * 1. `pugi dispatch clear-cache-refs --older-than <duration>` — operator
7
+ * runs this after a long session to evict stale handles. The flag
8
+ * accepts an `--older-than` window (e.g. `1h`, `24h`, `7d`); refs
9
+ * whose `createdAt` falls outside the window are removed.
10
+ *
11
+ * 2. Background sweep on REPL boot — when `cleanupStaleCacheRefs` is
12
+ * called on session start with a default 24h window, the dispatcher
13
+ * auto-prunes refs left over from crashed processes. Idempotent
14
+ * and silent (no logs unless `verbose: true`).
15
+ *
16
+ * Design notes:
17
+ *
18
+ * - Cleanup is path-scoped to `.pugi/cache-refs/` only. The function
19
+ * refuses to traverse outside that directory; a malicious symlink
20
+ * pointing at `~/.ssh/` would not cause damage because we only
21
+ * unlink files directly under cacheRefDir().
22
+ *
23
+ * - Cleanup is best-effort. A ref file with a corrupted JSON body
24
+ * has no `createdAt` to compare against — the policy is to treat
25
+ * such files as "stale" because they cannot be reused anyway.
26
+ * They get evicted alongside out-of-window refs.
27
+ *
28
+ * - The function returns a structured summary (counts + paths) so
29
+ * the CLI can render a deterministic report (`X refs removed,
30
+ * Y refs kept`).
31
+ */
32
+ import { readdirSync, readFileSync, statSync, rmSync } from 'node:fs';
33
+ import { join } from 'node:path';
34
+ import { cacheRefDir, cacheRefSchema } from './cache-handoff.js';
35
+ const DEFAULT_OLDER_THAN_MS = 24 * 60 * 60 * 1000;
36
+ /**
37
+ * Sweep `.pugi/cache-refs/` for stale refs.
38
+ *
39
+ * Returns a CleanupResult with three buckets:
40
+ * - `removed`: refs that were unlinked (either older than window
41
+ * OR corrupt + therefore unusable).
42
+ * - `kept`: refs that remain on disk after the sweep.
43
+ * - `corrupt`: refs that failed schema validation; these are also
44
+ * present in `removed` (the corrupt array is a sub-view for
45
+ * diagnostic surfaces).
46
+ *
47
+ * The function does not throw on individual file errors — it tracks
48
+ * the failure (silent skip for unreadable files) and continues so a
49
+ * single broken ref cannot block the rest of the sweep.
50
+ */
51
+ export function cleanupStaleCacheRefs(workspaceRoot, options = {}) {
52
+ const dir = cacheRefDir(workspaceRoot);
53
+ const now = options.now ?? Date.now;
54
+ const olderThanMs = options.olderThanMs ?? DEFAULT_OLDER_THAN_MS;
55
+ const verbose = options.verbose ?? false;
56
+ const cutoff = now() - olderThanMs;
57
+ let entries;
58
+ try {
59
+ entries = readdirSync(dir).filter((name) => name.endsWith('.json')).sort();
60
+ }
61
+ catch {
62
+ // No directory yet — nothing to clean. Return empty buckets so the
63
+ // caller's report says "0 refs removed" cleanly.
64
+ return {
65
+ removed: [],
66
+ kept: [],
67
+ corrupt: [],
68
+ removedCount: 0,
69
+ keptCount: 0,
70
+ corruptCount: 0,
71
+ };
72
+ }
73
+ const removed = [];
74
+ const kept = [];
75
+ const corrupt = [];
76
+ for (const name of entries) {
77
+ const full = join(dir, name);
78
+ let raw;
79
+ try {
80
+ raw = readFileSync(full, 'utf8');
81
+ }
82
+ catch {
83
+ // Unreadable — treat as corrupt and remove. mtime fallback path
84
+ // would be unreliable since we already failed to read the file.
85
+ removed.push(full);
86
+ corrupt.push(full);
87
+ tryUnlink(full, verbose);
88
+ continue;
89
+ }
90
+ let parsed;
91
+ try {
92
+ parsed = JSON.parse(raw);
93
+ }
94
+ catch {
95
+ removed.push(full);
96
+ corrupt.push(full);
97
+ tryUnlink(full, verbose);
98
+ continue;
99
+ }
100
+ const validated = cacheRefSchema.safeParse(parsed);
101
+ if (!validated.success) {
102
+ removed.push(full);
103
+ corrupt.push(full);
104
+ tryUnlink(full, verbose);
105
+ continue;
106
+ }
107
+ const createdAtMs = Date.parse(validated.data.createdAt);
108
+ // Date.parse returns NaN on invalid strings. Treat as stale.
109
+ const ageMs = Number.isFinite(createdAtMs)
110
+ ? createdAtMs
111
+ : tryStatMtime(full);
112
+ if (ageMs < cutoff) {
113
+ removed.push(full);
114
+ tryUnlink(full, verbose);
115
+ }
116
+ else {
117
+ kept.push(full);
118
+ }
119
+ }
120
+ return {
121
+ removed,
122
+ kept,
123
+ corrupt,
124
+ removedCount: removed.length,
125
+ keptCount: kept.length,
126
+ corruptCount: corrupt.length,
127
+ };
128
+ }
129
+ /* ------------------------------------------------------------------ */
130
+ /* Duration parsing */
131
+ /* ------------------------------------------------------------------ */
132
+ /**
133
+ * Parse a human-readable duration string (e.g. `1h`, `30m`, `7d`) into
134
+ * milliseconds. Used by the `--older-than` CLI flag handler. Returns
135
+ * null on parse failure so the CLI can surface a usage error.
136
+ *
137
+ * Accepted suffixes:
138
+ * - `ms` — milliseconds
139
+ * - `s` — seconds
140
+ * - `m` — minutes
141
+ * - `h` — hours
142
+ * - `d` — days
143
+ *
144
+ * A bare number with no suffix is rejected (the CLI requires explicit
145
+ * units to avoid the "is 60 seconds or 60 minutes?" trap).
146
+ */
147
+ export function parseDuration(input) {
148
+ const trimmed = input.trim().toLowerCase();
149
+ if (!trimmed)
150
+ return null;
151
+ const match = trimmed.match(/^([0-9]+(?:\.[0-9]+)?)(ms|s|m|h|d)$/);
152
+ if (!match)
153
+ return null;
154
+ const value = Number(match[1]);
155
+ if (!Number.isFinite(value) || value < 0)
156
+ return null;
157
+ switch (match[2]) {
158
+ case 'ms':
159
+ return value;
160
+ case 's':
161
+ return value * 1000;
162
+ case 'm':
163
+ return value * 60 * 1000;
164
+ case 'h':
165
+ return value * 60 * 60 * 1000;
166
+ case 'd':
167
+ return value * 24 * 60 * 60 * 1000;
168
+ default:
169
+ return null;
170
+ }
171
+ }
172
+ /* ------------------------------------------------------------------ */
173
+ /* Internals */
174
+ /* ------------------------------------------------------------------ */
175
+ function tryUnlink(path, verbose) {
176
+ try {
177
+ rmSync(path, { force: false });
178
+ if (verbose) {
179
+ process.stderr.write(`pugi-cache-cleanup: removed ${path}\n`);
180
+ }
181
+ }
182
+ catch {
183
+ // Concurrent cleanup raced us. Treat as success — the file is
184
+ // gone, which is what we wanted.
185
+ }
186
+ }
187
+ function tryStatMtime(path) {
188
+ try {
189
+ return statSync(path).mtimeMs;
190
+ }
191
+ catch {
192
+ // If we can't stat either, treat the ref as ancient so it gets
193
+ // evicted in the same pass.
194
+ return 0;
195
+ }
196
+ }
197
+ //# sourceMappingURL=cache-cleanup.js.map
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Fork-subagent prompt-cache inheritance (Leak L10 — 2026-05-27).
3
+ *
4
+ * Claude Code's leaked sub-agent spawn pattern: when a parent agent
5
+ * dispatches a child via the Task tool, the child boots with the parent's
6
+ * prompt cache REFERENCE inherited — not the conversation transcript
7
+ * verbatim, but a provider-native cache handle that lets the child reuse
8
+ * the parent's system prompt + cached tool definitions without re-paying
9
+ * the prompt-prefix tokens on the first turn.
10
+ *
11
+ * Anthropic's `cache_control` block in their messages API exposes this
12
+ * directly via cache breakpoints. OpenAI / xAI / Gemini have similar
13
+ * primitives at different granularities; the wire payload our Anvil
14
+ * proxy speaks is provider-agnostic, so we forward a generic
15
+ * `parent_cache_id` hint and Anvil routes it onto the underlying
16
+ * provider's cache primitive (or silently drops it if the provider
17
+ * doesn't support cache inheritance — graceful degrade).
18
+ *
19
+ * What this module does:
20
+ *
21
+ * 1. `inheritCacheContext(parentSessionId, childAgentId)` —
22
+ * synthesises a cache handle for a child dispatch. Persists the
23
+ * handle to `.pugi/cache-refs/<child-agent-id>.json` so:
24
+ *
25
+ * a. The child's own boot path can read it (e.g. a child engine
26
+ * loop running in a worktree subshell where the in-memory
27
+ * DispatcherContext isn't reachable).
28
+ * b. `pugi dispatch list-cache-refs` can surface active refs
29
+ * for debugging "why is my cache hit rate so low".
30
+ * c. `pugi dispatch clear-cache-refs --older-than 1h` can
31
+ * garbage-collect stale refs from crashed/killed children.
32
+ *
33
+ * 2. `readCacheRef(workspaceRoot, childAgentId)` — child-side read.
34
+ * Returns the persisted handle so the child's first engine loop
35
+ * turn can include the parent_cache_id hint in its request.
36
+ *
37
+ * 3. `cacheHandoffHookForRequest(handle)` — produces the wire payload
38
+ * shape that the Anvil bridge can splice into the
39
+ * `engineLoopServerRequest` body. Lives here (not in the bridge)
40
+ * so the cache-control schema is in one place.
41
+ *
42
+ * What this module does NOT do:
43
+ *
44
+ * - It does not replay the parent's conversation transcript into the
45
+ * child. That would defeat the cyber-zoo isolation contract
46
+ * (`shared_fs_readonly` etc.). The child gets a CACHE HINT — the
47
+ * provider may use it to skip re-tokenising shared prompt prefix,
48
+ * but the LOGICAL conversation always starts fresh from the child's
49
+ * own system prompt + brief.
50
+ *
51
+ * - It does not assume any provider honours the hint. Cache-miss is
52
+ * the default path. If Anvil routes the request to a model whose
53
+ * provider doesn't expose cache inheritance, the request still
54
+ * succeeds — just at full prompt-prefix cost.
55
+ *
56
+ * - It does not rotate or invalidate the parent's cache on the
57
+ * parent's side. Parent cache lifecycle is owned by the parent
58
+ * engine loop; the child holds a read-only reference.
59
+ *
60
+ * Cross-reference:
61
+ * - apps/pugi-cli/src/core/subagents/dispatcher-real.ts — where the
62
+ * child engine loop is driven; the cache_id from this module is
63
+ * forwarded onto Anvil via the `extensions` field of the engine
64
+ * loop server request (β2 forward-compat slot).
65
+ * - packages/pugi-sdk/src/engine-loop.ts — engineLoopServerRequest
66
+ * schema; cache-handoff field is OPTIONAL and additive.
67
+ *
68
+ * Leak research §L10 (Claude Code sub-agent spawn fork pattern):
69
+ * docs/research/2026-05-27-leak-parity-sprint.md (research memo).
70
+ */
71
+ import { mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
72
+ import { join, resolve as resolvePath } from 'node:path';
73
+ import { randomUUID } from 'node:crypto';
74
+ import { z } from 'zod';
75
+ /* ------------------------------------------------------------------ */
76
+ /* Types + schema */
77
+ /* ------------------------------------------------------------------ */
78
+ /**
79
+ * Persisted cache reference. The shape is intentionally minimal —
80
+ * provider-specific cache tokens are opaque strings so the file format
81
+ * does not couple to any one provider's API.
82
+ *
83
+ * - `cacheId` — opaque provider hint forwarded to Anvil.
84
+ * Synthesized client-side as `pugi-cache-<uuid>`
85
+ * so the server has a stable correlation key
86
+ * even when the underlying provider's cache
87
+ * object is not yet provisioned.
88
+ * - `contextRef` — logical reference key the parent uses to tag
89
+ * which prompt segments to re-use. Same value
90
+ * as cacheId for the v1 wire (single breakpoint
91
+ * per parent); reserved for future multi-breakpoint
92
+ * schemes (split system + tools + first-user
93
+ * segments).
94
+ * - `parentSessionId` — debugging / GC scoping. `list-cache-refs`
95
+ * can group by parent session.
96
+ * - `childAgentId` — the dispatch this ref is scoped to.
97
+ * - `createdAt` — ISO timestamp for `--older-than` cleanup.
98
+ * - `schemaVersion` — pinned to 1; bumped on breaking shape change.
99
+ */
100
+ export const cacheRefSchema = z.object({
101
+ schemaVersion: z.literal(1),
102
+ cacheId: z.string().min(1).max(256),
103
+ contextRef: z.string().min(1).max(256),
104
+ parentSessionId: z.string().min(1).max(256),
105
+ childAgentId: z.string().min(1).max(256),
106
+ createdAt: z.string().min(1),
107
+ });
108
+ /* ------------------------------------------------------------------ */
109
+ /* Path resolution */
110
+ /* ------------------------------------------------------------------ */
111
+ /**
112
+ * Resolve the directory under `.pugi/` where cache refs live. The
113
+ * directory is created on first write — readers tolerate its absence
114
+ * (no refs = empty list, not an error).
115
+ */
116
+ export function cacheRefDir(workspaceRoot) {
117
+ return resolvePath(workspaceRoot, '.pugi', 'cache-refs');
118
+ }
119
+ function cacheRefPath(workspaceRoot, childAgentId) {
120
+ // Sanitise the child id — directory traversal via crafted child id
121
+ // would let a dispatch target write outside the cache-refs dir. The
122
+ // child id format is owned by spawn.ts (`subagent-<uuid>`) but we
123
+ // defend in depth.
124
+ const safe = childAgentId.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 200);
125
+ return join(cacheRefDir(workspaceRoot), `${safe}.json`);
126
+ }
127
+ /**
128
+ * Synthesize a cache handle for a child dispatch and persist it to
129
+ * `.pugi/cache-refs/<childAgentId>.json`. Returns the in-memory handle
130
+ * the dispatcher feeds into the Anvil wire request.
131
+ *
132
+ * Idempotent: calling twice with the same `childAgentId` overwrites
133
+ * the existing ref. The dispatcher's `taskId` is a UUID per dispatch,
134
+ * so collisions are not expected, but the overwrite semantic is safer
135
+ * than failing — a stale ref from a previous run (e.g. process killed
136
+ * between spawn and run) should not block a fresh dispatch.
137
+ */
138
+ export function inheritCacheContext(parentSessionId, childAgentId, options) {
139
+ if (!parentSessionId) {
140
+ throw new Error('inheritCacheContext: parentSessionId must be non-empty');
141
+ }
142
+ if (!childAgentId) {
143
+ throw new Error('inheritCacheContext: childAgentId must be non-empty');
144
+ }
145
+ const now = options.now ?? defaultNow;
146
+ const cacheIdFactory = options.cacheIdFactory ?? defaultCacheId;
147
+ const cacheId = cacheIdFactory();
148
+ const ref = {
149
+ schemaVersion: 1,
150
+ cacheId,
151
+ contextRef: cacheId,
152
+ parentSessionId,
153
+ childAgentId,
154
+ createdAt: now(),
155
+ };
156
+ const dir = cacheRefDir(options.workspaceRoot);
157
+ mkdirSync(dir, { recursive: true });
158
+ const path = cacheRefPath(options.workspaceRoot, childAgentId);
159
+ writeFileSync(path, JSON.stringify(ref, null, 2), 'utf8');
160
+ return {
161
+ cacheId: ref.cacheId,
162
+ contextRef: ref.contextRef,
163
+ persistedPath: path,
164
+ };
165
+ }
166
+ /**
167
+ * Child-side read. Returns null when the ref is missing or malformed
168
+ * (the child boots without cache inheritance — degrade is silent so a
169
+ * corrupted ref does not block a dispatch).
170
+ *
171
+ * Malformed ref files are NOT auto-deleted by this read path — that's
172
+ * the cleanup command's job. A failed parse here just returns null so
173
+ * the dispatch proceeds at full prompt-prefix cost.
174
+ */
175
+ export function readCacheRef(workspaceRoot, childAgentId) {
176
+ const path = cacheRefPath(workspaceRoot, childAgentId);
177
+ let raw;
178
+ try {
179
+ raw = readFileSync(path, 'utf8');
180
+ }
181
+ catch {
182
+ return null;
183
+ }
184
+ let parsed;
185
+ try {
186
+ parsed = JSON.parse(raw);
187
+ }
188
+ catch {
189
+ return null;
190
+ }
191
+ const validated = cacheRefSchema.safeParse(parsed);
192
+ if (!validated.success)
193
+ return null;
194
+ return validated.data;
195
+ }
196
+ /**
197
+ * List every persisted cache ref under `.pugi/cache-refs/`. Used by
198
+ * `pugi dispatch list-cache-refs` and by GC sweeps in
199
+ * `cache-cleanup.ts`. Returns refs in deterministic
200
+ * (filename-ascending) order so the CLI output is stable.
201
+ *
202
+ * Malformed ref files are silently skipped — the cleanup command can
203
+ * surface them via a separate `--show-corrupt` flag if needed.
204
+ */
205
+ export function listCacheRefs(workspaceRoot) {
206
+ const dir = cacheRefDir(workspaceRoot);
207
+ let entries;
208
+ try {
209
+ entries = readdirSync(dir).filter((name) => name.endsWith('.json')).sort();
210
+ }
211
+ catch {
212
+ return [];
213
+ }
214
+ const refs = [];
215
+ for (const entry of entries) {
216
+ const full = join(dir, entry);
217
+ let raw;
218
+ try {
219
+ raw = readFileSync(full, 'utf8');
220
+ }
221
+ catch {
222
+ continue;
223
+ }
224
+ let parsed;
225
+ try {
226
+ parsed = JSON.parse(raw);
227
+ }
228
+ catch {
229
+ continue;
230
+ }
231
+ const validated = cacheRefSchema.safeParse(parsed);
232
+ if (!validated.success)
233
+ continue;
234
+ refs.push(validated.data);
235
+ }
236
+ return refs;
237
+ }
238
+ /* ------------------------------------------------------------------ */
239
+ /* Wire-format hook */
240
+ /* ------------------------------------------------------------------ */
241
+ /**
242
+ * Project the in-memory handle (or persisted ref) onto the wire-format
243
+ * hint object that the Anvil bridge merges into the engine-loop
244
+ * request body. Pulled out so callers (dispatcher-real, future bridge
245
+ * adapters) all speak the same shape.
246
+ */
247
+ export function cacheHandoffHookForRequest(source) {
248
+ return {
249
+ parent_cache_id: source.cacheId,
250
+ cache_context_ref: source.contextRef,
251
+ };
252
+ }
253
+ /* ------------------------------------------------------------------ */
254
+ /* Internals */
255
+ /* ------------------------------------------------------------------ */
256
+ function defaultNow() {
257
+ return new Date().toISOString();
258
+ }
259
+ function defaultCacheId() {
260
+ return `pugi-cache-${randomUUID()}`;
261
+ }
262
+ /* ------------------------------------------------------------------ */
263
+ /* Stat helper (used by cache-cleanup.ts) */
264
+ /* ------------------------------------------------------------------ */
265
+ /**
266
+ * Exposed for cache-cleanup.ts so the GC sweep can read mtime without
267
+ * re-implementing the path-resolution logic. Returns null when the
268
+ * file is gone (a concurrent cleanup may have raced us — silent miss).
269
+ */
270
+ export function cacheRefMtime(workspaceRoot, childAgentId) {
271
+ try {
272
+ const stat = statSync(cacheRefPath(workspaceRoot, childAgentId));
273
+ return stat.mtime;
274
+ }
275
+ catch {
276
+ return null;
277
+ }
278
+ }
279
+ /**
280
+ * Delete a single cache ref. Returns true when the file existed and
281
+ * was removed, false when it was already gone. Used by both the
282
+ * `clear-cache-refs` CLI and by post-dispatch cleanup (a successful
283
+ * subagent run does not need the cache ref to outlive its dispatch).
284
+ */
285
+ export function deleteCacheRef(workspaceRoot, childAgentId) {
286
+ const path = cacheRefPath(workspaceRoot, childAgentId);
287
+ try {
288
+ rmSync(path, { force: false });
289
+ return true;
290
+ }
291
+ catch {
292
+ return false;
293
+ }
294
+ }
295
+ //# sourceMappingURL=cache-handoff.js.map