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.
- package/README.md +336 -137
- package/assets/agents/gentle-ai-explore.md +1 -0
- package/assets/agents/gentle-ai-verify.md +6 -4
- package/assets/agents/gentle-ai-worker.md +7 -1
- package/assets/orchestrator-delegation-reference.md +163 -0
- package/assets/orchestrator-delegation.md +25 -176
- package/assets/orchestrator-tracking.md +1 -1
- package/assets/orchestrator-verification.md +14 -8
- package/assets/orchestrator-writer.md +5 -1
- package/assets/orchestrator.md +9 -9
- package/bin/gentle-shell.mjs +170 -22
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
- package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
- package/docs/assets/diagrams/agent-orchestration.svg +54 -15
- package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
- package/docs/assets/diagrams/measured-routing.svg +49 -0
- package/docs/assets/diagrams/native-review.svg +52 -12
- package/docs/assets/diagrams/odd-workflow.svg +128 -66
- package/docs/assets/diagrams/permission-modes.svg +51 -0
- package/docs/assets/features/audio-notifications.png +0 -0
- package/docs/assets/features/command-palette.png +0 -0
- package/docs/bundled-install.md +33 -3
- package/docs/delegated-verification.md +10 -8
- package/docs/gentle-agents-activity.md +11 -5
- package/docs/gentle-shell-uninstall.md +8 -1
- package/docs/gentle-shell.md +5 -4
- package/docs/install-wizard.md +8 -1
- package/docs/permission-modes.md +1 -1
- package/docs/readme-reference.md +107 -19
- package/docs/yolo-mode.md +21 -8
- package/extensions/codegraph-tools.ts +68 -6
- package/extensions/gentle-agents.ts +99 -12
- package/extensions/gentle-ai.ts +284 -52
- package/extensions/gentle-herdr-activity.ts +2 -3
- package/extensions/gentle-shell.ts +27 -12
- package/extensions/gentle-todo.ts +43 -9
- package/extensions/quiet-tools.ts +1 -1
- package/extensions/session-scratch.ts +365 -0
- package/extensions/startup-banner.ts +2 -3
- package/lib/agent-home.ts +8 -3
- package/lib/agents-protocol.ts +3 -0
- package/lib/agents-runner.ts +55 -6
- package/lib/agents-view.ts +3 -1
- package/lib/bounded-writer-admission.ts +51 -0
- package/lib/command-palette-catalog.ts +0 -4
- package/lib/commit-slice-signal.ts +100 -0
- package/lib/context-backstop.ts +84 -0
- package/lib/delivered-test-lint.ts +80 -0
- package/lib/destructive-command-guard.ts +120 -9
- package/lib/gauge-theme.ts +146 -0
- package/lib/gentle-ai-binary.ts +28 -7
- package/lib/gentle-shell-bundled.ts +39 -3
- package/lib/gentle-shell-import-pi.ts +233 -0
- package/lib/gentle-shell-launcher.ts +145 -11
- package/lib/gentle-shell-uninstall.ts +18 -1
- package/lib/herdr-activity.ts +24 -0
- package/lib/native-review-cli.ts +9 -1
- package/lib/orchestrator-discovery.ts +5 -1
- package/lib/orchestrator-presence.ts +9 -0
- package/lib/orchestrator-scope.ts +21 -3
- package/lib/provider-contract-bundle.ts +99 -53
- package/lib/review-candidate-view-owner.ts +29 -19
- package/lib/review-candidate-view.ts +36 -2
- package/lib/review-risk-assessment.ts +43 -38
- package/lib/review-session-standing-permission.ts +3 -1
- package/lib/session-profile-freeze.ts +30 -1
- package/lib/session-scratch.ts +474 -0
- package/lib/shell-bar.ts +25 -25
- package/lib/shell-changes-view.ts +80 -13
- package/lib/shell-gauge.ts +49 -6
- package/lib/shell-todo.ts +23 -4
- package/lib/shell-usage-view.ts +5 -1
- package/lib/shell-usage.ts +7 -7
- package/lib/verifier-admission.ts +29 -0
- package/lib/yolo-temp-scratch.ts +69 -0
- package/package.json +1 -2
- package/runtime/gentle-ai-binary.mjs +28 -7
- package/runtime/gentle-shell-bundled.mjs +38 -2
- package/runtime/gentle-shell-import-pi.mjs +234 -0
- package/runtime/gentle-shell-launcher.mjs +143 -9
- package/runtime/gentle-shell-uninstall.mjs +18 -1
- package/runtime/native-review-cli.mjs +8 -0
- package/runtime/review-risk-assessment.mjs +43 -38
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/bundled-engram-migration.mjs +125 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-server.mjs +14 -4
- package/scripts/main-channel.mjs +3 -2
- package/scripts/run-test-suite.mjs +5 -0
- package/scripts/verify-package-files.mjs +19 -13
- package/skills/gentle-ai/SKILL.md +3 -3
- package/skills/work-unit-commits/SKILL.md +5 -3
- package/tests/agent-risk-escalation.test.ts +37 -10
- package/tests/agents-runner.test.ts +260 -3
- package/tests/autonomous-guard.test.ts +122 -0
- package/tests/bounded-writer-admission.test.ts +11 -3
- package/tests/card-content-policy.test.ts +19 -9
- package/tests/child-safety.test.ts +39 -0
- package/tests/codegraph-tools.test.ts +123 -2
- package/tests/command-palette.test.ts +14 -4
- package/tests/commit-slice-signal.test.ts +162 -0
- package/tests/context-backstop.test.ts +159 -0
- package/tests/delegate-for-reason-contract.test.ts +91 -24
- package/tests/delivered-test-lint.test.ts +105 -0
- package/tests/destructive-command-guard.test.ts +85 -1
- package/tests/engram-safety.test.ts +1 -1
- package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
- package/tests/gauge-theme.test.ts +120 -0
- package/tests/gentle-agents.test.ts +282 -3
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-child-guards.test.ts +61 -0
- package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-shell-bin.test.ts +163 -0
- package/tests/gentle-shell-bundled.test.ts +169 -6
- package/tests/gentle-shell-import-pi.test.ts +262 -0
- package/tests/gentle-shell-launcher.test.ts +79 -0
- package/tests/gentle-shell-uninstall.test.ts +30 -0
- package/tests/gentle-shell.test.ts +42 -9
- package/tests/gentle-todo.test.ts +179 -0
- package/tests/headless-delivery-question.test.ts +58 -0
- package/tests/herdr-activity.test.ts +34 -1
- package/tests/installer-server.test.ts +10 -2
- package/tests/installer-windows-bootstrap.test.ts +33 -4
- package/tests/native-review-capability-contract.test.ts +7 -1
- package/tests/native-review-parity.test.ts +22 -1
- package/tests/no-self-pause-contract.test.ts +5 -2
- package/tests/odd-integration.test.ts +5 -1
- package/tests/odd-routing-canonical-ratchet.test.ts +127 -162
- package/tests/odd-routing-contract.test.ts +115 -30
- package/tests/orchestrator-budget.test.ts +19 -8
- package/tests/orchestrator-discovery.test.ts +29 -0
- package/tests/orchestrator-rdd-ownership.test.ts +3 -1
- package/tests/orchestrator-scope.test.ts +66 -0
- package/tests/orchestrator-status-publication-contract.test.ts +12 -8
- package/tests/package-manifest.test.ts +24 -20
- package/tests/pi-update-launcher.test.ts +21 -2
- package/tests/primary-session-prompt.test.ts +101 -0
- package/tests/provider-contract-bundle.test.ts +118 -0
- package/tests/provider-contract-mirror.test.ts +24 -0
- package/tests/quiet-tool-rendering.test.ts +23 -0
- package/tests/rdd-aware-verification-contract.test.ts +29 -11
- package/tests/rdd-status-line.test.ts +79 -0
- package/tests/review-agent-end-preflight.test.ts +6 -0
- package/tests/review-candidate-view.test.ts +49 -0
- package/tests/review-contract-prompt.test.ts +5 -2
- package/tests/review-controller-native-routing.test.ts +14 -1
- package/tests/review-controller.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +59 -2
- package/tests/review-loadout-rdd-off-sdk.test.ts +237 -0
- package/tests/review-owner-identity.test.ts +132 -0
- package/tests/review-risk-assessment.test.ts +163 -76
- package/tests/review-session-standing-permission-controller.test.ts +14 -1
- package/tests/review-session-standing-permission.test.ts +27 -1
- package/tests/run-test-suite.test.ts +15 -0
- package/tests/runtime-harness.mjs +16 -6
- package/tests/session-changes-shell.test.ts +1 -1
- package/tests/session-profile-freeze.test.ts +23 -0
- package/tests/session-scratch.test.ts +945 -0
- package/tests/session-subject-routing-contract.test.ts +2 -1
- package/tests/shell-bar.test.ts +7 -0
- package/tests/shell-changes-view.test.ts +137 -1
- package/tests/shell-gauge.test.ts +42 -0
- package/tests/small-task-inline-contract.test.ts +37 -0
- package/tests/standalone-config-home.test.ts +324 -0
- package/tests/startup-banner.test.ts +5 -4
- package/tests/startup-home-repo.test.ts +34 -0
- package/tests/support/orchestrator-modules.ts +1 -0
- package/tests/task-size-routing-contract.test.ts +69 -9
- package/tests/test-scratch-contract.test.ts +102 -0
- package/tests/test-strength-contract.test.ts +36 -1
- package/tests/tool-batching-contract.test.ts +64 -0
- package/tests/verification-proportionality.test.ts +46 -0
- package/tests/verifier-admission.test.ts +137 -0
- package/tests/verify-agent-contract.test.ts +8 -8
- package/tests/windows-hidden-processes.test.ts +5 -0
- package/tests/writer-edit-surface-scope.test.ts +3 -0
- package/tests/writer-feature-document-admission.test.ts +156 -0
- package/tests/yolo-mode.test.ts +18 -0
- package/tests/yolo-temp-scratch.test.ts +71 -0
- package/scripts/mirror-odd-routing.mjs +0 -242
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
- /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);
|