pi-crew 0.9.28 → 0.9.29
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/dist/build-meta.json +158 -91
- package/dist/index.mjs +893 -507
- package/dist/index.mjs.map +4 -4
- package/docs/REVIEW-FINDINGS-2026-07-CORE.md +139 -0
- package/docs/REVIEW-FINDINGS-2026-07.md +125 -0
- package/docs/bugs/bug-quota-display-truncation.md +223 -0
- package/docs/stories/README.md +3 -1
- package/docs/stories/US-DEPS-major-upgrade.md +62 -0
- package/package.json +4 -4
- package/src/config/config.ts +4 -0
- package/src/extension/crew-vibes/config.ts +1 -1
- package/src/extension/crew-vibes/footer.ts +292 -0
- package/src/extension/crew-vibes/index.ts +67 -52
- package/src/prompt/prompt-runtime.ts +1 -1
- package/src/runtime/child-pi.ts +52 -43
- package/src/runtime/cross-extension-rpc.ts +1 -1
- package/src/runtime/post-exit-stdio-guard.ts +22 -4
- package/src/runtime/stale-reconciler.ts +9 -4
- package/src/runtime/team-runner.ts +11 -16
- package/src/state/atomic-write.ts +46 -19
- package/src/state/event-log.ts +77 -24
- package/src/state/mailbox.ts +6 -3
- package/src/ui/pi-ui-compat.ts +11 -0
- package/src/utils/conflict-detect.ts +9 -3
- package/src/utils/paths.ts +7 -1
- package/src/worktree/cleanup.ts +19 -0
- package/src/worktree/worktree-manager.ts +145 -34
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
2
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { requestRenderTarget } from "../../ui/pi-ui-compat.ts";
|
|
4
|
+
import { asCrewTheme, type CrewTheme } from "../../ui/theme-adapter.ts";
|
|
5
|
+
import { truncateToWidth, visibleWidth } from "../../utils/visual.ts";
|
|
6
|
+
import type { CrewVibesConfig } from "./config.ts";
|
|
7
|
+
import type { ProviderUsage } from "./provider-usage.ts";
|
|
8
|
+
import { formatCount, getCapacityUsage, renderCapacity, renderProviderUsage } from "./render.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Custom footer replacement for crew-vibes.
|
|
12
|
+
*
|
|
13
|
+
* Why this exists: pi joins ALL `setStatus()` entries onto ONE line and
|
|
14
|
+
* right-truncates when the line overflows. The provider quota lives at the
|
|
15
|
+
* tail, so it gets chopped to "W..." whenever the `pi-crew` widget status grows
|
|
16
|
+
* during sub-agent runs. Five prior attempts (right-align padding, dynamic
|
|
17
|
+
* padding, separate keys) all failed because an extension using `setStatus`
|
|
18
|
+
* cannot see the widget's width, other extensions' widths, or pi's real render
|
|
19
|
+
* width. `setFooter` sidesteps all of that: `render(width)` receives the REAL
|
|
20
|
+
* width and we own the line layout, so the meters get a dedicated line that is
|
|
21
|
+
* never truncated by the join.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Live values the footer reads on each render (owned by the extension). */
|
|
25
|
+
export interface CrewVibesFooterSource {
|
|
26
|
+
getConfig(): CrewVibesConfig;
|
|
27
|
+
getQuotaUsage(): ProviderUsage | null;
|
|
28
|
+
getThinkingLevel(): string | undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CrewVibesFooterDeps {
|
|
32
|
+
tui: unknown;
|
|
33
|
+
theme: unknown;
|
|
34
|
+
footerData: unknown;
|
|
35
|
+
ctx: ExtensionContext;
|
|
36
|
+
source: CrewVibesFooterSource;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface FooterData {
|
|
40
|
+
getGitBranch(): string | null;
|
|
41
|
+
getExtensionStatuses(): ReadonlyMap<string, string>;
|
|
42
|
+
getAvailableProviderCount(): number;
|
|
43
|
+
onBranchChange(cb: () => void): () => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface FooterComponent {
|
|
47
|
+
render(width: number): string[];
|
|
48
|
+
invalidate(): void;
|
|
49
|
+
dispose(): void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function num(value: unknown): number {
|
|
53
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Mirror pi's footer cwd formatting: collapse the home prefix to `~`. */
|
|
57
|
+
function formatCwdForFooter(cwd: string, home: string | undefined): string {
|
|
58
|
+
if (!home) return cwd;
|
|
59
|
+
const resolvedCwd = resolve(cwd);
|
|
60
|
+
const resolvedHome = resolve(home);
|
|
61
|
+
const rel = relative(resolvedHome, resolvedCwd);
|
|
62
|
+
const inside = rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
63
|
+
if (!inside) return cwd;
|
|
64
|
+
return rel === "" ? "~" : `~${sep}${rel}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Mirror pi's status sanitizer (collapse control chars + runs of spaces). */
|
|
68
|
+
function sanitizeStatusText(text: string): string {
|
|
69
|
+
return text
|
|
70
|
+
.replace(/[\r\n\t]/g, " ")
|
|
71
|
+
.replace(/ +/g, " ")
|
|
72
|
+
.trim();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function asFooterData(value: unknown): FooterData | undefined {
|
|
76
|
+
if (!value || typeof value !== "object") return undefined;
|
|
77
|
+
const record = value as Record<string, unknown>;
|
|
78
|
+
if (
|
|
79
|
+
typeof record.getGitBranch !== "function" ||
|
|
80
|
+
typeof record.getExtensionStatuses !== "function" ||
|
|
81
|
+
typeof record.getAvailableProviderCount !== "function" ||
|
|
82
|
+
typeof record.onBranchChange !== "function"
|
|
83
|
+
) {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
return value as FooterData;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface SessionTotals {
|
|
90
|
+
input: number;
|
|
91
|
+
output: number;
|
|
92
|
+
cacheRead: number;
|
|
93
|
+
cacheWrite: number;
|
|
94
|
+
cost: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function computeTotals(entries: unknown[]): SessionTotals {
|
|
98
|
+
const totals: SessionTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
|
|
99
|
+
for (const entry of entries) {
|
|
100
|
+
const rec = entry as {
|
|
101
|
+
type?: string;
|
|
102
|
+
message?: { role?: string; usage?: Record<string, unknown> & { cost?: { total?: unknown } } };
|
|
103
|
+
};
|
|
104
|
+
if (rec?.type !== "message" || rec.message?.role !== "assistant") continue;
|
|
105
|
+
const usage = rec.message.usage;
|
|
106
|
+
if (!usage) continue;
|
|
107
|
+
totals.input += num(usage.input);
|
|
108
|
+
totals.output += num(usage.output);
|
|
109
|
+
totals.cacheRead += num(usage.cacheRead);
|
|
110
|
+
totals.cacheWrite += num(usage.cacheWrite);
|
|
111
|
+
totals.cost += num(usage.cost?.total);
|
|
112
|
+
}
|
|
113
|
+
return totals;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
class CrewVibesFooter implements FooterComponent {
|
|
117
|
+
private readonly theme: CrewTheme;
|
|
118
|
+
private readonly footerData: FooterData | undefined;
|
|
119
|
+
private readonly ctx: ExtensionContext;
|
|
120
|
+
private readonly source: CrewVibesFooterSource;
|
|
121
|
+
private readonly tui: unknown;
|
|
122
|
+
private readonly unsubscribeBranch: () => void;
|
|
123
|
+
|
|
124
|
+
constructor(deps: CrewVibesFooterDeps) {
|
|
125
|
+
this.theme = asCrewTheme(deps.theme);
|
|
126
|
+
this.footerData = asFooterData(deps.footerData);
|
|
127
|
+
this.ctx = deps.ctx;
|
|
128
|
+
this.source = deps.source;
|
|
129
|
+
this.tui = deps.tui;
|
|
130
|
+
this.unsubscribeBranch = this.footerData ? this.footerData.onBranchChange(() => requestRenderTarget(this.tui)) : () => {};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
invalidate(): void {}
|
|
134
|
+
|
|
135
|
+
dispose(): void {
|
|
136
|
+
this.unsubscribeBranch();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private buildPwdLine(width: number): string {
|
|
140
|
+
const sm = this.ctx.sessionManager;
|
|
141
|
+
let pwd = formatCwdForFooter(sm.getCwd(), process.env.HOME || process.env.USERPROFILE);
|
|
142
|
+
const branch = this.footerData?.getGitBranch();
|
|
143
|
+
if (branch) pwd = `${pwd} (${branch})`;
|
|
144
|
+
const sessionName = sm.getSessionName?.();
|
|
145
|
+
if (sessionName) pwd = `${pwd} • ${sessionName}`;
|
|
146
|
+
return truncateToWidth(this.theme.fg("dim", pwd), width, this.theme.fg("dim", "..."));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private buildStatsLine(width: number): string {
|
|
150
|
+
const theme = this.theme;
|
|
151
|
+
const model = this.ctx.model as { id?: string; provider?: string; reasoning?: unknown; contextWindow?: number } | undefined;
|
|
152
|
+
const totals = computeTotals(this.ctx.sessionManager.getEntries() as unknown[]);
|
|
153
|
+
const contextUsage = this.ctx.getContextUsage?.();
|
|
154
|
+
const contextWindow = contextUsage?.contextWindow ?? model?.contextWindow ?? 0;
|
|
155
|
+
const percentValue = contextUsage?.percent ?? 0;
|
|
156
|
+
const percentKnown = contextUsage?.percent !== null && contextUsage?.percent !== undefined;
|
|
157
|
+
|
|
158
|
+
const statsParts: string[] = [];
|
|
159
|
+
if (totals.input) statsParts.push(`↑${formatCount(totals.input)}`);
|
|
160
|
+
if (totals.output) statsParts.push(`↓${formatCount(totals.output)}`);
|
|
161
|
+
if (totals.cacheRead) statsParts.push(`R${formatCount(totals.cacheRead)}`);
|
|
162
|
+
if (totals.cacheWrite) statsParts.push(`W${formatCount(totals.cacheWrite)}`);
|
|
163
|
+
|
|
164
|
+
const usingSubscription = !!(model && this.ctx.modelRegistry?.isUsingOAuth?.(this.ctx.model as never));
|
|
165
|
+
if (totals.cost || usingSubscription) {
|
|
166
|
+
statsParts.push(`$${totals.cost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// User choice: always show the "(auto)" compaction indicator (pi's default);
|
|
170
|
+
// the toggle state is not observable by extensions.
|
|
171
|
+
const autoIndicator = " (auto)";
|
|
172
|
+
const contextDisplay = percentKnown
|
|
173
|
+
? `${percentValue.toFixed(1)}%/${formatCount(contextWindow)}${autoIndicator}`
|
|
174
|
+
: `?/${formatCount(contextWindow)}${autoIndicator}`;
|
|
175
|
+
const contextColored =
|
|
176
|
+
percentValue > 90
|
|
177
|
+
? theme.fg("error", contextDisplay)
|
|
178
|
+
: percentValue > 70
|
|
179
|
+
? theme.fg("warning", contextDisplay)
|
|
180
|
+
: contextDisplay;
|
|
181
|
+
statsParts.push(contextColored);
|
|
182
|
+
|
|
183
|
+
let statsLeft = statsParts.join(" ");
|
|
184
|
+
let statsLeftWidth = visibleWidth(statsLeft);
|
|
185
|
+
if (statsLeftWidth > width) {
|
|
186
|
+
statsLeft = truncateToWidth(statsLeft, width, "...");
|
|
187
|
+
statsLeftWidth = visibleWidth(statsLeft);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const minPadding = 2;
|
|
191
|
+
const modelName = model?.id || "no-model";
|
|
192
|
+
let rightSideWithoutProvider = modelName;
|
|
193
|
+
if (model?.reasoning) {
|
|
194
|
+
// Prefer the authoritative thinking level from session state
|
|
195
|
+
// (buildSessionContext), which reflects the current effective level
|
|
196
|
+
// including the model default — not just the last thinking_level_select
|
|
197
|
+
// event (which only fires on manual switch).
|
|
198
|
+
let level = this.source.getThinkingLevel();
|
|
199
|
+
try {
|
|
200
|
+
const ctx = this.ctx.sessionManager as { buildSessionContext?: () => { thinkingLevel?: string } };
|
|
201
|
+
if (typeof ctx.buildSessionContext === "function") {
|
|
202
|
+
const resolved = ctx.buildSessionContext()?.thinkingLevel;
|
|
203
|
+
if (resolved) level = resolved;
|
|
204
|
+
}
|
|
205
|
+
} catch {
|
|
206
|
+
// buildSessionContext may throw on empty/early sessions — fall back.
|
|
207
|
+
}
|
|
208
|
+
const finalLevel = level || "off";
|
|
209
|
+
rightSideWithoutProvider = finalLevel === "off" ? `${modelName} • thinking off` : `${modelName} • ${finalLevel}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
let rightSide = rightSideWithoutProvider;
|
|
213
|
+
const providerCount = this.footerData?.getAvailableProviderCount() ?? 0;
|
|
214
|
+
if (providerCount > 1 && model?.provider) {
|
|
215
|
+
rightSide = `(${model.provider}) ${rightSideWithoutProvider}`;
|
|
216
|
+
if (statsLeftWidth + minPadding + visibleWidth(rightSide) > width) rightSide = rightSideWithoutProvider;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const rightSideWidth = visibleWidth(rightSide);
|
|
220
|
+
let statsLine: string;
|
|
221
|
+
if (statsLeftWidth + minPadding + rightSideWidth <= width) {
|
|
222
|
+
statsLine = statsLeft + " ".repeat(width - statsLeftWidth - rightSideWidth) + rightSide;
|
|
223
|
+
} else {
|
|
224
|
+
const availableForRight = width - statsLeftWidth - minPadding;
|
|
225
|
+
if (availableForRight > 0) {
|
|
226
|
+
const truncatedRight = truncateToWidth(rightSide, availableForRight, "");
|
|
227
|
+
const padding = " ".repeat(Math.max(0, width - statsLeftWidth - visibleWidth(truncatedRight)));
|
|
228
|
+
statsLine = statsLeft + padding + truncatedRight;
|
|
229
|
+
} else {
|
|
230
|
+
statsLine = statsLeft;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const dimStatsLeft = theme.fg("dim", statsLeft);
|
|
235
|
+
const dimRemainder = theme.fg("dim", statsLine.slice(statsLeft.length));
|
|
236
|
+
return dimStatsLeft + dimRemainder;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private buildStatusLine(width: number): string | undefined {
|
|
240
|
+
if (!this.footerData) return undefined;
|
|
241
|
+
const statuses = this.footerData.getExtensionStatuses();
|
|
242
|
+
if (statuses.size === 0) return undefined;
|
|
243
|
+
const joined = Array.from(statuses.entries())
|
|
244
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
245
|
+
.map(([, text]) => sanitizeStatusText(text))
|
|
246
|
+
.join(" ");
|
|
247
|
+
if (!joined) return undefined;
|
|
248
|
+
return truncateToWidth(joined, width, this.theme.fg("dim", "..."));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private rightAlign(text: string, width: number): string {
|
|
252
|
+
const w = visibleWidth(text);
|
|
253
|
+
if (w >= width) return truncateToWidth(text, width, "…");
|
|
254
|
+
return " ".repeat(width - w) + text;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Capacity + provider quota. Uses the REAL render width, so the quota is
|
|
258
|
+
* never chopped. When both do not fit on one line, wrap to two lines
|
|
259
|
+
* (capacity above, quota right-aligned below) per the chosen behavior. */
|
|
260
|
+
private buildMeterLines(width: number): string[] {
|
|
261
|
+
const config = this.source.getConfig();
|
|
262
|
+
if (!config.enabled) return [];
|
|
263
|
+
const capText = config.capacity.enabled ? renderCapacity(this.theme, config.capacity, getCapacityUsage(this.ctx)) : undefined;
|
|
264
|
+
const quotaText = config.capacity.providerUsage ? renderProviderUsage(this.theme, this.source.getQuotaUsage()) : undefined;
|
|
265
|
+
|
|
266
|
+
if (!capText && !quotaText) return [];
|
|
267
|
+
if (capText && !quotaText) return [truncateToWidth(capText, width, "…")];
|
|
268
|
+
if (!capText && quotaText) return [this.rightAlign(quotaText, width)];
|
|
269
|
+
|
|
270
|
+
const cap = capText as string;
|
|
271
|
+
const quota = quotaText as string;
|
|
272
|
+
const capWidth = visibleWidth(cap);
|
|
273
|
+
const quotaWidth = visibleWidth(quota);
|
|
274
|
+
if (capWidth + 1 + quotaWidth <= width) {
|
|
275
|
+
const pad = Math.max(1, width - capWidth - quotaWidth);
|
|
276
|
+
return [cap + " ".repeat(pad) + quota];
|
|
277
|
+
}
|
|
278
|
+
return [truncateToWidth(cap, width, "…"), this.rightAlign(quota, width)];
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
render(width: number): string[] {
|
|
282
|
+
const lines = [this.buildPwdLine(width), this.buildStatsLine(width)];
|
|
283
|
+
const statusLine = this.buildStatusLine(width);
|
|
284
|
+
if (statusLine) lines.push(statusLine);
|
|
285
|
+
lines.push(...this.buildMeterLines(width));
|
|
286
|
+
return lines;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function createCrewVibesFooter(deps: CrewVibesFooterDeps): FooterComponent {
|
|
291
|
+
return new CrewVibesFooter(deps);
|
|
292
|
+
}
|
|
@@ -1,19 +1,17 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { requestRender, setFooter } from "../../ui/pi-ui-compat.ts";
|
|
2
3
|
import { type CrewVibesConfig, loadConfig, PROVIDER_STATUS_ID, saveConfig } from "./config.ts";
|
|
3
|
-
import { fetchProviderUsage, clearProviderUsageCache, providerSupportsQuota } from "./provider-usage.ts";
|
|
4
4
|
import { intervalForSpeed } from "./figures.ts";
|
|
5
|
+
import { type CrewVibesFooterSource, createCrewVibesFooter } from "./footer.ts";
|
|
6
|
+
import { clearProviderUsageCache, fetchProviderUsage, type ProviderUsage } from "./provider-usage.ts";
|
|
5
7
|
import {
|
|
6
8
|
asCrewTheme,
|
|
7
9
|
clearVibesStatus,
|
|
8
10
|
crewIndicatorFrames,
|
|
9
11
|
formatSpeed,
|
|
10
12
|
getCapacityUsage,
|
|
11
|
-
renderCapacity,
|
|
12
|
-
renderProviderUsage,
|
|
13
13
|
renderSpeedFooter,
|
|
14
14
|
renderWorkingMessage,
|
|
15
|
-
setCapacityStatus,
|
|
16
|
-
setProviderStatus,
|
|
17
15
|
setSpeedStatus,
|
|
18
16
|
} from "./render.ts";
|
|
19
17
|
import { SpeedAnimator, SpeedTracker } from "./speed.ts";
|
|
@@ -49,37 +47,41 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
|
|
|
49
47
|
let footerTimer: ReturnType<typeof setInterval> | undefined;
|
|
50
48
|
let capacityTimer: ReturnType<typeof setInterval> | undefined;
|
|
51
49
|
let providerTimer: ReturnType<typeof setInterval> | undefined;
|
|
52
|
-
let
|
|
50
|
+
let lastProviderUsage: ProviderUsage | null = null;
|
|
53
51
|
let currentProvider: string | undefined;
|
|
52
|
+
let currentThinkingLevel: string | undefined;
|
|
54
53
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
return text.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
54
|
+
function themeOf(ctx: ExtensionContext) {
|
|
55
|
+
return asCrewTheme(ctx.hasUI ? ctx.ui.theme : undefined);
|
|
58
56
|
}
|
|
59
57
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const padding = Math.max(2, cols - visibleLen(left) - visibleLen(right));
|
|
66
|
-
return left + "\u00A0".repeat(padding) + right;
|
|
67
|
-
}
|
|
58
|
+
const footerSource: CrewVibesFooterSource = {
|
|
59
|
+
getConfig: () => config,
|
|
60
|
+
getQuotaUsage: () => lastProviderUsage,
|
|
61
|
+
getThinkingLevel: () => currentThinkingLevel,
|
|
62
|
+
};
|
|
68
63
|
|
|
69
|
-
|
|
70
|
-
|
|
64
|
+
/** Whether the custom footer has crew-vibes meters to add on top of pi's stats. */
|
|
65
|
+
function metersActive(): boolean {
|
|
66
|
+
return config.enabled && (config.capacity.enabled || config.capacity.providerUsage);
|
|
71
67
|
}
|
|
72
68
|
|
|
73
|
-
|
|
69
|
+
/** Install our custom footer, or restore pi's built-in footer when idle.
|
|
70
|
+
* Unlike setStatus (joined + right-truncated), the footer's render(width)
|
|
71
|
+
* gets the real width and owns line layout, so the quota is never chopped. */
|
|
72
|
+
function installFooter(ctx: ExtensionContext): void {
|
|
74
73
|
if (!ctx?.hasUI) return;
|
|
75
|
-
if (!
|
|
76
|
-
|
|
74
|
+
if (!metersActive()) {
|
|
75
|
+
setFooter(ctx, undefined);
|
|
77
76
|
return;
|
|
78
77
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
78
|
+
setFooter(ctx, (tui, theme, footerData) => createCrewVibesFooter({ tui, theme, footerData, ctx, source: footerSource }));
|
|
79
|
+
requestRender(ctx);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Trigger a footer repaint; the footer recomputes capacity/quota on render. */
|
|
83
|
+
function refreshFooter(ctx: ExtensionContext): void {
|
|
84
|
+
if (ctx?.hasUI) requestRender(ctx);
|
|
83
85
|
}
|
|
84
86
|
|
|
85
87
|
function publishSpeedFooter(ctx: ExtensionContext, speed = footerAnimator.value()): void {
|
|
@@ -164,34 +166,36 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
|
|
|
164
166
|
function startCapacityTimer(ctx: ExtensionContext): void {
|
|
165
167
|
if (capacityTimer) return;
|
|
166
168
|
const interval = Math.max(250, config.capacity.refreshIntervalMs);
|
|
167
|
-
capacityTimer = setInterval(() =>
|
|
169
|
+
capacityTimer = setInterval(() => refreshFooter(ctx), interval);
|
|
168
170
|
capacityTimer.unref?.();
|
|
169
171
|
}
|
|
170
172
|
|
|
173
|
+
/** Fetch provider usage for currentProvider and refresh the footer.
|
|
174
|
+
* Called on start, on each timer tick, and immediately when the
|
|
175
|
+
* provider changes (model_select) so the quota reflects the new
|
|
176
|
+
* provider without waiting for the next 5-minute tick. */
|
|
177
|
+
async function fetchProviderAndRefresh(ctx: ExtensionContext): Promise<void> {
|
|
178
|
+
if (!config.enabled || !config.capacity.providerUsage) {
|
|
179
|
+
lastProviderUsage = null;
|
|
180
|
+
refreshFooter(ctx);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
lastProviderUsage = await fetchProviderUsage(config.capacity.providerRefreshMs, currentProvider);
|
|
185
|
+
} catch {
|
|
186
|
+
// Never crash on provider fetch failure
|
|
187
|
+
lastProviderUsage = null;
|
|
188
|
+
}
|
|
189
|
+
refreshFooter(ctx);
|
|
190
|
+
}
|
|
191
|
+
|
|
171
192
|
function startProviderTimer(ctx: ExtensionContext): void {
|
|
172
193
|
if (providerTimer) return;
|
|
173
194
|
if (!config.capacity.providerUsage) return;
|
|
174
195
|
const interval = Math.max(10000, config.capacity.providerRefreshMs);
|
|
175
196
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
stopProviderTimer();
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
181
|
-
try {
|
|
182
|
-
const usage = await fetchProviderUsage(config.capacity.providerRefreshMs, currentProvider);
|
|
183
|
-
lastProviderText = renderProviderUsage(themeOf(ctx), usage);
|
|
184
|
-
// Re-render combined capacity + provider line
|
|
185
|
-
publishCapacity(ctx);
|
|
186
|
-
} catch {
|
|
187
|
-
// Never crash on provider fetch failure
|
|
188
|
-
lastProviderText = undefined;
|
|
189
|
-
publishCapacity(ctx);
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
tick(); // Fetch immediately on start
|
|
194
|
-
providerTimer = setInterval(tick, interval);
|
|
197
|
+
fetchProviderAndRefresh(ctx); // Fetch immediately on start
|
|
198
|
+
providerTimer = setInterval(() => fetchProviderAndRefresh(ctx), interval);
|
|
195
199
|
providerTimer.unref?.();
|
|
196
200
|
}
|
|
197
201
|
|
|
@@ -209,11 +213,13 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
|
|
|
209
213
|
stopFooterTimer();
|
|
210
214
|
stopCapacityTimer();
|
|
211
215
|
stopProviderTimer();
|
|
216
|
+
setFooter(ctx, undefined);
|
|
212
217
|
clearVibesStatus(ctx);
|
|
213
218
|
return;
|
|
214
219
|
}
|
|
215
|
-
|
|
220
|
+
installFooter(ctx);
|
|
216
221
|
publishSpeedFooter(ctx);
|
|
222
|
+
startCapacityTimer(ctx);
|
|
217
223
|
if (config.capacity.providerUsage) startProviderTimer(ctx);
|
|
218
224
|
}
|
|
219
225
|
|
|
@@ -230,11 +236,13 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
|
|
|
230
236
|
clearProviderUsageCache();
|
|
231
237
|
// Initialize provider from current model — model_select only fires on manual switch
|
|
232
238
|
currentProvider = (ctx.model as { provider?: string } | undefined)?.provider;
|
|
239
|
+
currentThinkingLevel = undefined;
|
|
233
240
|
if (!config.enabled) {
|
|
241
|
+
setFooter(ctx, undefined);
|
|
234
242
|
clearVibesStatus(ctx);
|
|
235
243
|
return;
|
|
236
244
|
}
|
|
237
|
-
|
|
245
|
+
installFooter(ctx);
|
|
238
246
|
publishSpeedFooter(ctx);
|
|
239
247
|
startCapacityTimer(ctx);
|
|
240
248
|
startProviderTimer(ctx);
|
|
@@ -285,7 +293,7 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
|
|
|
285
293
|
|
|
286
294
|
pi.on("message_end", (event, ctx) => {
|
|
287
295
|
if (!isAssistantMessage(event.message)) return;
|
|
288
|
-
|
|
296
|
+
refreshFooter(ctx);
|
|
289
297
|
if (!config.enabled || !config.speed.enabled || !speedTracker.isStreaming) return;
|
|
290
298
|
|
|
291
299
|
const completed = speedTracker.finishMessage(assistantUsageOutput(event.message) ?? 0, assistantStopReason(event.message));
|
|
@@ -314,16 +322,23 @@ export function registerCrewVibes(pi: ExtensionAPI): void {
|
|
|
314
322
|
pi.on("model_select", (event, ctx) => {
|
|
315
323
|
currentProvider = (event as { model?: { provider?: string } }).model?.provider;
|
|
316
324
|
clearProviderUsageCache();
|
|
317
|
-
|
|
325
|
+
// Fetch immediately so the quota reflects the new provider without
|
|
326
|
+
// waiting for the next 5-minute timer tick.
|
|
327
|
+
fetchProviderAndRefresh(ctx);
|
|
328
|
+
});
|
|
329
|
+
pi.on("thinking_level_select", (event, ctx) => {
|
|
330
|
+
currentThinkingLevel = (event as { level?: unknown }).level as string | undefined;
|
|
331
|
+
refreshFooter(ctx);
|
|
318
332
|
});
|
|
319
|
-
pi.on("session_compact", (_event, ctx) =>
|
|
320
|
-
pi.on("session_tree", (_event, ctx) =>
|
|
333
|
+
pi.on("session_compact", (_event, ctx) => refreshFooter(ctx));
|
|
334
|
+
pi.on("session_tree", (_event, ctx) => refreshFooter(ctx));
|
|
321
335
|
|
|
322
336
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
323
337
|
stopLiveTimer();
|
|
324
338
|
stopFooterTimer();
|
|
325
339
|
stopCapacityTimer();
|
|
326
340
|
stopProviderTimer();
|
|
341
|
+
setFooter(ctx, undefined);
|
|
327
342
|
clearVibesStatus(ctx);
|
|
328
343
|
});
|
|
329
344
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
1
|
import * as fs from "node:fs";
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
|
|
4
4
|
export const PI_TEAMS_INHERIT_PROJECT_CONTEXT_ENV = "PI_TEAMS_INHERIT_PROJECT_CONTEXT";
|
|
5
5
|
export const PI_TEAMS_INHERIT_SKILLS_ENV = "PI_TEAMS_INHERIT_SKILLS";
|
package/src/runtime/child-pi.ts
CHANGED
|
@@ -80,6 +80,22 @@ function clearHardKillTimer(pid: number | undefined): void {
|
|
|
80
80
|
childHardKillTimers.delete(pid);
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* B6: spawn taskkill and attach an 'error' listener. spawn() emits ENOENT/EACCES
|
|
85
|
+
* asynchronously via the 'error' event (not as a throw), so an unlistened spawn
|
|
86
|
+
* can crash the parent as an uncaught exception. taskkill is a standard Windows
|
|
87
|
+
* binary so this is defensive, but the listener keeps failures bounded.
|
|
88
|
+
*/
|
|
89
|
+
function spawnTaskkillSafe(pid: number): void {
|
|
90
|
+
const taskkillChild = spawn("taskkill", ["/pid", String(pid), "/t", "/f"], {
|
|
91
|
+
stdio: "ignore",
|
|
92
|
+
windowsHide: true,
|
|
93
|
+
});
|
|
94
|
+
taskkillChild.on("error", (err) => {
|
|
95
|
+
logInternalError("child-pi.taskkill-spawn-error", err instanceof Error ? err : new Error(String(err)), `pid=${pid}`);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
83
99
|
export function killProcessPid(pid: number): void {
|
|
84
100
|
if (!Number.isInteger(pid) || pid <= 0) return;
|
|
85
101
|
try {
|
|
@@ -87,10 +103,7 @@ export function killProcessPid(pid: number): void {
|
|
|
87
103
|
// 3.8: Windows path uses taskkill /T /F (force kill the entire tree).
|
|
88
104
|
// taskkill itself can silently fail (PID gone, permission denied, etc.)
|
|
89
105
|
// so verify after 2s and log a warning if the process is still alive.
|
|
90
|
-
|
|
91
|
-
stdio: "ignore",
|
|
92
|
-
windowsHide: true,
|
|
93
|
-
});
|
|
106
|
+
spawnTaskkillSafe(pid);
|
|
94
107
|
const verifyTimer = setTimeout(() => {
|
|
95
108
|
try {
|
|
96
109
|
process.kill(pid, 0); // throws ESRCH when dead
|
|
@@ -101,10 +114,7 @@ export function killProcessPid(pid: number): void {
|
|
|
101
114
|
`pid=${pid}`,
|
|
102
115
|
);
|
|
103
116
|
try {
|
|
104
|
-
|
|
105
|
-
stdio: "ignore",
|
|
106
|
-
windowsHide: true,
|
|
107
|
-
});
|
|
117
|
+
spawnTaskkillSafe(pid);
|
|
108
118
|
} catch {
|
|
109
119
|
/* best-effort */
|
|
110
120
|
}
|
|
@@ -894,6 +904,20 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
894
904
|
});
|
|
895
905
|
// Pass steering file path to child for real-time steer injection
|
|
896
906
|
if (input.steeringFile) built.env.PI_CREW_STEERING_FILE = input.steeringFile;
|
|
907
|
+
// B5: if the parent already aborted before we spawn, do not start the child
|
|
908
|
+
// at all. Spawning a doomed process wastes resources, and the abort listener
|
|
909
|
+
// registered below will not re-fire for an already-aborted signal (so the
|
|
910
|
+
// child would only be killed later by the response-timeout path). Return a
|
|
911
|
+
// cancelled-style result immediately.
|
|
912
|
+
if (input.signal?.aborted) {
|
|
913
|
+
return {
|
|
914
|
+
exitCode: null,
|
|
915
|
+
stdout: "",
|
|
916
|
+
stderr: "",
|
|
917
|
+
error: "Aborted before spawn (parent AbortSignal already aborted)",
|
|
918
|
+
aborted: true,
|
|
919
|
+
};
|
|
920
|
+
}
|
|
897
921
|
const spawnSpec = getPiSpawnCommand(built.args);
|
|
898
922
|
try {
|
|
899
923
|
return await new Promise<ChildPiRunResult>((resolve) => {
|
|
@@ -1129,45 +1153,30 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
1129
1153
|
turnCount += 1;
|
|
1130
1154
|
if (maxTurns !== undefined && !softLimitReached && turnCount >= maxTurns) {
|
|
1131
1155
|
softLimitReached = true;
|
|
1132
|
-
//
|
|
1133
|
-
//
|
|
1134
|
-
//
|
|
1135
|
-
//
|
|
1136
|
-
//
|
|
1137
|
-
//
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
// advisory; if the worker ignores it and runs past maxTurns +
|
|
1151
|
-
// graceTurns, the hard-abort below terminates it.
|
|
1156
|
+
// C8: deliver the "wrap up" advisory by appending to the steering JSONL
|
|
1157
|
+
// file the child polls (PI_CREW_STEERING_FILE). The child is spawned with
|
|
1158
|
+
// stdio:["ignore",...], so child.stdin is null and the old stdin branch was
|
|
1159
|
+
// dead code that only spammed logs on every soft-limit hit. Advisory only —
|
|
1160
|
+
// the hard-abort below at maxTurns + graceTurns is the real enforcement, so
|
|
1161
|
+
// a failed write must NOT kill the worker.
|
|
1162
|
+
if (input.steeringFile) {
|
|
1163
|
+
try {
|
|
1164
|
+
fs.appendFileSync(
|
|
1165
|
+
input.steeringFile,
|
|
1166
|
+
JSON.stringify({
|
|
1167
|
+
type: "steer",
|
|
1168
|
+
message:
|
|
1169
|
+
"You have reached your turn limit. Wrap up immediately — provide your final answer now.",
|
|
1170
|
+
}) + "\n",
|
|
1171
|
+
"utf-8",
|
|
1172
|
+
);
|
|
1173
|
+
} catch (err) {
|
|
1152
1174
|
logInternalError(
|
|
1153
|
-
"child-pi.steer-
|
|
1154
|
-
new Error(
|
|
1155
|
-
"stdin write returned false (normal backpressure); steer buffered, worker NOT killed",
|
|
1156
|
-
),
|
|
1175
|
+
"child-pi.steer-write-failed",
|
|
1176
|
+
err instanceof Error ? err : new Error(String(err)),
|
|
1157
1177
|
`pid=${child.pid}`,
|
|
1158
1178
|
);
|
|
1159
1179
|
}
|
|
1160
|
-
} else {
|
|
1161
|
-
// stdin closed (worker already finished) or otherwise unwritable.
|
|
1162
|
-
// Also advisory — the worker is done or nearly done; let it exit
|
|
1163
|
-
// naturally. Hard-abort remains the safety net for true runaways.
|
|
1164
|
-
logInternalError(
|
|
1165
|
-
"child-pi.steer-not-writable",
|
|
1166
|
-
new Error(
|
|
1167
|
-
"stdin not writable when attempting steer injection (worker may be done); worker NOT killed",
|
|
1168
|
-
),
|
|
1169
|
-
`pid=${child.pid}`,
|
|
1170
|
-
);
|
|
1171
1180
|
}
|
|
1172
1181
|
} else if (maxTurns !== undefined && softLimitReached && turnCount >= maxTurns + (graceTurns ?? 5)) {
|
|
1173
1182
|
// Hard abort — terminate after grace turns
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as crypto from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { extractSignaturePayload, isHmacEnabled, verifyRpcSignature } from "../extension/rpc-hmac.ts";
|
|
3
3
|
|
|
4
4
|
export interface EventBus {
|
|
5
5
|
on(event: string, handler: (data: unknown) => void): () => void;
|