gentle-pi 4.2.0 → 4.3.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.
Files changed (202) hide show
  1. package/README.md +336 -137
  2. package/assets/agents/gentle-ai-explore.md +1 -0
  3. package/assets/agents/gentle-ai-verify.md +6 -4
  4. package/assets/agents/gentle-ai-worker.md +7 -1
  5. package/assets/orchestrator-delegation-reference.md +163 -0
  6. package/assets/orchestrator-delegation.md +25 -176
  7. package/assets/orchestrator-tracking.md +1 -1
  8. package/assets/orchestrator-verification.md +14 -8
  9. package/assets/orchestrator-writer.md +5 -1
  10. package/assets/orchestrator.md +9 -9
  11. package/bin/gentle-shell.mjs +170 -22
  12. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  13. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
  14. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
  15. package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
  16. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
  17. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
  18. package/docs/assets/diagrams/agent-orchestration.svg +54 -15
  19. package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
  20. package/docs/assets/diagrams/measured-routing.svg +49 -0
  21. package/docs/assets/diagrams/native-review.svg +52 -12
  22. package/docs/assets/diagrams/odd-workflow.svg +128 -66
  23. package/docs/assets/diagrams/permission-modes.svg +51 -0
  24. package/docs/assets/features/audio-notifications.png +0 -0
  25. package/docs/assets/features/command-palette.png +0 -0
  26. package/docs/bundled-install.md +33 -3
  27. package/docs/delegated-verification.md +10 -8
  28. package/docs/gentle-agents-activity.md +11 -5
  29. package/docs/gentle-shell-uninstall.md +8 -1
  30. package/docs/gentle-shell.md +5 -4
  31. package/docs/install-wizard.md +8 -1
  32. package/docs/permission-modes.md +1 -1
  33. package/docs/readme-reference.md +107 -19
  34. package/docs/yolo-mode.md +21 -8
  35. package/extensions/codegraph-tools.ts +68 -6
  36. package/extensions/gentle-agents.ts +99 -12
  37. package/extensions/gentle-ai.ts +284 -52
  38. package/extensions/gentle-herdr-activity.ts +2 -3
  39. package/extensions/gentle-shell.ts +27 -12
  40. package/extensions/gentle-todo.ts +43 -9
  41. package/extensions/quiet-tools.ts +1 -1
  42. package/extensions/session-scratch.ts +365 -0
  43. package/extensions/startup-banner.ts +2 -3
  44. package/lib/agent-home.ts +8 -3
  45. package/lib/agents-protocol.ts +3 -0
  46. package/lib/agents-runner.ts +55 -6
  47. package/lib/agents-view.ts +3 -1
  48. package/lib/bounded-writer-admission.ts +51 -0
  49. package/lib/command-palette-catalog.ts +0 -4
  50. package/lib/commit-slice-signal.ts +100 -0
  51. package/lib/context-backstop.ts +84 -0
  52. package/lib/delivered-test-lint.ts +80 -0
  53. package/lib/destructive-command-guard.ts +120 -9
  54. package/lib/gauge-theme.ts +146 -0
  55. package/lib/gentle-ai-binary.ts +28 -7
  56. package/lib/gentle-shell-bundled.ts +39 -3
  57. package/lib/gentle-shell-import-pi.ts +233 -0
  58. package/lib/gentle-shell-launcher.ts +145 -11
  59. package/lib/gentle-shell-uninstall.ts +18 -1
  60. package/lib/herdr-activity.ts +24 -0
  61. package/lib/native-review-cli.ts +9 -1
  62. package/lib/orchestrator-discovery.ts +5 -1
  63. package/lib/orchestrator-presence.ts +9 -0
  64. package/lib/orchestrator-scope.ts +21 -3
  65. package/lib/provider-contract-bundle.ts +99 -53
  66. package/lib/review-candidate-view-owner.ts +29 -19
  67. package/lib/review-candidate-view.ts +36 -2
  68. package/lib/review-risk-assessment.ts +43 -38
  69. package/lib/review-session-standing-permission.ts +3 -1
  70. package/lib/session-profile-freeze.ts +30 -1
  71. package/lib/session-scratch.ts +474 -0
  72. package/lib/shell-bar.ts +25 -25
  73. package/lib/shell-changes-view.ts +80 -13
  74. package/lib/shell-gauge.ts +49 -6
  75. package/lib/shell-todo.ts +23 -4
  76. package/lib/shell-usage-view.ts +5 -1
  77. package/lib/shell-usage.ts +7 -7
  78. package/lib/verifier-admission.ts +29 -0
  79. package/lib/yolo-temp-scratch.ts +69 -0
  80. package/package.json +1 -2
  81. package/runtime/gentle-ai-binary.mjs +28 -7
  82. package/runtime/gentle-shell-bundled.mjs +38 -2
  83. package/runtime/gentle-shell-import-pi.mjs +234 -0
  84. package/runtime/gentle-shell-launcher.mjs +143 -9
  85. package/runtime/gentle-shell-uninstall.mjs +18 -1
  86. package/runtime/native-review-cli.mjs +8 -0
  87. package/runtime/review-risk-assessment.mjs +43 -38
  88. package/scripts/build-runtime-modules.mjs +1 -0
  89. package/scripts/bundled-engram-migration.mjs +125 -0
  90. package/scripts/gentle-ai-installer.mjs +10 -10
  91. package/scripts/installer-server.mjs +14 -4
  92. package/scripts/main-channel.mjs +3 -2
  93. package/scripts/run-test-suite.mjs +5 -0
  94. package/scripts/verify-package-files.mjs +19 -13
  95. package/skills/gentle-ai/SKILL.md +3 -3
  96. package/skills/work-unit-commits/SKILL.md +5 -3
  97. package/tests/agent-risk-escalation.test.ts +37 -10
  98. package/tests/agents-runner.test.ts +260 -3
  99. package/tests/autonomous-guard.test.ts +122 -0
  100. package/tests/bounded-writer-admission.test.ts +11 -3
  101. package/tests/card-content-policy.test.ts +19 -9
  102. package/tests/child-safety.test.ts +39 -0
  103. package/tests/codegraph-tools.test.ts +123 -2
  104. package/tests/command-palette.test.ts +14 -4
  105. package/tests/commit-slice-signal.test.ts +162 -0
  106. package/tests/context-backstop.test.ts +159 -0
  107. package/tests/delegate-for-reason-contract.test.ts +91 -24
  108. package/tests/delivered-test-lint.test.ts +105 -0
  109. package/tests/destructive-command-guard.test.ts +85 -1
  110. package/tests/engram-safety.test.ts +1 -1
  111. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
  112. package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
  113. package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
  114. package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
  115. package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
  116. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
  117. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
  118. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
  119. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
  120. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
  121. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
  122. package/tests/gauge-theme.test.ts +120 -0
  123. package/tests/gentle-agents.test.ts +282 -3
  124. package/tests/gentle-ai-binary.test.ts +1 -1
  125. package/tests/gentle-ai-child-guards.test.ts +61 -0
  126. package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
  127. package/tests/gentle-ai-installer.test.ts +51 -51
  128. package/tests/gentle-shell-bin.test.ts +163 -0
  129. package/tests/gentle-shell-bundled.test.ts +169 -6
  130. package/tests/gentle-shell-import-pi.test.ts +262 -0
  131. package/tests/gentle-shell-launcher.test.ts +79 -0
  132. package/tests/gentle-shell-uninstall.test.ts +30 -0
  133. package/tests/gentle-shell.test.ts +42 -9
  134. package/tests/gentle-todo.test.ts +179 -0
  135. package/tests/headless-delivery-question.test.ts +58 -0
  136. package/tests/herdr-activity.test.ts +34 -1
  137. package/tests/installer-server.test.ts +10 -2
  138. package/tests/installer-windows-bootstrap.test.ts +33 -4
  139. package/tests/native-review-capability-contract.test.ts +7 -1
  140. package/tests/native-review-parity.test.ts +22 -1
  141. package/tests/no-self-pause-contract.test.ts +5 -2
  142. package/tests/odd-integration.test.ts +5 -1
  143. package/tests/odd-routing-canonical-ratchet.test.ts +127 -162
  144. package/tests/odd-routing-contract.test.ts +115 -30
  145. package/tests/orchestrator-budget.test.ts +19 -8
  146. package/tests/orchestrator-discovery.test.ts +29 -0
  147. package/tests/orchestrator-rdd-ownership.test.ts +3 -1
  148. package/tests/orchestrator-scope.test.ts +66 -0
  149. package/tests/orchestrator-status-publication-contract.test.ts +12 -8
  150. package/tests/package-manifest.test.ts +24 -20
  151. package/tests/pi-update-launcher.test.ts +21 -2
  152. package/tests/primary-session-prompt.test.ts +101 -0
  153. package/tests/provider-contract-bundle.test.ts +118 -0
  154. package/tests/provider-contract-mirror.test.ts +24 -0
  155. package/tests/quiet-tool-rendering.test.ts +23 -0
  156. package/tests/rdd-aware-verification-contract.test.ts +29 -11
  157. package/tests/rdd-status-line.test.ts +79 -0
  158. package/tests/review-agent-end-preflight.test.ts +6 -0
  159. package/tests/review-candidate-view.test.ts +49 -0
  160. package/tests/review-contract-prompt.test.ts +5 -2
  161. package/tests/review-controller-native-routing.test.ts +14 -1
  162. package/tests/review-controller.test.ts +1 -1
  163. package/tests/review-host-relay-routing.test.ts +59 -2
  164. package/tests/review-loadout-rdd-off-sdk.test.ts +237 -0
  165. package/tests/review-owner-identity.test.ts +132 -0
  166. package/tests/review-risk-assessment.test.ts +163 -76
  167. package/tests/review-session-standing-permission-controller.test.ts +14 -1
  168. package/tests/review-session-standing-permission.test.ts +27 -1
  169. package/tests/run-test-suite.test.ts +15 -0
  170. package/tests/runtime-harness.mjs +16 -6
  171. package/tests/session-changes-shell.test.ts +1 -1
  172. package/tests/session-profile-freeze.test.ts +23 -0
  173. package/tests/session-scratch.test.ts +945 -0
  174. package/tests/session-subject-routing-contract.test.ts +2 -1
  175. package/tests/shell-bar.test.ts +7 -0
  176. package/tests/shell-changes-view.test.ts +137 -1
  177. package/tests/shell-gauge.test.ts +42 -0
  178. package/tests/small-task-inline-contract.test.ts +37 -0
  179. package/tests/standalone-config-home.test.ts +324 -0
  180. package/tests/startup-banner.test.ts +5 -4
  181. package/tests/startup-home-repo.test.ts +34 -0
  182. package/tests/support/orchestrator-modules.ts +1 -0
  183. package/tests/task-size-routing-contract.test.ts +69 -9
  184. package/tests/test-scratch-contract.test.ts +102 -0
  185. package/tests/test-strength-contract.test.ts +36 -1
  186. package/tests/tool-batching-contract.test.ts +64 -0
  187. package/tests/verification-proportionality.test.ts +46 -0
  188. package/tests/verifier-admission.test.ts +137 -0
  189. package/tests/verify-agent-contract.test.ts +8 -8
  190. package/tests/windows-hidden-processes.test.ts +5 -0
  191. package/tests/writer-edit-surface-scope.test.ts +3 -0
  192. package/tests/writer-feature-document-admission.test.ts +156 -0
  193. package/tests/yolo-mode.test.ts +18 -0
  194. package/tests/yolo-temp-scratch.test.ts +71 -0
  195. package/scripts/mirror-odd-routing.mjs +0 -242
  196. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
  197. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
  198. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
  199. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  200. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
  201. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
  202. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -0,0 +1,474 @@
1
+ import { execFile, execFileSync } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { basename, dirname, isAbsolute, join, parse, resolve, sep } from "node:path";
6
+ import { directoryAfterSegment, SHELL_SEGMENT_SEPARATOR } from "./commit-slice-signal.ts";
7
+
8
+ // Session scratch and artifact ledger (odd/tasks/session-scratch-ledger.md).
9
+ // The runtime owns the parent session's TMPDIR and records the clones and
10
+ // worktrees the session creates, so cleanup reads a list instead of the model
11
+ // re-deducing what is garbage. Removal is limited to what the ledger recorded,
12
+ // and every check refuses rather than guesses.
13
+
14
+ export const LEDGER_VERSION = 1;
15
+ // Short on purpose (S9): TMPDIR points at `gpXXXXXX` and a subagent's TMPDIR at
16
+ // `gaXXXXXX` under it, and Unix socket paths below them are capped near 104 bytes.
17
+ export const SCRATCH_PREFIX = "gp";
18
+ /** Subagent TMPDIRs (lib/agents-runner.ts) nest under the session scratch; their runner removes them. */
19
+ export const SUBAGENT_SCRATCH_PREFIX = "ga";
20
+ // mkdtemp appends six alphanumeric characters to the prefix.
21
+ const SCRATCH_NAME = new RegExp(`^${SCRATCH_PREFIX}[A-Za-z0-9]{6}$`);
22
+ const SUBAGENT_SCRATCH_NAME = new RegExp(`^${SUBAGENT_SCRATCH_PREFIX}[A-Za-z0-9]{6}$`);
23
+ /** Whether a path's last component is named as the runtime names a session scratch. */
24
+ export const isSessionScratchName = (path: string): boolean => SCRATCH_NAME.test(basename(path));
25
+ export const SWEEP_GRACE_MS = 60 * 60 * 1000;
26
+ /** `/gentle:scratch clean` keeps the live scratch's entries changed this long before it started. */
27
+ export const CLEAN_RECENT_MS = 60_000;
28
+
29
+ // The TMPDIRs of this process's live and quarantined subagents. On globalThis
30
+ // because Pi loads every extension as its own module instance, again on /reload,
31
+ // and a runner of an earlier instance may still hold a quarantined child.
32
+ const LIVE_SUBAGENT_SCRATCH = Symbol.for("gentle-pi.live-subagent-scratch");
33
+ const liveSubagentRegistry = (): Set<string> => {
34
+ const holder = globalThis as { [LIVE_SUBAGENT_SCRATCH]?: Set<string> };
35
+ return (holder[LIVE_SUBAGENT_SCRATCH] ??= new Set());
36
+ };
37
+ /** The runner holds a subagent TMPDIR from its creation until it removes it. */
38
+ export const holdSubagentScratch = (dir: string): void => { liveSubagentRegistry().add(dir); };
39
+ export const releaseSubagentScratch = (dir: string): void => { liveSubagentRegistry().delete(dir); };
40
+ export const liveSubagentScratch = (): string[] => [...liveSubagentRegistry()];
41
+
42
+ export type ArtifactKind = "clone" | "worktree";
43
+
44
+ export interface ScratchArtifact {
45
+ kind: ArtifactKind;
46
+ path: string;
47
+ command: string;
48
+ recordedAt: string;
49
+ }
50
+
51
+ export interface ScratchLedger {
52
+ version: number;
53
+ sessionId: string;
54
+ pid: number;
55
+ processStartedAt: string;
56
+ /** The OS's opaque start token of the owning process (`processStartToken`); absent when it could not be read. */
57
+ processStartToken?: string;
58
+ cwd: string;
59
+ /** The real temp root the scratch was created directly under (the inherited TMPDIR or os.tmpdir()). */
60
+ tempRoot: string | null;
61
+ /** The temp variables before the session changed them (null: unset), restored at shutdown. */
62
+ inherited: Record<string, string | null>;
63
+ scratchDir: string | null;
64
+ artifacts: ScratchArtifact[];
65
+ }
66
+
67
+ export interface StoredLedger { path: string; ledger: ScratchLedger; mtimeMs: number }
68
+
69
+ /** The session id as a file and directory name component. */
70
+ export function safeSessionId(sessionId: string): string {
71
+ return sessionId.replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "session";
72
+ }
73
+
74
+ export function ledgerDirectory(configHome: string): string {
75
+ return join(configHome, "scratch");
76
+ }
77
+
78
+ export function ledgerPath(configHome: string, sessionId: string): string {
79
+ return join(ledgerDirectory(configHome), `${safeSessionId(sessionId)}.json`);
80
+ }
81
+
82
+ function isArtifact(value: unknown): value is ScratchArtifact {
83
+ const artifact = value as Partial<ScratchArtifact> | null;
84
+ return typeof artifact === "object" && artifact !== null && (artifact.kind === "clone" || artifact.kind === "worktree") &&
85
+ typeof artifact.path === "string" && isAbsolute(artifact.path) && typeof artifact.command === "string" && typeof artifact.recordedAt === "string";
86
+ }
87
+
88
+ function isLedger(value: unknown): value is ScratchLedger {
89
+ const ledger = value as Partial<ScratchLedger> | null;
90
+ return typeof ledger === "object" && ledger !== null && ledger.version === LEDGER_VERSION && typeof ledger.sessionId === "string" &&
91
+ typeof ledger.pid === "number" && typeof ledger.processStartedAt === "string" &&
92
+ (ledger.processStartToken === undefined || typeof ledger.processStartToken === "string") && typeof ledger.cwd === "string" &&
93
+ (ledger.tempRoot === null || typeof ledger.tempRoot === "string") && typeof ledger.inherited === "object" && ledger.inherited !== null &&
94
+ Object.values(ledger.inherited).every((value) => value === null || typeof value === "string") &&
95
+ (ledger.scratchDir === null || typeof ledger.scratchDir === "string") && Array.isArray(ledger.artifacts) && ledger.artifacts.every(isArtifact);
96
+ }
97
+
98
+ /** A valid ledger at `path`, or undefined for a missing, unreadable, or foreign file. */
99
+ export function readLedger(path: string): ScratchLedger | undefined {
100
+ try {
101
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
102
+ return isLedger(parsed) ? parsed : undefined;
103
+ } catch {
104
+ return undefined;
105
+ }
106
+ }
107
+
108
+ /** Atomic and private: a temp file in the same directory, then a rename. */
109
+ export function writeLedger(path: string, ledger: ScratchLedger): void {
110
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
111
+ const temp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
112
+ try {
113
+ writeFileSync(temp, `${JSON.stringify(ledger, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
114
+ renameSync(temp, path);
115
+ } catch (error) {
116
+ try { unlinkSync(temp); } catch { /* already renamed or never created */ }
117
+ throw error;
118
+ }
119
+ }
120
+
121
+ export function deleteLedger(path: string): void {
122
+ try { unlinkSync(path); } catch { /* already gone */ }
123
+ }
124
+
125
+ /** Every valid ledger in the config home; other files are left alone. */
126
+ export function listLedgers(configHome: string): StoredLedger[] {
127
+ const dir = ledgerDirectory(configHome);
128
+ let names: string[];
129
+ try { names = readdirSync(dir); } catch { return []; }
130
+ const result: StoredLedger[] = [];
131
+ for (const name of names) {
132
+ if (!name.endsWith(".json")) continue;
133
+ const path = join(dir, name);
134
+ const ledger = readLedger(path);
135
+ if (!ledger) continue;
136
+ try { result.push({ path, ledger, mtimeMs: statSync(path).mtimeMs }); } catch { /* removed meanwhile */ }
137
+ }
138
+ return result;
139
+ }
140
+
141
+ /** `kill(pid, 0)`: EPERM still means a live process owned by someone else. */
142
+ export function isProcessAlive(pid: number): boolean {
143
+ if (!Number.isInteger(pid) || pid <= 0) return false;
144
+ try {
145
+ process.kill(pid, 0);
146
+ return true;
147
+ } catch (error) {
148
+ return (error as NodeJS.ErrnoException).code === "EPERM";
149
+ }
150
+ }
151
+
152
+ /**
153
+ * An opaque token for when the process `pid` started, as the OS reports it, or
154
+ * undefined when it cannot be read: always on Windows, where only pid liveness
155
+ * is checked. Tokens are compared for equality only, never converted to wall
156
+ * clock time, so a clock step cannot make a live process look like another one.
157
+ */
158
+ export function processStartToken(pid: number, platform: NodeJS.Platform = process.platform): string | undefined {
159
+ if (!Number.isInteger(pid) || pid <= 0) return undefined;
160
+ try {
161
+ if (platform === "linux") {
162
+ // Field 22 counts clock ticks since boot; the command name (field 2) may hold spaces and parentheses.
163
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
164
+ const ticks = stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19];
165
+ return ticks && /^\d+$/.test(ticks) ? `linux:${ticks}` : undefined;
166
+ }
167
+ if (platform === "win32") return undefined;
168
+ // macOS and the BSDs: the start time text as ps prints it, in a fixed locale.
169
+ const started = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", timeout: 2_000, env: { ...process.env, LC_ALL: "C", TZ: "UTC" }, stdio: ["ignore", "pipe", "ignore"] }).trim();
170
+ return started ? `ps:${started}` : undefined;
171
+ } catch {
172
+ return undefined;
173
+ }
174
+ }
175
+
176
+ /**
177
+ * A private `gpXXXXXX` under `base`, or undefined when it cannot be created.
178
+ * `path` joins `base` as given, for TMPDIR (a real path can be longer, as
179
+ * macOS `/private/var/...`); `realPath` is what the ledger and checks use.
180
+ */
181
+ export function createSessionScratch(base: string | undefined): { path: string; realPath: string } | undefined {
182
+ if (!base || !isAbsolute(base)) return undefined;
183
+ try {
184
+ const path = mkdtempSync(join(base, SCRATCH_PREFIX));
185
+ try { chmodSync(path, 0o700); } catch { /* best effort, as mkdtemp already creates 0700 on POSIX */ }
186
+ return { path, realPath: realpathSync(path) };
187
+ } catch {
188
+ return undefined;
189
+ }
190
+ }
191
+
192
+ export const isWithin = (path: string, dir: string) => path === dir || path.startsWith(dir.endsWith(sep) ? dir : `${dir}${sep}`);
193
+
194
+ /** The real path of a temp root, or undefined when it does not exist. */
195
+ export function realTempRoot(path: string | undefined): string | undefined {
196
+ if (!path || !isAbsolute(path)) return undefined;
197
+ try { return realpathSync(path); } catch { return undefined; }
198
+ }
199
+
200
+ /**
201
+ * Why a recorded scratch dir is not ours to remove, "absent" when it is gone,
202
+ * or undefined when it is. `ownRoot` is the temp root of the session asking:
203
+ * a ledger's temp root inside the home is trusted only when it is that root.
204
+ */
205
+ export function scratchRefusal(ledger: ScratchLedger, ownRoot: string | undefined): string | undefined {
206
+ const dir = ledger.scratchDir;
207
+ if (!dir || !isAbsolute(dir)) return "not recorded";
208
+ // Prefix and ledger match: only a directory named as the runtime names a session scratch.
209
+ if (!SCRATCH_NAME.test(basename(dir))) return "not a session scratch name";
210
+ // Created directly under the temp root recorded with it, which is a temp root.
211
+ const root = ledger.tempRoot;
212
+ if (!root || !isAbsolute(root) || dirname(dir) !== root) return "outside its temp root";
213
+ if (isWithin(root, resolve(homedir())) && root !== ownRoot) return "temp root inside the home";
214
+ let stat;
215
+ try { stat = lstatSync(dir); } catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT" ? "absent" : "unreadable"; }
216
+ if (stat.isSymbolicLink() || !stat.isDirectory()) return "not a directory";
217
+ // The ledger holds the real path made at creation; a swapped parent changes it.
218
+ try { if (realpathSync(dir) !== dir) return "moved behind a symlink"; } catch { return "unreadable"; }
219
+ const uid = process.getuid?.();
220
+ if (uid !== undefined && stat.uid !== uid) return "owned by another user";
221
+ return undefined;
222
+ }
223
+
224
+ export type ScratchRemoval = "removed" | "absent" | "kept" | "refused";
225
+
226
+ /** The latest mtime or ctime under `path`, without following symlinks; Infinity when it cannot be told. */
227
+ function latestChange(path: string): number {
228
+ let latest = -Infinity;
229
+ const stack = [path];
230
+ for (let visited = 0; stack.length > 0; visited++) {
231
+ if (visited >= 200_000) return Infinity;
232
+ const current = stack.pop()!;
233
+ let stat;
234
+ try { stat = lstatSync(current); } catch (error) {
235
+ if (current !== path && (error as NodeJS.ErrnoException).code === "ENOENT") continue;
236
+ return Infinity;
237
+ }
238
+ latest = Math.max(latest, stat.mtimeMs, stat.ctimeMs);
239
+ if (stat.isDirectory()) {
240
+ try { for (const name of readdirSync(current)) stack.push(join(current, name)); } catch { return Infinity; }
241
+ }
242
+ }
243
+ return latest;
244
+ }
245
+
246
+ export interface ScratchRemovalOptions {
247
+ /** Remove the entries and keep the directory, as for the live session's own scratch. */
248
+ contentsOnly?: boolean;
249
+ ownRoot: string | undefined;
250
+ /** Real paths of this process's live and quarantined subagent TMPDIRs: kept, and the rest of the scratch removed. */
251
+ liveSubagentDirs?: readonly string[];
252
+ /** With `contentsOnly`, an entry with anything changed at or after this time (epoch ms) is kept. */
253
+ keepChangedSince?: number;
254
+ }
255
+
256
+ /**
257
+ * Removes a ledger's scratch dir, or with `contentsOnly` its entries except
258
+ * subagent TMPDIRs. A dir or entry holding one of `heldPaths` is kept: a
259
+ * recorded artifact is removed only through its own checks, and the scratch dir
260
+ * of a live session only through that session's ledger. A scratch holding a
261
+ * live subagent TMPDIR loses its other entries only. Symlinks are never followed.
262
+ */
263
+ export function removeSessionScratch(ledger: ScratchLedger, heldPaths: readonly string[], options: ScratchRemovalOptions): ScratchRemoval {
264
+ const refusal = scratchRefusal(ledger, options.ownRoot);
265
+ if (refusal === "absent") return "absent";
266
+ if (refusal) return "refused";
267
+ const dir = ledger.scratchDir!;
268
+ const holdsArtifact = (path: string) => heldPaths.some((held) => isWithin(held, path));
269
+ const holdsSubagent = (path: string) => (options.liveSubagentDirs ?? []).some((held) => isWithin(held, path));
270
+ try {
271
+ if (!options.contentsOnly) {
272
+ if (holdsArtifact(dir)) return "kept";
273
+ if (!holdsSubagent(dir)) {
274
+ rmSync(dir, { recursive: true, force: true });
275
+ return "removed";
276
+ }
277
+ }
278
+ let kept = false;
279
+ for (const name of readdirSync(dir)) {
280
+ const path = join(dir, name);
281
+ const recent = options.contentsOnly && options.keepChangedSince !== undefined && latestChange(path) >= options.keepChangedSince;
282
+ if (SUBAGENT_SCRATCH_NAME.test(name) || holdsArtifact(path) || holdsSubagent(path) || recent) { kept = true; continue; }
283
+ rmSync(path, { recursive: true, force: true });
284
+ }
285
+ return kept ? "kept" : "removed";
286
+ } catch {
287
+ return "refused";
288
+ }
289
+ }
290
+
291
+ /** Total bytes of regular files under `dir`, without following symlinks. */
292
+ export function directorySize(dir: string): number {
293
+ let total = 0;
294
+ const stack = [dir];
295
+ for (let visited = 0; stack.length > 0 && visited < 200_000; visited++) {
296
+ const path = stack.pop()!;
297
+ try {
298
+ const stat = lstatSync(path);
299
+ if (stat.isDirectory()) for (const name of readdirSync(path)) stack.push(join(path, name));
300
+ else if (stat.isFile()) total += stat.size;
301
+ } catch { /* removed meanwhile */ }
302
+ }
303
+ return total;
304
+ }
305
+
306
+ export function formatSize(bytes: number): string {
307
+ if (bytes < 1024) return `${bytes} B`;
308
+ const units = ["KiB", "MiB", "GiB", "TiB"];
309
+ let value = bytes / 1024;
310
+ let unit = 0;
311
+ while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit++; }
312
+ return `${value.toFixed(1)} ${units[unit]}`;
313
+ }
314
+
315
+ // Command parsing (S4).
316
+
317
+ const CLONE_VALUE_OPTIONS = new Set(["-b", "--branch", "-o", "--origin", "-c", "--config", "--depth", "--reference", "--reference-if-able", "--separate-git-dir", "-u", "--upload-pack", "--template", "-j", "--jobs", "--filter", "--shallow-since", "--shallow-exclude", "--server-option", "--bundle-uri", "--revision"]);
318
+ const WORKTREE_ADD_VALUE_OPTIONS = new Set(["-b", "-B", "--reason"]);
319
+ // A pipe, background, or redirection token ends the git arguments.
320
+ const ARGUMENTS_END = /^(\||&|\d*>|\d*<|&>|<|>)/;
321
+
322
+ function tokens(segment: string): string[] {
323
+ return (segment.match(/"[^"]*"|'[^']*'|[^\s"']+/g) ?? []).map((token) => token.replace(/^(["'])(.*)\1$/s, "$2"));
324
+ }
325
+
326
+ function positionals(args: readonly string[], valueOptions: ReadonlySet<string>): string[] {
327
+ const result: string[] = [];
328
+ for (let i = 0; i < args.length; i++) {
329
+ const arg = args[i];
330
+ if (arg === "--") { result.push(...args.slice(i + 1)); break; }
331
+ if (arg.startsWith("-") && arg !== "-") { if (valueOptions.has(arg)) i++; continue; }
332
+ result.push(arg);
333
+ }
334
+ return result;
335
+ }
336
+
337
+ /** The directory `git clone <repo>` creates without a destination, as git guesses it. */
338
+ function defaultCloneDirectory(repository: string): string | undefined {
339
+ const trimmed = repository.replace(/[\\/]+$/, "").replace(/[\\/]\.git$/, "");
340
+ const name = trimmed.slice(Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf(":"), trimmed.lastIndexOf("\\")) + 1).replace(/\.(git|bundle)$/, "");
341
+ return name && name !== "." && name !== ".." ? name : undefined;
342
+ }
343
+
344
+ export interface ArtifactTarget { kind: ArtifactKind; path: string }
345
+
346
+ /** Clone and worktree destinations a shell command creates, resolved against its `cd` segments and `git -C`. */
347
+ export function gitArtifactTargets(command: string, cwd: string): ArtifactTarget[] {
348
+ const targets: ArtifactTarget[] = [];
349
+ let base = cwd;
350
+ for (const segment of command.split(SHELL_SEGMENT_SEPARATOR)) {
351
+ const words = tokens(segment);
352
+ let i = 0;
353
+ while (i < words.length && /^\w+=/.test(words[i])) i++;
354
+ if (words[i] === "git") {
355
+ let directory = base;
356
+ for (i++; i < words.length && words[i].startsWith("-"); i++) {
357
+ if (words[i] === "-C" && i + 1 < words.length) directory = resolve(directory, words[++i]);
358
+ else if (words[i] === "-c") i++;
359
+ }
360
+ const end = words.findIndex((word, index) => index >= i && ARGUMENTS_END.test(word));
361
+ const args = words.slice(i, end < 0 ? undefined : end);
362
+ if (args[0] === "clone") {
363
+ const [repository, destination] = positionals(args.slice(1), CLONE_VALUE_OPTIONS);
364
+ const target = destination ?? (repository ? defaultCloneDirectory(repository) : undefined);
365
+ if (target) targets.push({ kind: "clone", path: resolve(directory, target) });
366
+ } else if (args[0] === "worktree" && args[1] === "add") {
367
+ const [path] = positionals(args.slice(2), WORKTREE_ADD_VALUE_OPTIONS);
368
+ if (path) targets.push({ kind: "worktree", path: resolve(directory, path) });
369
+ }
370
+ }
371
+ base = directoryAfterSegment(segment, base);
372
+ }
373
+ return targets;
374
+ }
375
+
376
+ /** A path the runtime never records or removes: a filesystem root, or the home, config, or session directory or an ancestor of them. */
377
+ export function isProtectedPath(path: string, keep: readonly string[]): boolean {
378
+ return parse(path).root === path || [homedir(), ...keep].some((dir) => dir && isWithin(resolve(dir), path));
379
+ }
380
+
381
+ // Artifact checks and removal (S6).
382
+
383
+ type Git = (args: string[], cwd: string) => Promise<string | undefined>;
384
+
385
+ // Inherited repository overrides would point git away from the recorded path.
386
+ const GIT_ENV_OVERRIDES = ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR", "GIT_OBJECT_DIRECTORY"];
387
+
388
+ const runGit: Git = (args, cwd) => new Promise((resolveResult) => {
389
+ const env = { ...process.env };
390
+ for (const key of GIT_ENV_OVERRIDES) delete env[key];
391
+ execFile("git", args, { cwd, env, encoding: "utf8", timeout: 10_000 }, (error, stdout) => resolveResult(error ? undefined : stdout.trim()));
392
+ });
393
+
394
+ export type ArtifactCheck =
395
+ | { state: "missing" }
396
+ | { state: "kept"; reason: string }
397
+ | { state: "removable"; commonDir?: string; identity?: { dev: bigint; ino: bigint } };
398
+
399
+ const sameDirectory = (path: string) => {
400
+ try {
401
+ const stat = lstatSync(path);
402
+ return !stat.isSymbolicLink() && stat.isDirectory() && realpathSync(path) === path;
403
+ } catch {
404
+ return false;
405
+ }
406
+ };
407
+
408
+ const identityOf = (path: string) => {
409
+ try {
410
+ // bigint: some filesystems report inode numbers above Number.MAX_SAFE_INTEGER.
411
+ const { dev, ino } = lstatSync(path, { bigint: true });
412
+ return { dev, ino };
413
+ } catch {
414
+ return undefined;
415
+ }
416
+ };
417
+
418
+ /** Whether a recorded clone or worktree can be removed without losing work. */
419
+ export async function checkArtifact(artifact: ScratchArtifact, keep: readonly string[], git: Git = runGit): Promise<ArtifactCheck> {
420
+ const { path, kind } = artifact;
421
+ try { lstatSync(path); } catch (error) {
422
+ // ENOTDIR: a path component is no longer a directory, so the artifact cannot exist there.
423
+ const code = (error as NodeJS.ErrnoException).code;
424
+ if (code === "ENOENT" || code === "ENOTDIR") return { state: "missing" };
425
+ }
426
+ if (!sameDirectory(path)) return { state: "kept", reason: "not the recorded directory" };
427
+ // Taken before any Git call, so a directory swapped in during the checks is not the one removed.
428
+ const identity = identityOf(path);
429
+ if (isProtectedPath(path, keep)) return { state: "kept", reason: "protected directory" };
430
+ const top = await git(["rev-parse", "--show-toplevel"], path);
431
+ if (top === undefined || resolve(top) !== path) return { state: "kept", reason: "not a Git work tree root" };
432
+ let dotGit;
433
+ try { dotGit = lstatSync(join(path, ".git")); } catch { return { state: "kept", reason: "not a Git work tree root" }; }
434
+ if (kind === "clone" ? !dotGit.isDirectory() : !dotGit.isFile()) return { state: "kept", reason: kind === "clone" ? "not a standalone clone" : "not a linked worktree" };
435
+ // Override user settings that hide untracked files or submodule changes from status.
436
+ const status = await git(["status", "--porcelain", "--untracked-files=all", "--ignore-submodules=none"], path);
437
+ if (status === undefined) return { state: "kept", reason: "Git status unavailable" };
438
+ if (status !== "") return { state: "kept", reason: "uncommitted changes" };
439
+ if (kind === "clone") {
440
+ // Removing the clone would orphan its linked worktrees; a prunable one has lost its directory already.
441
+ const worktrees = await git(["worktree", "list", "--porcelain"], path);
442
+ const present = worktrees?.split(/\n\s*\n/).filter((entry) => /^worktree /m.test(entry) && !/^prunable\b/m.test(entry));
443
+ if (!present?.length) return { state: "kept", reason: "Git worktrees unavailable" };
444
+ if (present.length > 1) return { state: "kept", reason: "has linked worktrees" };
445
+ // Every ref, stashes included, must already be on a remote.
446
+ const local = await git(["rev-list", "-n", "1", "--all", "--not", "--remotes"], path);
447
+ if (local === undefined) return { state: "kept", reason: "Git history unavailable" };
448
+ return local === "" ? { state: "removable", identity } : { state: "kept", reason: "commits not on any remote" };
449
+ }
450
+ const detached = await git(["rev-list", "-n", "1", "HEAD", "--not", "--branches", "--tags", "--remotes"], path);
451
+ if (detached === undefined) return { state: "kept", reason: "Git history unavailable" };
452
+ if (detached !== "") return { state: "kept", reason: "commits on no branch" };
453
+ const commonDir = await git(["rev-parse", "--path-format=absolute", "--git-common-dir"], path);
454
+ return commonDir ? { state: "removable", commonDir, identity } : { state: "kept", reason: "Git repository unavailable" };
455
+ }
456
+
457
+ /** Removes a checked artifact: a worktree through git (which refuses dirty ones), a clone by its recorded path only. */
458
+ export async function removeArtifact(artifact: ScratchArtifact, check: Extract<ArtifactCheck, { state: "removable" }>, git: Git = runGit): Promise<string | undefined> {
459
+ if (!sameDirectory(artifact.path)) return "not the recorded directory";
460
+ // The directory checked must be the one removed, not a replacement at the same path.
461
+ const now = identityOf(artifact.path);
462
+ if (!check.identity || !now || now.dev !== check.identity.dev || now.ino !== check.identity.ino) return "replaced after the check";
463
+ if (artifact.kind === "worktree") {
464
+ if (await git(["worktree", "remove", artifact.path], artifact.path) === undefined) return "git worktree remove failed";
465
+ if (check.commonDir) await git([`--git-dir=${check.commonDir}`, "worktree", "prune"], dirname(check.commonDir));
466
+ return undefined;
467
+ }
468
+ try {
469
+ rmSync(artifact.path, { recursive: true, force: true });
470
+ return undefined;
471
+ } catch (error) {
472
+ return `removal failed: ${(error as Error).message}`;
473
+ }
474
+ }
package/lib/shell-bar.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
- import { GAUGE_CELLS, gaugeTone, paintGauge, renderGauge, type GaugeTone } from "./shell-gauge.ts";
2
+ import { GAUGE_CELLS, gaugeTone, paintGauge, renderGauge, type GaugePaint, type GaugeTone } from "./shell-gauge.ts";
3
3
  import { renderUsageBar, selectUsageLimit, type ProviderUsage, type UsageWindow } from "./shell-usage.ts";
4
4
  import { sanitizeTerminalText } from "./terminal-theme.ts";
5
5
  import { CARD_TONE, floatRows, panelInnerWidth, renderCard } from "./shell-card.ts";
@@ -159,21 +159,21 @@ function executionSegment(modelId: string, effort: string | undefined, theme: Sh
159
159
  : theme.fg(ROLE.MODEL, modelId);
160
160
  }
161
161
 
162
- function contextSegment(contextPercent: number | null, theme: ShellBarTheme): string {
162
+ function contextSegment(contextPercent: number | null, theme: ShellBarTheme, paint?: GaugePaint): string {
163
163
  const percentText = contextPercent === null ? "?%" : `${Math.round(contextPercent)}%`;
164
- return `${theme.fg(ROLE.LABEL, "ctx")} ${paintGauge(contextPercent, theme)} ${theme.fg(ROLE.VALUE, percentText)}`;
164
+ return `${theme.fg(ROLE.LABEL, "ctx")} ${paintGauge(contextPercent, theme, GAUGE_CELLS, paint)} ${theme.fg(ROLE.VALUE, percentText)}`;
165
165
  }
166
166
 
167
167
  function costSegment(costTotal: number, subscription: boolean, theme: ShellBarTheme): string {
168
168
  return theme.fg(ROLE.VALUE, formatCost(costTotal, subscription));
169
169
  }
170
170
 
171
- function buildSegments(model: ShellBarModel, theme: ShellBarTheme, presentation?: Presentation): string[] {
171
+ function buildSegments(model: ShellBarModel, theme: ShellBarTheme, presentation?: Presentation, paint?: GaugePaint): string[] {
172
172
  const location = locationSegment(model, theme);
173
173
  const modelSegment = executionSegment(model.modelId, model.effort, theme);
174
- const context = contextSegment(model.contextPercent, theme);
174
+ const context = contextSegment(model.contextPercent, theme, paint);
175
175
  const cost = costSegment(model.costTotal, model.subscription, theme);
176
- const usage = model.usage ? renderUsageBar(model.usage, theme, model.modelId) : undefined;
176
+ const usage = model.usage ? renderUsageBar(model.usage, theme, model.modelId, paint) : undefined;
177
177
  const statuses = model.statuses.map((status) => theme.fg(ROLE.STATUS, sanitizeStatus(status)));
178
178
  return [
179
179
  ...(presentation?.density === "minimal" ? [] : [theme.fg(ROLE.BRAND, SHELL_BAR_BRAND)]),
@@ -295,11 +295,11 @@ function headerLeftStages(model: ShellHeaderModel, theme: ShellBarTheme, showMod
295
295
  const USAGE_LABEL_ROLE = ROLE.LABEL;
296
296
  const USAGE_HINT_ROLE = "dim";
297
297
 
298
- function usageWindowText(window: UsageWindow, theme: ShellBarTheme, withGauge: boolean): string {
298
+ function usageWindowText(window: UsageWindow, theme: ShellBarTheme, withGauge: boolean, paint?: GaugePaint): string {
299
299
  const percent = `${Math.round(window.usedPercent)}%`;
300
300
  const parts = [
301
301
  ...(window.label.length > 0 ? [theme.fg(ROLE.LABEL, window.label)] : []),
302
- ...(withGauge ? [paintGauge(window.usedPercent, theme)] : []),
302
+ ...(withGauge ? [paintGauge(window.usedPercent, theme, GAUGE_CELLS, paint)] : []),
303
303
  theme.fg(ROLE.VALUE, percent),
304
304
  ];
305
305
  return parts.join(" ");
@@ -310,10 +310,10 @@ function usageWindowText(window: UsageWindow, theme: ShellBarTheme, withGauge: b
310
310
  // text only. A provider with no usage data at all has no windows to shape,
311
311
  // so all three collapse to the bare "usage" label plus the shortcut hint.
312
312
  type UsageStage = "full" | "text" | "primary";
313
- function usageSegmentText(windows: UsageWindow[], theme: ShellBarTheme, stage: UsageStage, hint: string | undefined): string {
313
+ function usageSegmentText(windows: UsageWindow[], theme: ShellBarTheme, stage: UsageStage, hint: string | undefined, paint?: GaugePaint): string {
314
314
  const label = theme.fg(USAGE_LABEL_ROLE, "usage");
315
315
  const shown = stage === "primary" ? windows.slice(0, 1) : windows;
316
- const body = shown.map((window) => usageWindowText(window, theme, stage === "full")).join(` ${theme.fg(ROLE.LABEL, "·")} `);
316
+ const body = shown.map((window) => usageWindowText(window, theme, stage === "full", paint)).join(` ${theme.fg(ROLE.LABEL, "·")} `);
317
317
  const head = body.length > 0 ? `${label} ${body}` : label;
318
318
  return hint ? `${head} ${theme.fg(ROLE.LABEL, "·")} ${theme.fg(USAGE_HINT_ROLE, hint)}` : head;
319
319
  }
@@ -338,16 +338,16 @@ function fitHeaderContent(text: string, width: number): string {
338
338
  return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
339
339
  }
340
340
 
341
- export function renderShellHeaderBar(model: ShellHeaderModel, theme: ShellBarTheme, width: number, usageHint?: string, presentation?: Presentation): ShellHeaderResult {
341
+ export function renderShellHeaderBar(model: ShellHeaderModel, theme: ShellBarTheme, width: number, usageHint?: string, presentation?: Presentation, paint?: GaugePaint): ShellHeaderResult {
342
342
  const targetWidth = Math.max(0, Math.floor(width));
343
343
  let content: ShellHeaderResult | undefined;
344
344
  let contentWidth = targetWidth;
345
345
  const row = floatHeaderRow(theme, targetWidth, (inner) => {
346
346
  contentWidth = inner;
347
- content = headerContent(model, theme, inner, usageHint, presentation);
347
+ content = headerContent(model, theme, inner, usageHint, presentation, paint);
348
348
  return content.text;
349
349
  });
350
- if (row === undefined || !content) return headerContent(model, theme, width, usageHint, presentation);
350
+ if (row === undefined || !content) return headerContent(model, theme, width, usageHint, presentation, paint);
351
351
  const { usageSpan } = content;
352
352
  // The inset splits evenly around the content, so its left share is where
353
353
  // the content's columns start.
@@ -356,8 +356,8 @@ export function renderShellHeaderBar(model: ShellHeaderModel, theme: ShellBarThe
356
356
  }
357
357
 
358
358
  /** Shared geometry for the rail header and the below-editor header widget. */
359
- export function renderShellHeaderChrome(model: ShellHeaderModel, theme: ShellBarTheme, width: number, usageHint?: string, presentation?: HeaderPresentation): ShellHeaderChrome {
360
- const { text, usageSpan } = renderShellHeaderBar(model, theme, width, usageHint, presentation);
359
+ export function renderShellHeaderChrome(model: ShellHeaderModel, theme: ShellBarTheme, width: number, usageHint?: string, presentation?: HeaderPresentation, paint?: GaugePaint): ShellHeaderChrome {
360
+ const { text, usageSpan } = renderShellHeaderBar(model, theme, width, usageHint, presentation, paint);
361
361
  const targetWidth = Math.max(0, Math.floor(width));
362
362
  const painted = floatHeaderPaint(theme, targetWidth, () => "");
363
363
  const rule = renderShellHeaderRule(theme, targetWidth);
@@ -377,12 +377,12 @@ export function shellHeaderUsageHit(chrome: ShellHeaderChrome, x: number, y: num
377
377
  return y === chrome.headerRow && usageSpan !== undefined && x >= usageSpan.start && x < usageSpan.end;
378
378
  }
379
379
 
380
- function headerContent(model: ShellHeaderModel, theme: ShellBarTheme, width: number, usageHint?: string, presentation?: Presentation): ShellHeaderResult {
380
+ function headerContent(model: ShellHeaderModel, theme: ShellBarTheme, width: number, usageHint?: string, presentation?: Presentation, paint?: GaugePaint): ShellHeaderResult {
381
381
  const targetWidth = Math.max(0, Math.floor(width));
382
382
  const jobs = model.jobs ? theme.fg(ROLE.STATUS, shellJobsCount(model.jobs)) : undefined;
383
383
  const ctxCost = joinSegments([
384
384
  ...(jobs ? [jobs] : []),
385
- ...(presentation?.visibility.usageCost === false ? [] : [contextSegment(model.contextPercent, theme), costSegment(model.costTotal, model.subscription, theme)]),
385
+ ...(presentation?.visibility.usageCost === false ? [] : [contextSegment(model.contextPercent, theme, paint), costSegment(model.costTotal, model.subscription, theme)]),
386
386
  ], theme);
387
387
  const windows = model.usage ? (selectUsageLimit(model.usage, model.modelId)?.windows ?? []) : [];
388
388
  const leftStages = headerLeftStages(model, theme, presentation?.visibility.modelDetails !== false)
@@ -403,7 +403,7 @@ function headerContent(model: ShellHeaderModel, theme: ShellBarTheme, width: num
403
403
  { leftIndex: leftStages.length - 1, usageStage: undefined },
404
404
  ];
405
405
  for (const { leftIndex, usageStage } of attempts) {
406
- const usageText = usageStage ? usageSegmentText(windows, theme, usageStage, usageHint) : undefined;
406
+ const usageText = usageStage ? usageSegmentText(windows, theme, usageStage, usageHint, paint) : undefined;
407
407
  const right = usageText ? joinSegments([ctxCost, usageText], theme) : ctxCost;
408
408
  const left = joinSegments(leftStages[leftIndex]!, theme);
409
409
  if (visibleWidth(left) + (right ? RIGHT_PADDING : 0) + visibleWidth(right) > targetWidth) continue;
@@ -422,11 +422,11 @@ function headerContent(model: ShellHeaderModel, theme: ShellBarTheme, width: num
422
422
 
423
423
  // A single owner supplies real captured filenames, not ShellBarModel's totals.
424
424
  // Undefined keeps every legacy surface alive for style/width/theme transitions.
425
- export function renderShellBelowInputFloat(model: ShellBarModel, theme: ShellBarTheme, width: number, usageHint?: string, presentation?: HeaderPresentation, changes?: ChangesModel): ShellHeaderChrome | undefined {
425
+ export function renderShellBelowInputFloat(model: ShellBarModel, theme: ShellBarTheme, width: number, usageHint?: string, presentation?: HeaderPresentation, changes?: ChangesModel, paint?: GaugePaint): ShellHeaderChrome | undefined {
426
426
  if (presentation?.headerPlacement !== "below-input") return undefined;
427
427
  const targetWidth = Math.max(0, Math.floor(width));
428
428
  if (floatHeaderPaint(theme, targetWidth, () => "") === undefined) return undefined;
429
- const chrome = renderShellHeaderChrome(buildShellHeaderModel(model, presentation.statusPlacement === "hidden"), theme, targetWidth, usageHint, presentation);
429
+ const chrome = renderShellHeaderChrome(buildShellHeaderModel(model, presentation.statusPlacement === "hidden"), theme, targetWidth, usageHint, presentation, paint);
430
430
  const changesRow = changes?.files.length && presentation.visibility.changes !== false
431
431
  ? floatHeaderRow(theme, targetWidth, (inner) => renderChangesWidget(changes, theme, inner)[0] ?? "")
432
432
  : undefined;
@@ -456,12 +456,12 @@ export function renderShellBelowInputFloat(model: ShellBarModel, theme: ShellBar
456
456
  // header row's own cascade, so context, cost and usage outlive the location
457
457
  // on small screens, and keeps extension statuses on a second line instead of
458
458
  // dropping them the way the header deliberately does.
459
- export function renderShellBottomOnlyBar(model: ShellBarModel, theme: ShellBarTheme, width: number, usageHint?: string, presentation?: HeaderPresentation, changes?: ChangesModel): string[] {
460
- const grouped = renderShellBelowInputFloat(model, theme, width, usageHint, presentation, changes);
459
+ export function renderShellBottomOnlyBar(model: ShellBarModel, theme: ShellBarTheme, width: number, usageHint?: string, presentation?: HeaderPresentation, changes?: ChangesModel, paint?: GaugePaint): string[] {
460
+ const grouped = renderShellBelowInputFloat(model, theme, width, usageHint, presentation, changes, paint);
461
461
  if (grouped) return grouped.rows;
462
462
  const headerModel = buildShellHeaderModel(model, true);
463
463
  // Neon, missing backgrounds and sub-minimum widths keep the old row count.
464
- const rows = [headerContent(headerModel, theme, width, usageHint, presentation).text];
464
+ const rows = [headerContent(headerModel, theme, width, usageHint, presentation, paint).text];
465
465
  const statuses = model.statuses.map(sanitizeStatus).filter((status) => status.length > 0).map((status) => theme.fg(ROLE.STATUS, status));
466
466
  return statuses.length ? [...rows, truncateToWidth(joinSegments(statuses, theme), Math.max(0, Math.floor(width)), "…")] : rows;
467
467
  }
@@ -503,8 +503,8 @@ export function renderShellHeaderRule(theme: ShellBarTheme, width: number): stri
503
503
  return theme.fg(HEADER_RULE_ROLE, HEADER_RULE_CHAR.repeat(targetWidth));
504
504
  }
505
505
 
506
- export function renderShellBar(model: ShellBarModel, theme: ShellBarTheme, width: number, presentation?: Presentation): string[] {
507
- let segments = buildSegments(model, theme, presentation);
506
+ export function renderShellBar(model: ShellBarModel, theme: ShellBarTheme, width: number, presentation?: Presentation, paint?: GaugePaint): string[] {
507
+ let segments = buildSegments(model, theme, presentation, paint);
508
508
  const right = model.sessionName ? theme.fg(ROLE.SESSION, model.sessionName) : undefined;
509
509
 
510
510
  let left = joinSegments(segments, theme);