pi-cockpit 0.18.0 → 0.19.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 +2 -2
- package/package.json +2 -2
- package/src/ambient.ts +48 -0
- package/src/config.ts +3 -1
- package/src/editor/input-history-store.ts +43 -11
- package/src/icons.ts +4 -6
- package/src/index.ts +28 -8
- package/src/public/v1/events.ts +5 -0
- package/src/settings/cockpit-provider.ts +4 -4
- package/src/todo-store.ts +16 -1
- package/src/types.ts +1 -1
- package/src/usage/extension.ts +997 -924
package/README.md
CHANGED
|
@@ -120,7 +120,7 @@ The tab title is `frame + pi - <session> - <working state>`. The session part fo
|
|
|
120
120
|
- `theme`: named theme override; empty string follows the Pi session theme.
|
|
121
121
|
- `usage.enabled`: master switch for the usage bars (quota, balance, spend). On by default; off hides the footer segment and disables the `/usage` command. Ports the [`hknet/pi-usage-bars`](https://github.com/hknet/pi-usage-bars) extension.
|
|
122
122
|
- `usage.footer`: when `true`, render the live quota bar on a dedicated footer line. The `/usage` command works regardless of this toggle. On by default.
|
|
123
|
-
- `usage.pollIntervalMs`: how often to refresh usage data, in milliseconds. Clamped to `30000`..`1800000` (30s..30min)
|
|
123
|
+
- `usage.pollIntervalMs`: how often to refresh usage data, in milliseconds. Clamped to `30000`..`1800000` (30s..30min); `0` switches to manual refresh — no background polling and no footer bar; the `/usage` overlay fetches on open and re-fetches with `r`. Default `120000` (2min). Lower values hit provider APIs more often.
|
|
124
124
|
- `usage.barWidth`: characters per quota bar in the footer. Clamped to `4`..`16`. Default `8`.
|
|
125
125
|
- `usage.commandKey`: the `/`-command that opens the usage selector overlay. Default `"usage"`. Requires `/reload` to take effect (commands register at session start).
|
|
126
126
|
|
|
@@ -136,7 +136,7 @@ The tab title is `frame + pi - <session> - <working state>`. The session part fo
|
|
|
136
136
|
- `/cockpit sidebar auto|on|off` — selects dock behavior.
|
|
137
137
|
- `/cockpit sidebar resize` or `Ctrl+Shift+R` — enters temporary Resize mode. Left/Right adjusts one column, Shift+Left/Shift+Right adjusts four, Enter accepts, and Escape rolls back. Mouse reporting is active only during Resize mode.
|
|
138
138
|
- `/cockpit quiet` — toggles quiet mode; `/cockpit bg` shows background jobs.
|
|
139
|
-
- `/usage` — opens the usage selector overlay: quota, balance, and spend for every configured provider, with the active one marked `✓`. Search filters by name; arrows/`j`/`k` move, Enter expands the selected provider, Esc closes. Expanding a provider also shows its **7-day token trend** — a sparkline of daily token usage, a `total tok · $cost · turns/day` summary, and the top 2 models by token share — read from the optional `pi-maestro-flow` usage-history store (`~/.pi/agent/usage-history`), so it appears once that extension has recorded turns and is omitted on bare Pi. The command key is configurable via `usage.commandKey` (default `"usage"`, requires `/reload`). Disabled while `usage.enabled` is off.
|
|
139
|
+
- `/usage` — opens the usage selector overlay: quota, balance, and spend for every configured provider, with the active one marked `✓`. Search filters by name; arrows/`j`/`k` move, Enter expands the selected provider, Esc closes; `r` (with an empty search box) re-fetches all providers; `p` (with an empty search box) toggles background polling — persisted to `usage.pollIntervalMs` (off writes `0`, on restores the default `120000`), so the footer bar starts/stops. Expanding a provider also shows its **7-day token trend** — a sparkline of daily token usage, a `total tok · $cost · turns/day` summary, and the top 2 models by token share — read from the optional `pi-maestro-flow` usage-history store (`~/.pi/agent/usage-history`), so it appears once that extension has recorded turns and is omitted on bare Pi. The command key is configurable via `usage.commandKey` (default `"usage"`, requires `/reload`). Disabled while `usage.enabled` is off.
|
|
140
140
|
- `/theme` — switch theme with live preview; `/theme <name>` applies directly. `cockpit-zen` is the restrained warm-gold theme designed for the Zen stack; selecting it does not change `stackStyle`. Pi ships no standalone theme command; cockpit provides one.
|
|
141
141
|
|
|
142
142
|
## Sidebar compatibility
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-cockpit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "Responsive Maestro operations sidebar, fallback status widgets, and footer for Pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
"@earendil-works/pi-coding-agent": "0.83.0",
|
|
73
73
|
"@earendil-works/pi-tui": "0.83.0",
|
|
74
74
|
"@types/node": "^22.0.0",
|
|
75
|
-
"pi-maestro-teammate": "2.
|
|
75
|
+
"pi-maestro-teammate": "2.2.0",
|
|
76
76
|
"typebox": "1.3.7",
|
|
77
77
|
"typescript": "^5.5.0"
|
|
78
78
|
},
|
package/src/ambient.ts
CHANGED
|
@@ -135,3 +135,51 @@ export function titleFor(
|
|
|
135
135
|
export function statusText(problem: string | undefined, mark: string): string | undefined {
|
|
136
136
|
return problem ? `${mark} cockpit: ${problem}` : undefined;
|
|
137
137
|
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Some host UI setters invalidate the whole TUI even when the value did not
|
|
141
|
+
* change. Cache the last ambient values so the 250ms Cockpit tick only mutates
|
|
142
|
+
* host surfaces when there is a real text change.
|
|
143
|
+
*/
|
|
144
|
+
export class AmbientSurfaceCache {
|
|
145
|
+
private workingMessageKnown = false;
|
|
146
|
+
private workingMessage: string | undefined;
|
|
147
|
+
private titleKnown = false;
|
|
148
|
+
private title: string | undefined;
|
|
149
|
+
private readonly statuses = new Map<string, string | undefined>();
|
|
150
|
+
|
|
151
|
+
reset(): void {
|
|
152
|
+
this.workingMessageKnown = false;
|
|
153
|
+
this.workingMessage = undefined;
|
|
154
|
+
this.titleKnown = false;
|
|
155
|
+
this.title = undefined;
|
|
156
|
+
this.statuses.clear();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
setWorkingMessage(setter: (message: string | undefined) => void, message: string | undefined): boolean {
|
|
160
|
+
if (this.workingMessageKnown && this.workingMessage === message) return false;
|
|
161
|
+
this.workingMessageKnown = true;
|
|
162
|
+
this.workingMessage = message;
|
|
163
|
+
setter(message);
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
setTitle(setter: (title: string) => void, title: string): boolean {
|
|
168
|
+
if (this.titleKnown && this.title === title) return false;
|
|
169
|
+
this.titleKnown = true;
|
|
170
|
+
this.title = title;
|
|
171
|
+
setter(title);
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
setStatus(
|
|
176
|
+
key: string,
|
|
177
|
+
setter: (key: string, text: string | undefined) => void,
|
|
178
|
+
text: string | undefined,
|
|
179
|
+
): boolean {
|
|
180
|
+
if (this.statuses.has(key) && this.statuses.get(key) === text) return false;
|
|
181
|
+
this.statuses.set(key, text);
|
|
182
|
+
setter(key, text);
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -23,7 +23,9 @@ function mergeUsageConfig(base: UsageConfig, raw: unknown): UsageConfig {
|
|
|
23
23
|
const isFiniteInt = (v: unknown): v is number =>
|
|
24
24
|
typeof v === "number" && Number.isFinite(v) && Number.isSafeInteger(v);
|
|
25
25
|
const pollIntervalMs = isFiniteInt(o.pollIntervalMs)
|
|
26
|
-
?
|
|
26
|
+
? o.pollIntervalMs <= 0
|
|
27
|
+
? 0 // 0 = manual refresh: no background poll, /usage fetches on open and on `r`
|
|
28
|
+
: Math.min(USAGE_POLL_MAX_MS, Math.max(USAGE_POLL_MIN_MS, o.pollIntervalMs))
|
|
27
29
|
: base.pollIntervalMs;
|
|
28
30
|
const barWidth = isFiniteInt(o.barWidth)
|
|
29
31
|
? Math.min(USAGE_BAR_MAX_WIDTH, Math.max(USAGE_BAR_MIN_WIDTH, o.barWidth))
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* so a brand new session picks up where the previous one left off.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { createHash } from "node:crypto";
|
|
10
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
11
11
|
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
12
12
|
import { homedir } from "node:os";
|
|
13
13
|
import { basename, join, resolve } from "node:path";
|
|
@@ -19,6 +19,9 @@ const DEFAULT_MAX_ENTRIES = 100;
|
|
|
19
19
|
const DEFAULT_SAVE_DEBOUNCE_MS = 250;
|
|
20
20
|
const PRIVATE_DIRECTORY_MODE = 0o700;
|
|
21
21
|
const PRIVATE_FILE_MODE = 0o600;
|
|
22
|
+
const DEFAULT_SAVE_RETRY_DELAYS_MS = Object.freeze([25, 75, 150]);
|
|
23
|
+
|
|
24
|
+
type RenameFile = (oldPath: string, newPath: string) => Promise<void>;
|
|
22
25
|
|
|
23
26
|
/**
|
|
24
27
|
* Serializes every save across store instances so a fresh store's load() never
|
|
@@ -32,6 +35,8 @@ export interface InputHistoryStoreOptions {
|
|
|
32
35
|
maxEntries?: number;
|
|
33
36
|
debounceMs?: number;
|
|
34
37
|
onError?: (error: unknown) => void;
|
|
38
|
+
renameFile?: RenameFile;
|
|
39
|
+
retryDelaysMs?: readonly number[];
|
|
35
40
|
}
|
|
36
41
|
|
|
37
42
|
/**
|
|
@@ -59,6 +64,8 @@ export class InputHistoryStore {
|
|
|
59
64
|
private readonly maxEntries: number;
|
|
60
65
|
private readonly debounceMs: number;
|
|
61
66
|
private readonly onError: ((error: unknown) => void) | undefined;
|
|
67
|
+
private readonly renameFile: RenameFile;
|
|
68
|
+
private readonly retryDelaysMs: readonly number[];
|
|
62
69
|
private entries: string[] = [];
|
|
63
70
|
private timer: NodeJS.Timeout | undefined;
|
|
64
71
|
private writes: Promise<void> = Promise.resolve();
|
|
@@ -70,6 +77,8 @@ export class InputHistoryStore {
|
|
|
70
77
|
this.maxEntries = Math.max(1, options.maxEntries ?? DEFAULT_MAX_ENTRIES);
|
|
71
78
|
this.debounceMs = Math.max(0, options.debounceMs ?? DEFAULT_SAVE_DEBOUNCE_MS);
|
|
72
79
|
this.onError = options.onError;
|
|
80
|
+
this.renameFile = options.renameFile ?? rename;
|
|
81
|
+
this.retryDelaysMs = retryDelays(options.retryDelaysMs);
|
|
73
82
|
}
|
|
74
83
|
|
|
75
84
|
/** Newest first. */
|
|
@@ -124,21 +133,30 @@ export class InputHistoryStore {
|
|
|
124
133
|
}
|
|
125
134
|
|
|
126
135
|
private async save(): Promise<void> {
|
|
136
|
+
await mkdir(this.dir, { recursive: true, mode: PRIVATE_DIRECTORY_MODE });
|
|
137
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
138
|
+
await this.mergeDiskEntries();
|
|
139
|
+
const temp = `${this.filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
140
|
+
try {
|
|
141
|
+
const payload = JSON.stringify({ version: 1, entries: this.entries }, null, 2);
|
|
142
|
+
await writeFile(temp, payload, { mode: PRIVATE_FILE_MODE, flag: "wx" });
|
|
143
|
+
await this.renameFile(temp, this.filePath);
|
|
144
|
+
return;
|
|
145
|
+
} catch (error) {
|
|
146
|
+
await rm(temp, { force: true }).catch(() => undefined);
|
|
147
|
+
const retryDelay = this.retryDelaysMs[attempt];
|
|
148
|
+
if (retryDelay === undefined || !isTransientReplaceError(error)) throw error;
|
|
149
|
+
await delay(retryDelay);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private async mergeDiskEntries(): Promise<void> {
|
|
127
155
|
// Another pi window in the same workspace may have written since we loaded;
|
|
128
156
|
// keep whatever it added instead of overwriting it.
|
|
129
157
|
const known = new Set(this.entries);
|
|
130
158
|
const disk = (await this.readFromDisk()).filter((value) => !known.has(value));
|
|
131
159
|
this.entries = capped([...this.entries, ...disk], this.maxEntries);
|
|
132
|
-
await mkdir(this.dir, { recursive: true, mode: PRIVATE_DIRECTORY_MODE });
|
|
133
|
-
const temp = `${this.filePath}.${process.pid}.tmp`;
|
|
134
|
-
try {
|
|
135
|
-
const payload = JSON.stringify({ version: 1, entries: this.entries }, null, 2);
|
|
136
|
-
await writeFile(temp, payload, { mode: PRIVATE_FILE_MODE });
|
|
137
|
-
await rename(temp, this.filePath);
|
|
138
|
-
} catch (error) {
|
|
139
|
-
await rm(temp, { force: true });
|
|
140
|
-
throw error;
|
|
141
|
-
}
|
|
142
160
|
}
|
|
143
161
|
|
|
144
162
|
private async readFromDisk(): Promise<string[]> {
|
|
@@ -171,3 +189,17 @@ function parseEntries(raw: unknown): string[] {
|
|
|
171
189
|
function capped(entries: string[], maxEntries: number): string[] {
|
|
172
190
|
return entries.length > maxEntries ? entries.slice(0, maxEntries) : entries;
|
|
173
191
|
}
|
|
192
|
+
|
|
193
|
+
function retryDelays(value: readonly number[] | undefined): readonly number[] {
|
|
194
|
+
const source = value ?? DEFAULT_SAVE_RETRY_DELAYS_MS;
|
|
195
|
+
return Object.freeze(source.map((delayMs) => Math.max(0, delayMs)).filter(Number.isFinite));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function isTransientReplaceError(error: unknown): boolean {
|
|
199
|
+
const code = (error as NodeJS.ErrnoException | null)?.code;
|
|
200
|
+
return code === "EPERM" || code === "EBUSY" || code === "EACCES";
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function delay(ms: number): Promise<void> {
|
|
204
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
205
|
+
}
|
package/src/icons.ts
CHANGED
|
@@ -149,13 +149,11 @@ export function resolveGlyphs(mode: IconMode): IconGlyphs {
|
|
|
149
149
|
/**
|
|
150
150
|
* One source of truth for animation cadence.
|
|
151
151
|
*
|
|
152
|
-
* The frame clock and
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
* 4-frame ASCII spinner degenerated into a two-glyph flip (| ↔ -), which reads
|
|
156
|
-
* as blinking rather than rotation.
|
|
152
|
+
* The frame clock and redraw tick stay aligned so terminal animation advances
|
|
153
|
+
* one frame per paint. A half-second cadence remains legible without keeping
|
|
154
|
+
* the terminal renderer busy during long-running work.
|
|
157
155
|
*/
|
|
158
|
-
export const ANIMATION_PERIOD_MS =
|
|
156
|
+
export const ANIMATION_PERIOD_MS = 500;
|
|
159
157
|
|
|
160
158
|
/**
|
|
161
159
|
* The frame to draw at `now`, or a stable glyph when nothing is driving redraws.
|
package/src/index.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { TUI } from "@earendil-works/pi-tui";
|
|
|
5
5
|
import { ambientKeysShouldYield, capturingOverlayVisible } from "./capturing-overlay.ts";
|
|
6
6
|
import { Key, decodeKittyPrintable, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
7
7
|
import { AgentReadStoreRouter, effectiveAgentStatus, type CompletePayload, type MessagePayload, type StartedPayload } from "./agents-store.ts";
|
|
8
|
-
import { statusText, titleFor, workingMessage, type AmbientState } from "./ambient.ts";
|
|
8
|
+
import { AmbientSurfaceCache, statusText, titleFor, workingMessage, type AmbientState } from "./ambient.ts";
|
|
9
9
|
import { generateTitleWithModel } from "./title-llm.ts";
|
|
10
10
|
import { suggestTitle } from "./title-gen.ts";
|
|
11
11
|
import { BashBgStore } from "./bash-bg-store.ts";
|
|
@@ -89,6 +89,7 @@ import {
|
|
|
89
89
|
COCKPIT_PREEMPT_RESIZE_EVENT,
|
|
90
90
|
COCKPIT_SESSION_LIST_EVENT,
|
|
91
91
|
COCKPIT_TODO_TOGGLE_EVENT,
|
|
92
|
+
MAESTRO_TODO_STATE_CHANGED_EVENT,
|
|
92
93
|
MAESTRO_UI_SNAPSHOT_EVENT,
|
|
93
94
|
MAESTRO_UI_SNAPSHOT_VERSION,
|
|
94
95
|
SUPERVISION_EVENT,
|
|
@@ -152,7 +153,7 @@ const COCKPIT_STATUS_KEY = "cockpit";
|
|
|
152
153
|
// a turn runs (screens/REPL.tsx TITLE_STATIC_PREFIX / TITLE_ANIMATION_FRAMES).
|
|
153
154
|
const TITLE_STATIC = "✳";
|
|
154
155
|
const TITLE_FRAMES = ["⠂", "⠐"];
|
|
155
|
-
const WIDTH_POLL_INTERVAL_MS =
|
|
156
|
+
const WIDTH_POLL_INTERVAL_MS = 500;
|
|
156
157
|
// Static mode keeps token totals fresh enough without recomputing on every message.
|
|
157
158
|
const USAGE_REFRESH_THROTTLE_MS = 10_000;
|
|
158
159
|
// Quiet mode's rename of pi's hidden-thinking label. The live thinking timer
|
|
@@ -416,6 +417,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
416
417
|
// Persisted rather than toasted: a config that failed to load silently downgrades
|
|
417
418
|
// the whole session to defaults, so it belongs in a slot that does not scroll away.
|
|
418
419
|
let configProblem: string | undefined;
|
|
420
|
+
const ambientSurfaces = new AmbientSurfaceCache();
|
|
419
421
|
let quietToolsRegistered = false;
|
|
420
422
|
|
|
421
423
|
// Register quiet tools at extension load time, not just in session_start.
|
|
@@ -483,8 +485,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
483
485
|
const g = resolveGlyphs(config.icons.mode);
|
|
484
486
|
try {
|
|
485
487
|
if (!config.enabled) {
|
|
486
|
-
ctx.ui.setWorkingMessage(undefined);
|
|
487
|
-
ctx.ui.setStatus(
|
|
488
|
+
ambientSurfaces.setWorkingMessage((message) => ctx.ui.setWorkingMessage(message), undefined);
|
|
489
|
+
ambientSurfaces.setStatus(COCKPIT_STATUS_KEY, (key, text) => ctx.ui.setStatus(key, text), undefined);
|
|
488
490
|
return;
|
|
489
491
|
}
|
|
490
492
|
const activeTool = [...activeTools.values()].at(-1);
|
|
@@ -500,7 +502,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
500
502
|
hideLiveDuration: config.staticMode,
|
|
501
503
|
separator: ` ${g.separator} `,
|
|
502
504
|
};
|
|
503
|
-
ctx.ui.setWorkingMessage(workingMessage(state, now));
|
|
505
|
+
ambientSurfaces.setWorkingMessage((message) => ctx.ui.setWorkingMessage(message), workingMessage(state, now));
|
|
504
506
|
if (title.enabled) {
|
|
505
507
|
state.session = title.showSession ? sessionTag(ctx.sessionManager, aiTitle) : undefined;
|
|
506
508
|
state.model = title.showModel ? modelTag(ctx.model) : undefined;
|
|
@@ -511,9 +513,12 @@ export default function (pi: ExtensionAPI): void {
|
|
|
511
513
|
? maestroKnowledgeTag(maestro.snapshot()?.workflow?.knowledge)
|
|
512
514
|
: undefined;
|
|
513
515
|
state.frame = titleFrame(running);
|
|
514
|
-
|
|
516
|
+
ambientSurfaces.setTitle(
|
|
517
|
+
(value) => ctx.ui.setTitle(value),
|
|
518
|
+
titleFor(state, { ok: g.check, fail: g.cross }, g.separator, { maxLength: title.maxLength }),
|
|
519
|
+
);
|
|
515
520
|
}
|
|
516
|
-
ctx.ui.setStatus(
|
|
521
|
+
ambientSurfaces.setStatus(COCKPIT_STATUS_KEY, (key, text) => ctx.ui.setStatus(key, text), statusText(configProblem, g.blocked));
|
|
517
522
|
} catch {
|
|
518
523
|
// ambient surfaces are best-effort; never let them break a render
|
|
519
524
|
}
|
|
@@ -601,6 +606,12 @@ export default function (pi: ExtensionAPI): void {
|
|
|
601
606
|
pi,
|
|
602
607
|
getConfig: () => config,
|
|
603
608
|
onStatusChange: () => req(),
|
|
609
|
+
setPollIntervalMs: (ms: number) => {
|
|
610
|
+
config = { ...config, usage: { ...config.usage, pollIntervalMs: ms } };
|
|
611
|
+
const result = saveConfig(config);
|
|
612
|
+
req();
|
|
613
|
+
return result.ok;
|
|
614
|
+
},
|
|
604
615
|
});
|
|
605
616
|
const policy = (): TickPolicyState => ({
|
|
606
617
|
staticMode: config.staticMode,
|
|
@@ -1333,6 +1344,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1333
1344
|
} catch {
|
|
1334
1345
|
// ambient surfaces are best-effort
|
|
1335
1346
|
}
|
|
1347
|
+
ambientSurfaces.reset();
|
|
1336
1348
|
stopTick();
|
|
1337
1349
|
viewportStabilityPatch?.detach();
|
|
1338
1350
|
viewportStabilityPatch = undefined;
|
|
@@ -1694,6 +1706,12 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1694
1706
|
if (!maestro.applySnapshot(payload)) return;
|
|
1695
1707
|
req();
|
|
1696
1708
|
}),
|
|
1709
|
+
pi.events.on(MAESTRO_TODO_STATE_CHANGED_EVENT, (payload) => {
|
|
1710
|
+
if (!payload || typeof payload !== "object" || (payload as { version?: unknown }).version !== 1) return;
|
|
1711
|
+
const ctx = lastCtx;
|
|
1712
|
+
if (!ctx || !todos.hydrateFromEntries(ctx.sessionManager.getEntries())) return;
|
|
1713
|
+
req();
|
|
1714
|
+
}),
|
|
1697
1715
|
pi.events.on(SUPERVISION_EVENT, (payload) => {
|
|
1698
1716
|
if (!supervision.applyEvent(payload)) return;
|
|
1699
1717
|
req();
|
|
@@ -1748,6 +1766,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1748
1766
|
// --- session + agent lifecycle ---
|
|
1749
1767
|
pi.on("session_start", (_e, ctx) => {
|
|
1750
1768
|
lastCtx = ctx;
|
|
1769
|
+
ambientSurfaces.reset();
|
|
1751
1770
|
agentReads.bindSession(ctx.sessionManager.getSessionId());
|
|
1752
1771
|
agents = agentReads.current;
|
|
1753
1772
|
usingRuntimeV2 = false;
|
|
@@ -2465,7 +2484,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
2465
2484
|
else usageSubsystem?.stop(ctx);
|
|
2466
2485
|
}
|
|
2467
2486
|
// footer/footer-toggle/barWidth change only the rendered string (the
|
|
2468
|
-
// next render reads them fresh); pollIntervalMs change restarts the
|
|
2487
|
+
// next render reads them fresh); pollIntervalMs change restarts the
|
|
2488
|
+
// poller (0 switches to manual refresh: no timer, no footer bar).
|
|
2469
2489
|
if (previous.usage.pollIntervalMs !== nextConfig.usage.pollIntervalMs && nextConfig.usage.enabled) {
|
|
2470
2490
|
usageSubsystem?.rescheduleTimer();
|
|
2471
2491
|
usageSubsystem?.refresh();
|
package/src/public/v1/events.ts
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
export const COCKPIT_MAESTRO_QUERY_EVENT = "cockpit:maestro-query";
|
|
4
4
|
export const MAESTRO_UI_SNAPSHOT_EVENT = "maestro:ui-snapshot";
|
|
5
5
|
export const MAESTRO_UI_SNAPSHOT_VERSION = 1 as const;
|
|
6
|
+
export const MAESTRO_TODO_STATE_CHANGED_EVENT = "maestro:todo-state-changed";
|
|
7
|
+
export interface MaestroTodoStateChangedV1 {
|
|
8
|
+
version: 1;
|
|
9
|
+
}
|
|
6
10
|
|
|
7
11
|
/**
|
|
8
12
|
* Unified supervision telemetry emitted by the shared supervision layer
|
|
@@ -190,6 +194,7 @@ export interface CockpitUiOwnershipV1 {
|
|
|
190
194
|
export interface MaestroEventMapV1 {
|
|
191
195
|
"cockpit:maestro-query": MaestroQueryV1;
|
|
192
196
|
"maestro:ui-snapshot": MaestroUiSnapshotV1;
|
|
197
|
+
"maestro:todo-state-changed": MaestroTodoStateChangedV1;
|
|
193
198
|
"cockpit:ui-ownership": CockpitUiOwnershipV1;
|
|
194
199
|
"cockpit:open-session-list": CockpitSessionListRequestV1;
|
|
195
200
|
"cockpit:preempt-resize": undefined;
|
|
@@ -211,7 +211,7 @@ const CATALOGS = {
|
|
|
211
211
|
"cockpit.usage.footer": "Show in footer",
|
|
212
212
|
"cockpit.usage.footer.description": "Render the live quota bar on a dedicated footer line. The /usage command works regardless of this toggle.",
|
|
213
213
|
"cockpit.usage.pollIntervalMs": "Poll interval (ms)",
|
|
214
|
-
"cockpit.usage.pollIntervalMs.description": "How often to refresh usage data. Clamped to 30s..30min. Lower values hit provider APIs more often.",
|
|
214
|
+
"cockpit.usage.pollIntervalMs.description": "How often to refresh usage data. Clamped to 30s..30min; 0 = manual refresh only (no polling, no footer bar; /usage fetches on open and with `r`). Lower values hit provider APIs more often.",
|
|
215
215
|
"cockpit.usage.barWidth": "Bar width",
|
|
216
216
|
"cockpit.usage.barWidth.description": "Characters per quota bar in the footer. Clamped to 4..16.",
|
|
217
217
|
"cockpit.usage.commandKey": "Command key",
|
|
@@ -296,7 +296,7 @@ const CATALOGS = {
|
|
|
296
296
|
"cockpit.usage.footer": "在状态栏显示",
|
|
297
297
|
"cockpit.usage.footer.description": "在专属状态栏行渲染实时配额条。/usage 命令不受此开关影响。",
|
|
298
298
|
"cockpit.usage.pollIntervalMs": "轮询间隔(毫秒)",
|
|
299
|
-
"cockpit.usage.pollIntervalMs.description": "刷新用量数据的频率,钳制在 30 秒..30
|
|
299
|
+
"cockpit.usage.pollIntervalMs.description": "刷新用量数据的频率,钳制在 30 秒..30 分钟;0 = 仅手动刷新(不轮询、不显示状态栏;进入 /usage 时抓取并按 r 刷新)。值越小调用供应商 API 越频繁。",
|
|
300
300
|
"cockpit.usage.barWidth": "进度条宽度",
|
|
301
301
|
"cockpit.usage.barWidth.description": "状态栏中每条配额条的字符宽度,钳制在 4..16。",
|
|
302
302
|
"cockpit.usage.commandKey": "命令键",
|
|
@@ -423,7 +423,7 @@ const DEFINITIONS: readonly SettingDefinition[] = [
|
|
|
423
423
|
activation: "live",
|
|
424
424
|
sensitivity: "public",
|
|
425
425
|
reversibility: "full",
|
|
426
|
-
editor: { kind: "integer", min:
|
|
426
|
+
editor: { kind: "integer", min: 0, max: 1_800_000, step: 1000 },
|
|
427
427
|
},
|
|
428
428
|
{
|
|
429
429
|
key: "usage.barWidth",
|
|
@@ -795,7 +795,7 @@ function validValue(key: CockpitSettingKey, value: JsonValue): boolean {
|
|
|
795
795
|
if (key === "title.generationModel") return typeof value === "string";
|
|
796
796
|
if (key.startsWith("title.")) return typeof value === "boolean";
|
|
797
797
|
if (key === "usage.enabled" || key === "usage.footer") return typeof value === "boolean";
|
|
798
|
-
if (key === "usage.pollIntervalMs") return typeof value === "number" && Number.isSafeInteger(value) && value >= 30_000 && value <= 1_800_000;
|
|
798
|
+
if (key === "usage.pollIntervalMs") return typeof value === "number" && Number.isSafeInteger(value) && (value === 0 || (value >= 30_000 && value <= 1_800_000));
|
|
799
799
|
if (key === "usage.barWidth") return typeof value === "number" && Number.isSafeInteger(value) && value >= 4 && value <= 16;
|
|
800
800
|
if (key === "usage.commandKey") return typeof value === "string" && value.trim().length > 0;
|
|
801
801
|
if (key === "quietSymbols") return value === "check" || value === "dot";
|
package/src/todo-store.ts
CHANGED
|
@@ -48,6 +48,17 @@ function asSkills(value: unknown): TodoSkill[] {
|
|
|
48
48
|
return skills;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
function sameActor(left: TodoActor | undefined, right: TodoActor | undefined): boolean {
|
|
52
|
+
return left?.id === right?.id && left?.label === right?.label;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function sameSkills(left: readonly TodoSkill[], right: readonly TodoSkill[]): boolean {
|
|
56
|
+
return left.length === right.length && left.every((skill, index) => {
|
|
57
|
+
const candidate = right[index];
|
|
58
|
+
return candidate !== undefined && skill.name === candidate.name && skill.role === candidate.role;
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
51
62
|
// Map the todo tool's status strings onto our four display states.
|
|
52
63
|
// "deleted" is returned verbatim so the caller can drop the task.
|
|
53
64
|
export function mapStatus(raw: unknown): TodoState | "deleted" {
|
|
@@ -115,7 +126,11 @@ export class TodoStore {
|
|
|
115
126
|
|| item.status !== candidate.status
|
|
116
127
|
|| item.subject !== candidate.subject
|
|
117
128
|
|| item.blockedBy.length !== candidate.blockedBy.length
|
|
118
|
-
|| item.blockedBy.some((id, blockedIndex) => id !== candidate.blockedBy[blockedIndex])
|
|
129
|
+
|| item.blockedBy.some((id, blockedIndex) => id !== candidate.blockedBy[blockedIndex])
|
|
130
|
+
|| !sameActor(item.createdBy, candidate.createdBy)
|
|
131
|
+
|| !sameActor(item.assignee, candidate.assignee)
|
|
132
|
+
|| !sameSkills(item.skills, candidate.skills)
|
|
133
|
+
|| item.updatedAt !== candidate.updatedAt;
|
|
119
134
|
});
|
|
120
135
|
if (!changed) return false;
|
|
121
136
|
this.items = next;
|
package/src/types.ts
CHANGED
|
@@ -221,7 +221,7 @@ export interface UsageConfig {
|
|
|
221
221
|
enabled: boolean;
|
|
222
222
|
/** Show the live quota bar in the Cockpit footer (a dedicated footer line). */
|
|
223
223
|
footer: boolean;
|
|
224
|
-
/** Poll interval in milliseconds; clamped 30s..30min. Default 2min. */
|
|
224
|
+
/** Poll interval in milliseconds; clamped 30s..30min. Default 2min. 0 = manual refresh (no polling, no footer bar; /usage fetches on open and on `r`). */
|
|
225
225
|
pollIntervalMs: number;
|
|
226
226
|
/** Characters per quota bar in the footer; clamped 4..16. Default 8. */
|
|
227
227
|
barWidth: number;
|