gentle-pi 4.3.0 → 4.4.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 +8 -5
- package/assets/agents/gentle-ai-worker.md +1 -1
- package/assets/design-canvas/canvas.css +648 -0
- package/assets/design-canvas/canvas.js +1497 -0
- package/assets/design-canvas/index.html +88 -0
- package/assets/design-canvas/inspector.js +290 -0
- package/assets/orchestrator-delegation-reference.md +2 -2
- package/assets/orchestrator-verification.md +1 -1
- package/assets/orchestrator.md +2 -2
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +101 -151
- package/docs/bundled-install.md +47 -42
- package/docs/delegated-verification.md +6 -0
- package/docs/gentle-agents-activity.md +5 -1
- package/docs/gentle-music.md +61 -0
- package/docs/gentle-shell.md +77 -7
- package/docs/install-wizard.md +30 -6
- package/docs/permission-modes.md +7 -2
- package/docs/readme-reference.md +19 -15
- package/docs/session-profile-format.md +26 -0
- package/docs/voice.md +259 -0
- package/docs/yolo-mode.md +17 -2
- package/extensions/ask-user-choice.ts +4 -1
- package/extensions/gentle-agents.ts +173 -38
- package/extensions/gentle-ai.ts +214 -69
- package/extensions/gentle-design.ts +158 -0
- package/extensions/gentle-music.ts +289 -0
- package/extensions/gentle-shell.ts +332 -73
- package/extensions/gentle-voice.ts +11 -0
- package/extensions/session-scratch.ts +41 -22
- package/extensions/skill-registry.ts +63 -8
- package/lib/agent-profiles.ts +26 -0
- package/lib/agents-config.ts +35 -0
- package/lib/agents-protocol.ts +57 -1
- package/lib/agents-runner.ts +52 -3
- package/lib/agents-session-transport.ts +101 -6
- package/lib/agents-widget.ts +14 -1
- package/lib/auto-permission-memory.ts +48 -18
- package/lib/auto-permission-runtime.ts +21 -6
- package/lib/clipboard-image.ts +147 -0
- package/lib/command-palette-catalog.ts +2 -0
- package/lib/commit-slice-signal.ts +1 -1
- package/lib/context-backstop.ts +36 -0
- package/lib/design-canvas-server.ts +947 -0
- package/lib/design-comments.ts +437 -0
- package/lib/design-handoffs.ts +313 -0
- package/lib/design-open-browser.ts +58 -0
- package/lib/design-sources.ts +120 -0
- package/lib/external-editor.ts +31 -0
- package/lib/gentle-design.ts +513 -0
- package/lib/gentle-shell-bundled.ts +123 -49
- package/lib/gentle-shell-launcher.ts +9 -4
- package/lib/gentle-shell-startup-update.ts +61 -9
- package/lib/gentle-shell-uninstall.ts +33 -1
- package/lib/image-attachments.ts +150 -0
- package/lib/inprocess-reviewer.ts +33 -2
- package/lib/nan-provider.ts +3 -0
- package/lib/native-review-cli.ts +11 -4
- package/lib/notification-audio-native.ts +149 -13
- package/lib/notification-audio.ts +4 -0
- package/lib/notification-pulse-worker.ts +82 -18
- package/lib/notification-service.ts +5 -1
- package/lib/orchestrator-presence.ts +78 -8
- package/lib/orchestrator-scope.ts +10 -0
- package/lib/orchestrator-state.ts +11 -3
- package/lib/permission-mode-policy.ts +2 -2
- package/lib/review-host-relay.ts +11 -2
- package/lib/review-reminder-receipt.ts +24 -1
- package/lib/session-profile-append-controller.ts +713 -0
- package/lib/session-profile-disk-reader.ts +182 -0
- package/lib/session-profile-persistence.ts +15 -7
- package/lib/session-scratch.ts +52 -3
- package/lib/session-worktree-registry.ts +7 -0
- package/lib/shell-bar.ts +4 -3
- package/lib/shell-changes.ts +81 -5
- package/lib/shell-prompt.ts +4 -4
- package/lib/shell-sidebar-layout.ts +26 -6
- package/lib/shell-todo.ts +1 -1
- package/lib/verifier-admission.ts +5 -4
- package/lib/vim-editor-adapter.ts +11 -8
- package/lib/voice-artifacts.ts +188 -0
- package/lib/voice-command.ts +576 -0
- package/lib/voice-install.ts +455 -0
- package/lib/voice-platform.ts +99 -0
- package/lib/voice-push-to-talk.ts +262 -0
- package/lib/voice-session.ts +460 -0
- package/lib/voice-settings.ts +50 -0
- package/lib/voice-ui.ts +158 -0
- package/lib/yolo-session-policy.ts +58 -22
- package/package.json +1 -1
- package/runtime/gentle-shell-bundled.mjs +122 -48
- package/runtime/gentle-shell-launcher.mjs +9 -4
- package/runtime/gentle-shell-startup-update.mjs +61 -9
- package/runtime/gentle-shell-uninstall.mjs +33 -1
- package/runtime/native-review-cli.mjs +11 -4
- package/scripts/bundled-engram-migration.mjs +0 -6
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-downloads.mjs +7 -7
- package/scripts/installer-preflight.mjs +5 -3
- package/scripts/installer-windows.mjs +37 -7
- package/scripts/main-channel.mjs +91 -17
- package/scripts/pi-update.mjs +34 -6
- package/scripts/verify-package-files.mjs +26 -2
- package/scripts/voice-worker.cjs +476 -0
- package/skills/design/SKILL.md +110 -0
- package/tests/agent-profiles.test.ts +24 -0
- package/tests/agents-config.test.ts +61 -0
- package/tests/agents-fake-child.ts +8 -1
- package/tests/agents-protocol.test.ts +32 -0
- package/tests/agents-runner.test.ts +226 -5
- package/tests/agents-session-transport.test.ts +53 -3
- package/tests/ask-user-choice.test.ts +30 -0
- package/tests/auto-mode.test.ts +17 -0
- package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
- package/tests/auto-permission-runtime.test.ts +85 -1
- package/tests/autonomous-guard.test.ts +20 -0
- package/tests/bundled-install.test.ts +3 -3
- package/tests/clipboard-image.test.ts +114 -0
- package/tests/command-palette.test.ts +1 -1
- package/tests/context-backstop.test.ts +70 -4
- package/tests/delegate-for-reason-contract.test.ts +2 -1
- package/tests/design-canvas-assets.test.ts +710 -0
- package/tests/design-canvas-server.test.ts +1096 -0
- package/tests/design-comments.test.ts +201 -0
- package/tests/design-handoffs.test.ts +282 -0
- package/tests/design-open-browser.test.ts +70 -0
- package/tests/design-sources.test.ts +132 -0
- package/tests/engram-safety.test.ts +132 -57
- package/tests/external-editor.test.ts +84 -0
- package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
- package/tests/gentle-agents.test.ts +365 -20
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-ai.test.ts +292 -13
- package/tests/gentle-design.test.ts +1313 -0
- package/tests/gentle-music.test.ts +335 -0
- package/tests/gentle-shell-bundled.test.ts +301 -248
- package/tests/gentle-shell-launcher.test.ts +28 -2
- package/tests/gentle-shell-startup-update.test.ts +77 -1
- package/tests/gentle-shell-uninstall.test.ts +59 -0
- package/tests/gentle-shell.test.ts +183 -8
- package/tests/gentle-todo.test.ts +11 -0
- package/tests/image-attachments.test.ts +119 -0
- package/tests/image-paste-chips.test.ts +236 -0
- package/tests/inprocess-reviewer.test.ts +55 -2
- package/tests/installer-bundles.test.ts +12 -6
- package/tests/installer-posix-bootstrap.test.ts +8 -8
- package/tests/installer-preflight.test.ts +16 -0
- package/tests/installer-server.test.ts +2 -2
- package/tests/installer-windows-bootstrap.test.ts +61 -3
- package/tests/installer-windows-diagnostics.test.ts +111 -0
- package/tests/main-channel-upgrade.test.ts +232 -2
- package/tests/main-channel.test.ts +140 -21
- package/tests/nan-provider.test.ts +36 -0
- package/tests/native-review-capability-contract.test.ts +11 -4
- package/tests/native-review-parity.test.ts +1 -1
- package/tests/notification-audio-native.test.ts +128 -9
- package/tests/notification-customize.test.ts +17 -5
- package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
- package/tests/orchestrator-budget.test.ts +5 -2
- package/tests/orchestrator-presence.test.ts +100 -2
- package/tests/orchestrator-rdd-ownership.test.ts +2 -2
- package/tests/orchestrator-scope.test.ts +14 -0
- package/tests/orchestrator-state.test.ts +29 -0
- package/tests/package-files.test.ts +34 -0
- package/tests/package-manifest.test.ts +6 -6
- package/tests/permission-mode-policy.test.ts +10 -1
- package/tests/pi-update.test.ts +99 -4
- package/tests/rdd-aware-verification-contract.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +88 -1
- package/tests/review-host-relay.test.ts +9 -2
- package/tests/review-json-arguments.test.ts +20 -3
- package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
- package/tests/review-relay-transport-agent.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +31 -1
- package/tests/session-changes.test.ts +2 -1
- package/tests/session-profile-append-controller-fork.test.ts +458 -0
- package/tests/session-profile-append-controller-pi.test.ts +290 -0
- package/tests/session-profile-append-controller.test.ts +731 -0
- package/tests/session-profile-disk-reader-pi.test.ts +183 -0
- package/tests/session-profile-disk-reader.test.ts +311 -0
- package/tests/session-scratch.test.ts +68 -2
- package/tests/session-worktree-registry.test.ts +13 -1
- package/tests/shell-bar.test.ts +14 -2
- package/tests/shell-changes.test.ts +148 -1
- package/tests/shell-prompt.test.ts +3 -3
- package/tests/shell-render-cache.test.ts +134 -1
- package/tests/shell-sidebar-layout.test.ts +50 -0
- package/tests/short-check-routing-contract.test.ts +1 -1
- package/tests/skill-collision-prefixes.test.ts +3 -1
- package/tests/skill-registry-watcher.test.ts +17 -5
- package/tests/skill-registry.test.ts +47 -3
- package/tests/task-size-routing-contract.test.ts +1 -1
- package/tests/test-strength-contract.test.ts +1 -1
- package/tests/verifier-admission.test.ts +18 -1
- package/tests/voice-command.test.ts +773 -0
- package/tests/voice-install.test.ts +323 -0
- package/tests/voice-platform.test.ts +176 -0
- package/tests/voice-push-to-talk.test.ts +444 -0
- package/tests/voice-session.test.ts +434 -0
- package/tests/voice-settings.test.ts +49 -0
- package/tests/voice-ui.test.ts +97 -0
- package/tests/voice-worker.test.ts +445 -0
- package/tests/weak-model-economy-prompt.test.ts +32 -0
- package/tests/windows-hidden-processes.test.ts +25 -2
- package/tests/yolo-customize.test.ts +5 -1
- package/tests/yolo-mode.test.ts +1 -1
- package/tests/yolo-session-policy-capture.test.ts +104 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Observable selected-record authority only, not conversation durability.
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { isDeepStrictEqual } from "node:util";
|
|
4
|
+
import {
|
|
5
|
+
isSessionProfileFamilyEntry,
|
|
6
|
+
readSessionProfileEntry,
|
|
7
|
+
type SessionProfileReadResult,
|
|
8
|
+
} from "./session-profile-persistence.ts";
|
|
9
|
+
|
|
10
|
+
export interface SessionProfileSource {
|
|
11
|
+
getSessionId(): string;
|
|
12
|
+
getSessionFile(): string | undefined;
|
|
13
|
+
getBranch(): readonly unknown[];
|
|
14
|
+
}
|
|
15
|
+
export type DiskProfileIndeterminateReason =
|
|
16
|
+
| "missing-source"
|
|
17
|
+
| "invalid-candidate-metadata"
|
|
18
|
+
| "unreadable-file"
|
|
19
|
+
| "source-changed"
|
|
20
|
+
| "invalid-record"
|
|
21
|
+
| "session-header-mismatch"
|
|
22
|
+
| "duplicate-id"
|
|
23
|
+
| "missing-header"
|
|
24
|
+
| "selected-record-missing"
|
|
25
|
+
| "selected-record-mismatch"
|
|
26
|
+
| "unserializable-source";
|
|
27
|
+
export type DiskProfileResult =
|
|
28
|
+
| {
|
|
29
|
+
status: "indeterminate";
|
|
30
|
+
reason: DiskProfileIndeterminateReason;
|
|
31
|
+
entryIndex?: never;
|
|
32
|
+
lineNumber?: number;
|
|
33
|
+
}
|
|
34
|
+
| (SessionProfileReadResult & { entryIndex?: number; lineNumber?: number });
|
|
35
|
+
|
|
36
|
+
export interface DiskProfileOptions {
|
|
37
|
+
readFile?: (path: string) => string;
|
|
38
|
+
/** Trusted caller evidence ONLY: exclude known failed appends, never arbitrary
|
|
39
|
+
* invalid/future entries. This reader owns no quarantine or append controller.
|
|
40
|
+
* Failed IDs remain excluded even if they subsequently appear on disk. */
|
|
41
|
+
knownFailedEntryIds?: ReadonlySet<string>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function record(value: unknown): value is Record<string, unknown> {
|
|
45
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
46
|
+
}
|
|
47
|
+
/** Own required metadata of a profile-family candidate, before serialization. */
|
|
48
|
+
export function hasSessionProfileCandidateMetadata(value: unknown): boolean {
|
|
49
|
+
return (
|
|
50
|
+
record(value) && metadata(value) && Object.hasOwn(value, "customType")
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
function metadata(value: Record<string, unknown>): boolean {
|
|
54
|
+
return (
|
|
55
|
+
["type", "id", "parentId", "timestamp"].every((key) =>
|
|
56
|
+
Object.hasOwn(value, key),
|
|
57
|
+
) &&
|
|
58
|
+
typeString(value.type) &&
|
|
59
|
+
typeString(value.id) &&
|
|
60
|
+
typeString(value.timestamp) &&
|
|
61
|
+
(value.parentId === null || typeString(value.parentId))
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
function typeString(value: unknown): value is string {
|
|
65
|
+
return typeof value === "string" && value.length > 0;
|
|
66
|
+
}
|
|
67
|
+
function snapshot(value: unknown): unknown {
|
|
68
|
+
// Same undefined omission/toJSON behavior as Pi's JSONL serialization. Throws
|
|
69
|
+
// on cycles/BigInt; decoded copies cannot retain mutable memory references.
|
|
70
|
+
try {
|
|
71
|
+
return JSON.parse(JSON.stringify(value));
|
|
72
|
+
} catch {
|
|
73
|
+
throw new TypeError("Unserializable source snapshot");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function unavailable(
|
|
77
|
+
reason: DiskProfileIndeterminateReason,
|
|
78
|
+
lineNumber?: number): DiskProfileResult {
|
|
79
|
+
return lineNumber === undefined
|
|
80
|
+
? { status: "indeterminate", reason }
|
|
81
|
+
: { status: "indeterminate", reason, lineNumber };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Stateless synchronous read of the captured public active branch and file.
|
|
86
|
+
* No writes, SDK imports, UI, fallback policy, cache, ancestry repair or fsync.
|
|
87
|
+
* An orphan selected record can be corroborated: complete disk ancestry is NOT
|
|
88
|
+
* required. No external-writer/TOCTOU guarantee is implied by source checks.
|
|
89
|
+
* Missing disk is conservative and does not decide future memory-only activation.
|
|
90
|
+
*/
|
|
91
|
+
export function readSessionProfileDisk(
|
|
92
|
+
source: SessionProfileSource,
|
|
93
|
+
options: DiskProfileOptions = {},
|
|
94
|
+
): DiskProfileResult {
|
|
95
|
+
try {
|
|
96
|
+
const sessionId = source.getSessionId();
|
|
97
|
+
const path = source.getSessionFile();
|
|
98
|
+
const rawBranch = source.getBranch();
|
|
99
|
+
if (!typeString(sessionId) || !typeString(path) || !Array.isArray(rawBranch))
|
|
100
|
+
return unavailable("missing-source");
|
|
101
|
+
// Validate own required candidate metadata before serialization can hide
|
|
102
|
+
// missing/inherited fields. Ordinary branch entries have no profile meaning.
|
|
103
|
+
for (const raw of rawBranch) {
|
|
104
|
+
if (
|
|
105
|
+
isSessionProfileFamilyEntry(raw) &&
|
|
106
|
+
!hasSessionProfileCandidateMetadata(raw)
|
|
107
|
+
)
|
|
108
|
+
return unavailable("invalid-candidate-metadata");
|
|
109
|
+
}
|
|
110
|
+
const branch = snapshot(rawBranch) as unknown[];
|
|
111
|
+
const failed = new Set(options.knownFailedEntryIds);
|
|
112
|
+
let entryIndex = -1;
|
|
113
|
+
for (let index = branch.length - 1; index >= 0; index--) {
|
|
114
|
+
const candidate = branch[index];
|
|
115
|
+
if (
|
|
116
|
+
isSessionProfileFamilyEntry(candidate) &&
|
|
117
|
+
!failed.has((candidate as { id?: unknown }).id as string)
|
|
118
|
+
) {
|
|
119
|
+
entryIndex = index;
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
let text: string;
|
|
124
|
+
try {
|
|
125
|
+
text = (options.readFile ?? ((file) => readFileSync(file, "utf8")))(path);
|
|
126
|
+
} catch {
|
|
127
|
+
return unavailable("unreadable-file");
|
|
128
|
+
}
|
|
129
|
+
if (
|
|
130
|
+
source.getSessionId() !== sessionId ||
|
|
131
|
+
source.getSessionFile() !== path ||
|
|
132
|
+
!isDeepStrictEqual(snapshot(source.getBranch()), branch)
|
|
133
|
+
)
|
|
134
|
+
return unavailable("source-changed");
|
|
135
|
+
const disk = new Map<
|
|
136
|
+
string,
|
|
137
|
+
{ value: Record<string, unknown>; lineNumber: number }
|
|
138
|
+
>();
|
|
139
|
+
let foundHeader = false;
|
|
140
|
+
const lines = text.split("\n");
|
|
141
|
+
for (let index = 0; index < lines.length; index++) {
|
|
142
|
+
if (!lines[index].trim()) continue;
|
|
143
|
+
const lineNumber = index + 1;
|
|
144
|
+
let value: unknown;
|
|
145
|
+
try {
|
|
146
|
+
value = JSON.parse(lines[index]);
|
|
147
|
+
} catch {
|
|
148
|
+
// Pi's session parser skips malformed lines, so a file Pi opens must
|
|
149
|
+
// not become permanently indeterminate here.
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (!record(value)) return unavailable("invalid-record", lineNumber);
|
|
153
|
+
if (!foundHeader) {
|
|
154
|
+
if (value.type !== "session" || value.id !== sessionId)
|
|
155
|
+
return unavailable("session-header-mismatch", lineNumber);
|
|
156
|
+
foundHeader = true;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (!metadata(value) || value.type === "session")
|
|
160
|
+
return unavailable("invalid-record", lineNumber);
|
|
161
|
+
if (disk.has(value.id as string) || value.id === sessionId)
|
|
162
|
+
return unavailable("duplicate-id", lineNumber);
|
|
163
|
+
disk.set(value.id as string, { value, lineNumber });
|
|
164
|
+
}
|
|
165
|
+
if (!foundHeader) return unavailable("missing-header");
|
|
166
|
+
if (entryIndex < 0) return { status: "absent" };
|
|
167
|
+
const selected = branch[entryIndex] as Record<string, unknown>;
|
|
168
|
+
const saved = disk.get(selected.id as string);
|
|
169
|
+
if (!saved) return unavailable("selected-record-missing");
|
|
170
|
+
if (!isDeepStrictEqual(selected, saved.value))
|
|
171
|
+
return unavailable("selected-record-mismatch", saved.lineNumber);
|
|
172
|
+
const semantic = readSessionProfileEntry({
|
|
173
|
+
type: selected.type as string,
|
|
174
|
+
customType: selected.customType as string,
|
|
175
|
+
data: selected.data,
|
|
176
|
+
});
|
|
177
|
+
return { ...semantic, entryIndex, lineNumber: saved.lineNumber };
|
|
178
|
+
} catch {
|
|
179
|
+
// Never expose exception messages or record contents (including getters).
|
|
180
|
+
return unavailable("unserializable-source");
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -11,7 +11,20 @@ import {
|
|
|
11
11
|
} from "./model-routing-authority.ts";
|
|
12
12
|
|
|
13
13
|
export const SESSION_PROFILE_CUSTOM_TYPE = "gentle-pi.session-profile/v1";
|
|
14
|
-
const SESSION_PROFILE_FAMILY = "gentle-pi.session-profile/";
|
|
14
|
+
export const SESSION_PROFILE_FAMILY = "gentle-pi.session-profile/";
|
|
15
|
+
|
|
16
|
+
/** True for any custom entry in the session-profile family, known version or not. */
|
|
17
|
+
export function isSessionProfileFamilyEntry(entry: unknown): boolean {
|
|
18
|
+
return (
|
|
19
|
+
typeof entry === "object" &&
|
|
20
|
+
entry !== null &&
|
|
21
|
+
(entry as { type?: unknown }).type === "custom" &&
|
|
22
|
+
typeof (entry as { customType?: unknown }).customType === "string" &&
|
|
23
|
+
(entry as { customType: string }).customType.startsWith(
|
|
24
|
+
SESSION_PROFILE_FAMILY,
|
|
25
|
+
)
|
|
26
|
+
);
|
|
27
|
+
}
|
|
15
28
|
|
|
16
29
|
export type SessionProfileOrigin = "user" | "local" | "repo" | "global";
|
|
17
30
|
|
|
@@ -148,12 +161,7 @@ export function createSessionProfileClear(): SessionProfileClearPayload {
|
|
|
148
161
|
export function readSessionProfileEntry(
|
|
149
162
|
entry: SessionProfileEntry,
|
|
150
163
|
): SessionProfileReadResult {
|
|
151
|
-
if (
|
|
152
|
-
entry.type !== "custom" ||
|
|
153
|
-
typeof entry.customType !== "string" ||
|
|
154
|
-
!entry.customType.startsWith(SESSION_PROFILE_FAMILY)
|
|
155
|
-
)
|
|
156
|
-
return { status: "absent" };
|
|
164
|
+
if (!isSessionProfileFamilyEntry(entry)) return { status: "absent" };
|
|
157
165
|
if (entry.customType !== SESSION_PROFILE_CUSTOM_TYPE)
|
|
158
166
|
return { status: "unsupported" };
|
|
159
167
|
const data = entry.data;
|
package/lib/session-scratch.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execFile, execFileSync } from "node:child_process";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { mkdir, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
4
5
|
import { homedir } from "node:os";
|
|
5
6
|
import { basename, dirname, isAbsolute, join, parse, resolve, sep } from "node:path";
|
|
6
7
|
import { directoryAfterSegment, SHELL_SEGMENT_SEPARATOR } from "./commit-slice-signal.ts";
|
|
@@ -12,6 +13,8 @@ import { directoryAfterSegment, SHELL_SEGMENT_SEPARATOR } from "./commit-slice-s
|
|
|
12
13
|
// and every check refuses rather than guesses.
|
|
13
14
|
|
|
14
15
|
export const LEDGER_VERSION = 1;
|
|
16
|
+
/** The ledger keeps the most recent artifacts, evicting the oldest first, so a long session's ledger file stays bounded. */
|
|
17
|
+
export const LEDGER_ARTIFACTS_CAP = 64;
|
|
15
18
|
// Short on purpose (S9): TMPDIR points at `gpXXXXXX` and a subagent's TMPDIR at
|
|
16
19
|
// `gaXXXXXX` under it, and Unix socket paths below them are capped near 104 bytes.
|
|
17
20
|
export const SCRATCH_PREFIX = "gp";
|
|
@@ -105,8 +108,36 @@ export function readLedger(path: string): ScratchLedger | undefined {
|
|
|
105
108
|
}
|
|
106
109
|
}
|
|
107
110
|
|
|
111
|
+
// The fs functions the ledger file I/O uses. Tests spy on this object to pin the
|
|
112
|
+
// ledger I/O to the async fs API; the sync fs functions stay for the single-ledger
|
|
113
|
+
// read and the scratch-directory checks, which were already synchronous.
|
|
114
|
+
export const ledgerFs = { mkdir, readFile, readdir, rename, stat, unlink, writeFile };
|
|
115
|
+
|
|
116
|
+
/** A valid ledger read from `path`, or undefined for a missing, unreadable, or foreign file. */
|
|
117
|
+
async function readLedgerFile(path: string): Promise<ScratchLedger | undefined> {
|
|
118
|
+
try {
|
|
119
|
+
const parsed: unknown = JSON.parse(await ledgerFs.readFile(path, "utf8"));
|
|
120
|
+
return isLedger(parsed) ? parsed : undefined;
|
|
121
|
+
} catch {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
108
126
|
/** Atomic and private: a temp file in the same directory, then a rename. */
|
|
109
|
-
export function writeLedger(path: string, ledger: ScratchLedger): void {
|
|
127
|
+
export async function writeLedger(path: string, ledger: ScratchLedger): Promise<void> {
|
|
128
|
+
await ledgerFs.mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
129
|
+
const temp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
130
|
+
try {
|
|
131
|
+
await ledgerFs.writeFile(temp, `${JSON.stringify(ledger, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
132
|
+
await ledgerFs.rename(temp, path);
|
|
133
|
+
} catch (error) {
|
|
134
|
+
try { await ledgerFs.unlink(temp); } catch { /* already renamed or never created */ }
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Synchronous `writeLedger`, for the one caller that cannot await it (the shutdown path, whose callers do not await the handler). */
|
|
140
|
+
export function writeLedgerSync(path: string, ledger: ScratchLedger): void {
|
|
110
141
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
111
142
|
const temp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
112
143
|
try {
|
|
@@ -123,7 +154,25 @@ export function deleteLedger(path: string): void {
|
|
|
123
154
|
}
|
|
124
155
|
|
|
125
156
|
/** Every valid ledger in the config home; other files are left alone. */
|
|
126
|
-
export function listLedgers(configHome: string): StoredLedger[] {
|
|
157
|
+
export async function listLedgers(configHome: string): Promise<StoredLedger[]> {
|
|
158
|
+
const dir = ledgerDirectory(configHome);
|
|
159
|
+
let names: string[];
|
|
160
|
+
try { names = await ledgerFs.readdir(dir); } catch { return []; }
|
|
161
|
+
const result: StoredLedger[] = [];
|
|
162
|
+
for (const name of names) {
|
|
163
|
+
if (!name.endsWith(".json")) continue;
|
|
164
|
+
const path = join(dir, name);
|
|
165
|
+
try {
|
|
166
|
+
const ledger = await readLedgerFile(path);
|
|
167
|
+
if (!ledger) continue;
|
|
168
|
+
result.push({ path, ledger, mtimeMs: (await ledgerFs.stat(path)).mtimeMs });
|
|
169
|
+
} catch { /* removed meanwhile */ }
|
|
170
|
+
}
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Synchronous `listLedgers`, for the one caller that cannot await it (the shutdown path, whose callers do not await the handler). */
|
|
175
|
+
export function listLedgersSync(configHome: string): StoredLedger[] {
|
|
127
176
|
const dir = ledgerDirectory(configHome);
|
|
128
177
|
let names: string[];
|
|
129
178
|
try { names = readdirSync(dir); } catch { return []; }
|
|
@@ -388,7 +437,7 @@ const GIT_ENV_OVERRIDES = ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_CO
|
|
|
388
437
|
const runGit: Git = (args, cwd) => new Promise((resolveResult) => {
|
|
389
438
|
const env = { ...process.env };
|
|
390
439
|
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()));
|
|
440
|
+
execFile("git", args, { cwd, env, encoding: "utf8", timeout: 10_000, windowsHide: true }, (error, stdout) => resolveResult(error ? undefined : stdout.trim()));
|
|
392
441
|
});
|
|
393
442
|
|
|
394
443
|
export type ArtifactCheck =
|
|
@@ -30,6 +30,13 @@ function canonicalDirectory(path: string, cwd: string): string {
|
|
|
30
30
|
return statSync(canonical).isDirectory() ? canonical : dirname(canonical);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
// Windows accepts `/` as its separator and Git prints roots that way, while
|
|
34
|
+
// realpath answers with `\`. Only the separator is translated, so any other
|
|
35
|
+
// non-canonical spelling (dot segments, trailing separators) still differs.
|
|
36
|
+
export function nativeRootSpelling(path: string, platform: NodeJS.Platform = process.platform): string {
|
|
37
|
+
return platform === "win32" ? path.replaceAll("/", "\\") : path;
|
|
38
|
+
}
|
|
39
|
+
|
|
33
40
|
export function resolveSessionWorktreeWithGit(path: string, cwd: string, run: typeof execFileSync = execFileSync): WorktreeIdentity | undefined {
|
|
34
41
|
try {
|
|
35
42
|
const directory = canonicalDirectory(path, cwd);
|
package/lib/shell-bar.ts
CHANGED
|
@@ -22,7 +22,7 @@ export interface ShellBarModel {
|
|
|
22
22
|
review?: ReviewSidebarSnapshot;
|
|
23
23
|
jobs?: RunningJobDisplay[];
|
|
24
24
|
profile?: string;
|
|
25
|
-
changes?: { files: number; added: number; deleted: number
|
|
25
|
+
changes?: { files: number; added: number; deleted: number };
|
|
26
26
|
cwd: string;
|
|
27
27
|
branch: string | null;
|
|
28
28
|
dirty: number | undefined;
|
|
@@ -240,7 +240,6 @@ export function renderShellSidebarBar(model: ShellBarModel, theme: ShellBarTheme
|
|
|
240
240
|
changes?.files
|
|
241
241
|
? `${changes.files} ${changes.files === 1 ? "file" : "files"} · ${theme.fg("success", `+${changes.added}`)} ${theme.fg("error", `−${changes.deleted}`)}`
|
|
242
242
|
: label("No captured changes"),
|
|
243
|
-
...(changes?.notice ? [theme.fg("warning", sanitizeStatus(changes.notice))] : []),
|
|
244
243
|
label("/gentle:changes"),
|
|
245
244
|
],
|
|
246
245
|
}]),
|
|
@@ -427,7 +426,9 @@ export function renderShellBelowInputFloat(model: ShellBarModel, theme: ShellBar
|
|
|
427
426
|
const targetWidth = Math.max(0, Math.floor(width));
|
|
428
427
|
if (floatHeaderPaint(theme, targetWidth, () => "") === undefined) return undefined;
|
|
429
428
|
const chrome = renderShellHeaderChrome(buildShellHeaderModel(model, presentation.statusPlacement === "hidden"), theme, targetWidth, usageHint, presentation, paint);
|
|
430
|
-
|
|
429
|
+
// The row exists only while files are actually tracked; an unborn-only root
|
|
430
|
+
// is silence here (/gentle:changes explains the state on demand).
|
|
431
|
+
const changesRow = Boolean(changes && changes.files.length > 0) && presentation.visibility.changes !== false
|
|
431
432
|
? floatHeaderRow(theme, targetWidth, (inner) => renderChangesWidget(changes, theme, inner)[0] ?? "")
|
|
432
433
|
: undefined;
|
|
433
434
|
const statuses = presentation.statusPlacement === "hidden" ? [] : [
|
package/lib/shell-changes.ts
CHANGED
|
@@ -27,6 +27,8 @@ export interface ChangesModel {
|
|
|
27
27
|
files: ChangedFile[];
|
|
28
28
|
added: number;
|
|
29
29
|
deleted: number;
|
|
30
|
+
/** Stored once per model build; the footer memo reads it every keystroke. */
|
|
31
|
+
digest?: string;
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
export interface ChangesSnapshot {
|
|
@@ -63,7 +65,7 @@ const HAS_RENAME_BRACES = /\{[^{}]* => [^{}]*\}/;
|
|
|
63
65
|
const RENAME_ARROW = " => ";
|
|
64
66
|
|
|
65
67
|
export function emptyChanges(): ChangesModel {
|
|
66
|
-
return { files: [], added: 0, deleted: 0 };
|
|
68
|
+
return { files: [], added: 0, deleted: 0, digest: EMPTY_CHANGES_DIGEST };
|
|
67
69
|
}
|
|
68
70
|
|
|
69
71
|
function renamedPath(path: string): string {
|
|
@@ -124,9 +126,42 @@ export function changesModel(changed: ChangedFile[]): ChangesModel {
|
|
|
124
126
|
files,
|
|
125
127
|
added: files.reduce((total, file) => total + file.added, 0),
|
|
126
128
|
deleted: files.reduce((total, file) => total + file.deleted, 0),
|
|
129
|
+
digest: changesDigest({ files, added: 0, deleted: 0 }),
|
|
127
130
|
};
|
|
128
131
|
}
|
|
129
132
|
|
|
133
|
+
// The footer Status memo asks "did any change move?" on every keystroke, so
|
|
134
|
+
// the answer must be O(1) to read: the model stores this digest once per
|
|
135
|
+
// build (changesModel/emptyChanges) and consumers read the stored field.
|
|
136
|
+
// It is a 64-bit FNV-1a roll over every per-file tuple the renderer consumes
|
|
137
|
+
// (path, status, counts, diffRevision, countsUnavailable), so counts alone
|
|
138
|
+
// cannot miss a same-count edit. A hash collision could only skip one
|
|
139
|
+
// refresh until the next change moves the digest again -- it never renders a
|
|
140
|
+
// wrong picture -- but it is a probabilistic signal, not an exact one.
|
|
141
|
+
function fnv1a(hash: number, text: string): number {
|
|
142
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
143
|
+
hash ^= text.charCodeAt(index);
|
|
144
|
+
hash = Math.imul(hash, 0x01000193);
|
|
145
|
+
}
|
|
146
|
+
return hash >>> 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const FNV_OFFSET_BASIS = 0x811c9dc5;
|
|
150
|
+
const SECOND_SEED = 0x811c9dc5 ^ 0x9e3779b9;
|
|
151
|
+
|
|
152
|
+
export function changesDigest(model: ChangesModel): string {
|
|
153
|
+
let first = FNV_OFFSET_BASIS;
|
|
154
|
+
let second = SECOND_SEED;
|
|
155
|
+
for (const file of model.files) {
|
|
156
|
+
const tuple = `\u0000${file.path}\u0000${file.status}\u0000${file.added}\u0000${file.deleted}\u0000${file.diffRevision ?? ""}\u0000${file.countsUnavailable ?? ""}`;
|
|
157
|
+
first = fnv1a(first, tuple);
|
|
158
|
+
second = fnv1a(second, tuple);
|
|
159
|
+
}
|
|
160
|
+
return `${first.toString(16).padStart(8, "0")}${second.toString(16).padStart(8, "0")}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const EMPTY_CHANGES_DIGEST = changesDigest({ files: [], added: 0, deleted: 0 });
|
|
164
|
+
|
|
130
165
|
export function changesSummary(model: ChangesModel): string {
|
|
131
166
|
const noun = model.files.length === 1 ? "file" : "files";
|
|
132
167
|
return `${model.files.length} ${noun} · +${model.added} −${model.deleted}${model.files.some(file => file.countsUnavailable) ? " · partial counts" : ""}`;
|
|
@@ -134,19 +169,39 @@ export function changesSummary(model: ChangesModel): string {
|
|
|
134
169
|
|
|
135
170
|
// One line: summary, the files joined by dots, and the command pushed to
|
|
136
171
|
// the right edge. The file list goes first when the terminal is narrow.
|
|
172
|
+
// The idle widget exists only while files are tracked. An unborn root (no
|
|
173
|
+
// commits yet) is silence here: /gentle:changes explains the state on demand,
|
|
174
|
+
// where the message has room and context.
|
|
175
|
+
//
|
|
176
|
+
// Both surfaces that paint it (the sidebar part and the below-input header
|
|
177
|
+
// row) re-render every frame, so the paint is memoized per digest, width and
|
|
178
|
+
// theme identity -- one stored frame, like CodemodeCard's cachedFrame. The
|
|
179
|
+
// digest already rolls up every per-file detail the paint consumes (path,
|
|
180
|
+
// status, counts, diffRevision, countsUnavailable), so a digest hit skips the
|
|
181
|
+
// per-file join entirely; a digest-less (hand-built) model never caches, and
|
|
182
|
+
// a new theme object repaints.
|
|
183
|
+
type WidgetFrame = { digest: string; width: number; theme: ChangesTheme; lines: string[] };
|
|
184
|
+
let widgetFrame: WidgetFrame | undefined;
|
|
185
|
+
|
|
137
186
|
export function renderChangesWidget(model: ChangesModel, theme: ChangesTheme, width: number): string[] {
|
|
138
187
|
if (model.files.length === 0) return [];
|
|
188
|
+
if (model.digest !== undefined && widgetFrame?.digest === model.digest && widgetFrame.width === width && widgetFrame.theme === theme) return widgetFrame.lines;
|
|
139
189
|
const noun = model.files.length === 1 ? "file" : "files";
|
|
140
190
|
const dot = theme.fg("muted", "·");
|
|
141
191
|
const head = `${theme.fg("accent", WIDGET_GLYPH)} ${theme.fg("text", `${model.files.length} ${noun}`)} ${dot} ${theme.fg("success", `+${model.added}`)} ${theme.fg("error", `−${model.deleted}`)}${model.files.some(file => file.countsUnavailable) ? " · partial counts" : ""}`;
|
|
142
192
|
const hint = theme.fg("dim", CHANGES_COMMAND);
|
|
143
193
|
const list = model.files.map((file) => theme.fg("muted", file.path)).join(` ${dot} `);
|
|
144
194
|
const left = `${head} ${dot} ${list}`;
|
|
195
|
+
let lines: string[];
|
|
145
196
|
const gap = width - visibleWidth(left) - visibleWidth(hint);
|
|
146
|
-
if (gap >= 2)
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
197
|
+
if (gap >= 2) lines = [`${left}${" ".repeat(gap)}${hint}`];
|
|
198
|
+
else {
|
|
199
|
+
const headGap = width - visibleWidth(head) - visibleWidth(hint);
|
|
200
|
+
lines = headGap >= 2 ? [`${head}${" ".repeat(headGap)}${hint}`] : [truncateToWidth(head, width, "…")];
|
|
201
|
+
}
|
|
202
|
+
if (model.digest === undefined) return lines;
|
|
203
|
+
widgetFrame = { digest: model.digest, width, theme, lines };
|
|
204
|
+
return lines;
|
|
150
205
|
}
|
|
151
206
|
|
|
152
207
|
export interface GitResult {
|
|
@@ -199,12 +254,24 @@ export async function foreignRootBranch(git: GitRunner): Promise<string | undefi
|
|
|
199
254
|
return verified.code === 0 ? branch : "no commits yet";
|
|
200
255
|
}
|
|
201
256
|
|
|
257
|
+
// A root whose HEAD is unborn (no commits yet) has no baseline: `git diff
|
|
258
|
+
// HEAD` is meaningless and `git status` reports every file as untracked.
|
|
259
|
+
// Worktree-holder folders -- repositories that exist only to parent linked
|
|
260
|
+
// worktrees -- look exactly like this, and scanning them floods Changes with
|
|
261
|
+
// thousands of phantom untracked files (the whole holder's contents). One
|
|
262
|
+
// cheap verify answers "is there anything to diff against" before a scan.
|
|
263
|
+
const HEAD_EXISTS_ARGS = ["rev-parse", "--verify", "-q", "HEAD"];
|
|
264
|
+
|
|
202
265
|
// /gentle:changes: one group per registered root holding its working tree
|
|
203
266
|
// against HEAD. A root whose Git fails (timed out, rejected) has no group but
|
|
204
267
|
// is listed in failedRoots: a failed scan is never reported as a clean root.
|
|
268
|
+
// A root with no commits has no group either, listed in unbornRoots: there is
|
|
269
|
+
// nothing to track yet, and the bar says so instead of scanning the holder.
|
|
205
270
|
export class WorktreeChangesTracker {
|
|
206
271
|
worktrees: WorktreeChanges[] = [];
|
|
207
272
|
failedRoots: string[] = [];
|
|
273
|
+
/** Registered roots whose HEAD is unborn: skipped, never scanned. */
|
|
274
|
+
unbornRoots: string[] = [];
|
|
208
275
|
scannedRootCount = 0;
|
|
209
276
|
private inFlight: Promise<ChangesModel> | undefined;
|
|
210
277
|
private readonly discover: GitRunner;
|
|
@@ -266,9 +333,17 @@ export class WorktreeChangesTracker {
|
|
|
266
333
|
const metadata = new Map((result.code === 0 ? parseWorktrees(result.stdout) : []).map((tree) => [tree.root, tree]));
|
|
267
334
|
// Roots scan in parallel so one slow root does not delay the others.
|
|
268
335
|
const failed: string[] = [];
|
|
336
|
+
const unborn: string[] = [];
|
|
269
337
|
const scanned = await Promise.all([...roots].map(async (root): Promise<WorktreeChanges | undefined> => {
|
|
270
338
|
const known = metadata.get(root);
|
|
271
339
|
try {
|
|
340
|
+
// Only an answered "no" means unborn: a rejected verify cannot tell
|
|
341
|
+
// unborn from broken Git, so the scan below still decides.
|
|
342
|
+
const head = await this.gitForRoot(root)(HEAD_EXISTS_ARGS).catch(() => undefined);
|
|
343
|
+
if (head && head.code !== 0) {
|
|
344
|
+
unborn.push(root);
|
|
345
|
+
return undefined;
|
|
346
|
+
}
|
|
272
347
|
const tracker = new ChangesTracker(this.gitForRoot(root), this.linesForRoot(root), this.revisionForRoot(root));
|
|
273
348
|
await tracker.start();
|
|
274
349
|
if (!tracker.statusRead) {
|
|
@@ -301,6 +376,7 @@ export class WorktreeChangesTracker {
|
|
|
301
376
|
if (latest.size !== roots.size || [...latest].some((root) => !roots.has(root))) return this.capture();
|
|
302
377
|
this.worktrees = trees;
|
|
303
378
|
this.failedRoots = failed;
|
|
379
|
+
this.unbornRoots = unborn;
|
|
304
380
|
this.scannedRootCount = roots.size;
|
|
305
381
|
return this.model;
|
|
306
382
|
}
|
package/lib/shell-prompt.ts
CHANGED
|
@@ -23,10 +23,10 @@ const PETAL_TONE = {
|
|
|
23
23
|
|
|
24
24
|
export type PetalTone = (typeof PETAL_TONE)[keyof typeof PETAL_TONE];
|
|
25
25
|
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
export const
|
|
26
|
+
// Render-driven animation (gentle-shell#994): the petal advances inside the
|
|
27
|
+
// prompt's own frames while streaming, so the only timer left is a watchdog
|
|
28
|
+
// that repaints silent working phases (long tool runs with no output).
|
|
29
|
+
export const SHELL_PULSE_WATCHDOG_MS = 1000;
|
|
30
30
|
export const SHELL_SCANNER_STEPS = 15;
|
|
31
31
|
const SCANNER_ENTRY_OFFSET = 3;
|
|
32
32
|
|
|
@@ -25,7 +25,7 @@ type LayoutRoot = Component & { [NODE]?: () => LayoutNode };
|
|
|
25
25
|
type Host = TUI & { mode?: string; layoutRoot?: LayoutRoot };
|
|
26
26
|
type SidebarCache = { revision: number };
|
|
27
27
|
type RailHit = { key: string; component: Component; startY: number; height: number; width: number };
|
|
28
|
-
type SectionCacheEntry = { component: Component; digest: string | undefined; revision: number; contentWidth: number; theme: ShellBarTheme; lines: string[] };
|
|
28
|
+
type SectionCacheEntry = { component: Component; digest: string | undefined; revision: number; contentWidth: number; theme: ShellBarTheme; themeSignature: string; lines: string[] };
|
|
29
29
|
type SidebarPresentation = { scrollTop: number; output: LayoutNode };
|
|
30
30
|
type PreparedRail = {
|
|
31
31
|
revision: number;
|
|
@@ -35,6 +35,7 @@ type PreparedRail = {
|
|
|
35
35
|
cardStyle: CardStyle;
|
|
36
36
|
root: LayoutRoot;
|
|
37
37
|
theme: ShellBarTheme;
|
|
38
|
+
themeSignature: string;
|
|
38
39
|
parts: Array<[string, SidebarRail]>;
|
|
39
40
|
digests: Array<string | undefined>;
|
|
40
41
|
contentWidth: number;
|
|
@@ -74,6 +75,20 @@ function railDigest(rail: SidebarRail): string | undefined {
|
|
|
74
75
|
}
|
|
75
76
|
}
|
|
76
77
|
|
|
78
|
+
// Pi passes extensions one live theme proxy, so identity never changes on a
|
|
79
|
+
// theme switch, and the switch only calls ui.invalidate(), which does not reach
|
|
80
|
+
// the rail inside the layout tree. How the current theme paints a few roles is
|
|
81
|
+
// what changes, so the memo keys on that: a digest-only section (Status,
|
|
82
|
+
// Agents, Todos) would otherwise keep the previous theme's colors.
|
|
83
|
+
const THEME_SIGNATURE_ROLES = ["accent", "border", "borderAccent", "muted", "dim", "text", "success", "warning", "error"];
|
|
84
|
+
function themeSignature(theme: ShellBarTheme): string {
|
|
85
|
+
try {
|
|
86
|
+
return THEME_SIGNATURE_ROLES.map((role) => theme.fg(role, "·")).join("") + theme.bold("·");
|
|
87
|
+
} catch {
|
|
88
|
+
return "";
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
77
92
|
export const STATUS_OWNER = { HEADER: "header", BOTTOM: "bottom" } as const;
|
|
78
93
|
export type StatusOwner = (typeof STATUS_OWNER)[keyof typeof STATUS_OWNER];
|
|
79
94
|
export interface StatusOwnerInput {
|
|
@@ -217,9 +232,13 @@ export function installSidebar(tui: TUI, theme: ShellBarTheme, placement: () =>
|
|
|
217
232
|
}
|
|
218
233
|
const style = cardStyle();
|
|
219
234
|
const parts = [...state.parts.entries()];
|
|
235
|
+
// Digest closures read live session state, so they must be evaluated once
|
|
236
|
+
// per frame and stored for the next comparison (issue #1681): the sections
|
|
237
|
+
// loop below reuses this pass instead of calling each digest a second time.
|
|
220
238
|
const digests = parts.map(([, rail]) => railDigest(rail));
|
|
239
|
+
const signature = themeSignature(theme);
|
|
221
240
|
const unchanged = prepared?.revision === cache.revision &&
|
|
222
|
-
prepared.width === width && prepared.mode === host.mode && prepared.headerPlacement === headerPlacement() && prepared.cardStyle === style && prepared.root === root && prepared.theme === theme &&
|
|
241
|
+
prepared.width === width && prepared.mode === host.mode && prepared.headerPlacement === headerPlacement() && prepared.cardStyle === style && prepared.root === root && prepared.theme === theme && prepared.themeSignature === signature &&
|
|
223
242
|
prepared.parts.length === parts.length && prepared.parts.every(([key, part], index) => parts[index]?.[0] === key && parts[index]?.[1] === part) &&
|
|
224
243
|
prepared.digests.length === digests.length && prepared.digests.every((digest, index) => digest === digests[index]);
|
|
225
244
|
if (unchanged) {
|
|
@@ -228,6 +247,7 @@ export function installSidebar(tui: TUI, theme: ShellBarTheme, placement: () =>
|
|
|
228
247
|
return prepared.active;
|
|
229
248
|
}
|
|
230
249
|
try {
|
|
250
|
+
const digestByPart = new Map(parts.map(([key], index) => [key, digests[index]]));
|
|
231
251
|
// The header is a full-width sibling row, not a rail section: it reads
|
|
232
252
|
// the terminal width (minus the rail's right inset), never the
|
|
233
253
|
// 50-column rail's content width.
|
|
@@ -241,16 +261,16 @@ export function installSidebar(tui: TUI, theme: ShellBarTheme, placement: () =>
|
|
|
241
261
|
sectionCache.delete(key);
|
|
242
262
|
return { key, component, lines: [] as string[] };
|
|
243
263
|
}
|
|
244
|
-
const digest =
|
|
264
|
+
const digest = digestByPart.get(key);
|
|
245
265
|
const existing = sectionCache.get(key);
|
|
246
|
-
const reusable = existing?.component === component && existing.contentWidth === contentWidth && existing.theme === theme &&
|
|
266
|
+
const reusable = existing?.component === component && existing.contentWidth === contentWidth && existing.theme === theme && existing.themeSignature === signature &&
|
|
247
267
|
(digest !== undefined ? existing.digest === digest : existing.digest === undefined && existing.revision === cache.revision);
|
|
248
268
|
const lines = reusable ? existing.lines : (() => {
|
|
249
269
|
const rendered = [...(component.render(contentWidth - RAIL_PADDING * 2) ?? [])];
|
|
250
270
|
while (rendered.length && rendered[rendered.length - 1]?.trim() === "") rendered.pop();
|
|
251
271
|
return rendered;
|
|
252
272
|
})();
|
|
253
|
-
sectionCache.set(key, { component, digest, revision: cache.revision, contentWidth, theme, lines });
|
|
273
|
+
sectionCache.set(key, { component, digest, revision: cache.revision, contentWidth, theme, themeSignature: signature, lines });
|
|
254
274
|
return { key, component, lines };
|
|
255
275
|
}).filter((section) => section.component !== undefined && section.lines.length > 0) as Array<{ key: string; component: Component; lines: string[] }>;
|
|
256
276
|
// The header carries the brand once it is active; the banner is the
|
|
@@ -276,7 +296,7 @@ export function installSidebar(tui: TUI, theme: ShellBarTheme, placement: () =>
|
|
|
276
296
|
// Height is owned by the native ScrollView, never by the transcript.
|
|
277
297
|
const active = railLines.length > 0 && railLines.every((line) => visibleWidth(line) <= contentWidth);
|
|
278
298
|
headerLines = headerActive && headerPlacement() === "top" ? preparedHeaderLines : [];
|
|
279
|
-
prepared = { revision: cache.revision, width, mode: host.mode, headerPlacement: headerPlacement(), cardStyle: style, root, theme, parts, digests, contentWidth, active, lines: railLines, hits, headerLines, headerActive: headerLines.length > 0 };
|
|
299
|
+
prepared = { revision: cache.revision, width, mode: host.mode, headerPlacement: headerPlacement(), cardStyle: style, root, theme, themeSignature: signature, parts, digests, contentWidth, active, lines: railLines, hits, headerLines, headerActive: headerLines.length > 0 };
|
|
280
300
|
state.active = active;
|
|
281
301
|
return active;
|
|
282
302
|
} catch {
|
package/lib/shell-todo.ts
CHANGED
|
@@ -281,7 +281,7 @@ export function replayTodo(entries: readonly unknown[]): TodoState {
|
|
|
281
281
|
// the tail of the conversation, never the prefix.
|
|
282
282
|
const TODO_PROMPT_BLOCK = [
|
|
283
283
|
"## Todo list",
|
|
284
|
-
"
|
|
284
|
+
"A small task (Task Size) uses no todo list: do it and report. For tracked or multi-step work, keep it current with the `todo` tool: mark a task in_progress before starting it, done right after finishing it, and rewrite the whole list with `write` whenever the plan changes. Update it before you end the turn.",
|
|
285
285
|
"A blocked task waits on something outside the list: do not work on it until its condition holds. When the plan changes, mark the tasks it made obsolete dropped instead of leaving them pending or marking them done.",
|
|
286
286
|
"While tasks stay open, the current list arrives as a hidden conversation message whenever it changes; the latest one is current, and it is not repeated here.",
|
|
287
287
|
].join("\n");
|
|
@@ -7,10 +7,11 @@ import { contextBackstopReached, type MeasuredContext } from "./context-backstop
|
|
|
7
7
|
// existing feature document (the same lines writer admission accepts), or the parent's measured context reaches the
|
|
8
8
|
// context backstop. The agent catalog has no generic verifier alias, so only `gentle-ai-verify` is gated.
|
|
9
9
|
const VERIFIER_NAMES = ["gentle-ai-verify"];
|
|
10
|
-
// A
|
|
11
|
-
// example `Risk: item 3 (shared parser)
|
|
12
|
-
//
|
|
13
|
-
|
|
10
|
+
// A declaration that starts a line (optionally as a list item) or a sentence (after `.`, `!` or `?` and a space) with
|
|
11
|
+
// `Risk:` whose first entry is `item N` with N in 1-6, for example `Risk: item 3 (shared parser)`, `- Risk: item 2,
|
|
12
|
+
// item 3 (reason)` or `... on top of ebf3f35. Risk: item 1 (migration)`. `Risk: none` and a mention inside a
|
|
13
|
+
// sentence ("Do not write Risk: item 3") do not start one.
|
|
14
|
+
const HIGH_RISK_ITEM_LINE = /(?:^[ \t]*(?:[-*+][ \t]+)?|[.!?][ \t]+)Risk:[ \t]*item[ \t]+[1-6](?!\d)/m;
|
|
14
15
|
export const VERIFIER_RISK_REJECTION =
|
|
15
16
|
"An independent verifier runs only for a change that declares a named high-risk item (`Risk: item N` from the Task Size list in the task). This change declares none: run its focused test and suite inline yourself now; do not retry the launch, and never add a Risk line or create a feature document only to get a verifier admitted.";
|
|
16
17
|
|