@esso0428/pi-subagents 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/CHANGELOG.md +638 -0
  2. package/CONTRIBUTING.md +68 -0
  3. package/LICENSE +21 -0
  4. package/README.md +745 -0
  5. package/SECURITY.md +95 -0
  6. package/dist/agent-manager.d.ts +144 -0
  7. package/dist/agent-manager.js +542 -0
  8. package/dist/agent-runner.d.ts +212 -0
  9. package/dist/agent-runner.js +850 -0
  10. package/dist/agent-types.d.ts +67 -0
  11. package/dist/agent-types.js +168 -0
  12. package/dist/context.d.ts +12 -0
  13. package/dist/context.js +56 -0
  14. package/dist/cross-extension-rpc.d.ts +46 -0
  15. package/dist/cross-extension-rpc.js +76 -0
  16. package/dist/custom-agents.d.ts +17 -0
  17. package/dist/custom-agents.js +156 -0
  18. package/dist/default-agents.d.ts +7 -0
  19. package/dist/default-agents.js +122 -0
  20. package/dist/enabled-models.d.ts +49 -0
  21. package/dist/enabled-models.js +145 -0
  22. package/dist/env.d.ts +6 -0
  23. package/dist/env.js +28 -0
  24. package/dist/group-join.d.ts +32 -0
  25. package/dist/group-join.js +116 -0
  26. package/dist/index.d.ts +16 -0
  27. package/dist/index.js +2209 -0
  28. package/dist/invocation-config.d.ts +22 -0
  29. package/dist/invocation-config.js +15 -0
  30. package/dist/memory.d.ts +53 -0
  31. package/dist/memory.js +165 -0
  32. package/dist/model-resolver.d.ts +19 -0
  33. package/dist/model-resolver.js +80 -0
  34. package/dist/nico-overrides.d.ts +53 -0
  35. package/dist/nico-overrides.js +169 -0
  36. package/dist/output-file.d.ts +24 -0
  37. package/dist/output-file.js +101 -0
  38. package/dist/prompts.d.ts +32 -0
  39. package/dist/prompts.js +73 -0
  40. package/dist/schedule-store.d.ts +38 -0
  41. package/dist/schedule-store.js +155 -0
  42. package/dist/schedule.d.ts +109 -0
  43. package/dist/schedule.js +338 -0
  44. package/dist/settings.d.ts +141 -0
  45. package/dist/settings.js +162 -0
  46. package/dist/skill-loader.d.ts +24 -0
  47. package/dist/skill-loader.js +93 -0
  48. package/dist/status-note.d.ts +13 -0
  49. package/dist/status-note.js +24 -0
  50. package/dist/types.d.ts +197 -0
  51. package/dist/types.js +5 -0
  52. package/dist/ui/agent-widget.d.ts +160 -0
  53. package/dist/ui/agent-widget.js +484 -0
  54. package/dist/ui/conversation-viewer.d.ts +57 -0
  55. package/dist/ui/conversation-viewer.js +354 -0
  56. package/dist/ui/fleet-list.d.ts +106 -0
  57. package/dist/ui/fleet-list.js +345 -0
  58. package/dist/ui/schedule-menu.d.ts +16 -0
  59. package/dist/ui/schedule-menu.js +95 -0
  60. package/dist/ui/viewer-keys.d.ts +20 -0
  61. package/dist/ui/viewer-keys.js +17 -0
  62. package/dist/usage.d.ts +50 -0
  63. package/dist/usage.js +49 -0
  64. package/dist/worktree.d.ts +45 -0
  65. package/dist/worktree.js +160 -0
  66. package/examples/agent-tool-description.md +42 -0
  67. package/package.json +56 -0
  68. package/src/agent-manager.ts +631 -0
  69. package/src/agent-runner.ts +1014 -0
  70. package/src/agent-types.ts +202 -0
  71. package/src/context.ts +58 -0
  72. package/src/cross-extension-rpc.ts +122 -0
  73. package/src/custom-agents.ts +167 -0
  74. package/src/default-agents.ts +126 -0
  75. package/src/enabled-models.ts +180 -0
  76. package/src/env.ts +33 -0
  77. package/src/group-join.ts +141 -0
  78. package/src/index.ts +2400 -0
  79. package/src/invocation-config.ts +40 -0
  80. package/src/memory.ts +179 -0
  81. package/src/model-resolver.ts +100 -0
  82. package/src/nico-overrides.ts +235 -0
  83. package/src/output-file.ts +110 -0
  84. package/src/prompts.ts +99 -0
  85. package/src/schedule-store.ts +153 -0
  86. package/src/schedule.ts +365 -0
  87. package/src/settings.ts +288 -0
  88. package/src/skill-loader.ts +102 -0
  89. package/src/status-note.ts +25 -0
  90. package/src/types.ts +208 -0
  91. package/src/ui/agent-widget.ts +566 -0
  92. package/src/ui/conversation-viewer.ts +362 -0
  93. package/src/ui/fleet-list.ts +380 -0
  94. package/src/ui/schedule-menu.ts +104 -0
  95. package/src/ui/viewer-keys.ts +39 -0
  96. package/src/usage.ts +60 -0
  97. package/src/worktree.ts +191 -0
  98. package/vitest.config.ts +18 -0
@@ -0,0 +1,380 @@
1
+ /**
2
+ * fleet-list.ts — Claude Code-style "FleetView" list rendered below the editor.
3
+ *
4
+ * Shows `main` + each running/queued subagent as a navigable list. Pressing ↓ (or
5
+ * ←) at an empty prompt activates the list; ↑/↓ move the selection (filled ● marker),
6
+ * Enter opens the selected agent's live conversation overlay, Esc returns to the prompt.
7
+ * A viewer stays open when its agent finishes; finished agents linger briefly in the list.
8
+ *
9
+ * Mechanics (see plan): the list is a `belowEditor` widget (render-only), and ALL key
10
+ * handling goes through `onTerminalInput` — which fires before the focused editor and
11
+ * can `consume` keys — gated on `getEditorText() === ""` so normal typing is untouched.
12
+ */
13
+
14
+ import { Editor, isKeyRelease, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
15
+ import type { AgentManager } from "../agent-manager.js";
16
+ import type { AgentRecord } from "../types.js";
17
+ import { getLifetimeTotal } from "../usage.js";
18
+ import { type AgentActivity, getDisplayName, type Theme } from "./agent-widget.js";
19
+ import { ConversationViewer, VIEWPORT_HEIGHT_PCT } from "./conversation-viewer.js";
20
+
21
+ /** Widget key for the below-editor fleet list. */
22
+ const FLEET_KEY = "fleet";
23
+ /** Max agent rows shown at once; extras collapse into a "↓ N more" indicator. */
24
+ const MAX_AGENT_ROWS = 5;
25
+ /** Re-render cadence so elapsed/token stats tick while agents run. */
26
+ const TICK_MS = 200;
27
+ /** How long a finished agent lingers in the list before it drops out. */
28
+ const FINISHED_LINGER_MS = 4000;
29
+
30
+ /** Minimal UI surface the FleetView needs from `ctx.ui` (structural subset). */
31
+ export type FleetUICtx = {
32
+ setWidget(
33
+ key: string,
34
+ content: undefined | ((tui: any, theme: Theme) => { render(width: number): string[]; invalidate(): void; dispose?(): void }),
35
+ options?: { placement?: "aboveEditor" | "belowEditor" },
36
+ ): void;
37
+ onTerminalInput(handler: (data: string) => { consume?: boolean; data?: string } | undefined): () => void;
38
+ getEditorText(): string;
39
+ notify(message: string, type?: "info" | "warning" | "error"): void;
40
+ custom<T>(
41
+ factory: (tui: any, theme: Theme, keybindings: any, done: (result: T) => void) => { render(width: number): string[]; invalidate(): void; dispose?(): void },
42
+ options?: { overlay?: boolean; overlayOptions?: unknown; onHandle?: (handle: unknown) => void },
43
+ ): Promise<T>;
44
+ };
45
+
46
+ type MainEntry = { kind: "main" };
47
+ type AgentEntry = { kind: "agent"; record: AgentRecord };
48
+ type FleetEntry = MainEntry | AgentEntry;
49
+
50
+ /** `11s` — integer seconds, no decimal/suffix (matches Claude Code, unlike formatMs). */
51
+ export function formatFleetElapsed(ms: number): string {
52
+ return `${Math.max(0, Math.round(ms / 1000))}s`;
53
+ }
54
+
55
+ /** `↓ 13.1k tokens` — down-arrow prefix, compact magnitude, plural "tokens". */
56
+ export function formatFleetTokens(count: number): string {
57
+ let compact: string;
58
+ if (count >= 1_000_000) compact = `${(count / 1_000_000).toFixed(1)}M`;
59
+ else if (count >= 1_000) compact = `${(count / 1_000).toFixed(1)}k`;
60
+ else compact = `${count}`;
61
+ return `↓ ${compact} tokens`;
62
+ }
63
+
64
+ /**
65
+ * Place `right` flush to `width`, truncating `left` first so the stats survive.
66
+ * The final clamp guarantees the line never exceeds `width` (which would wrap and
67
+ * desync pi's line-diff → flicker) even on a terminal too narrow for the stats.
68
+ */
69
+ function rightAlign(left: string, right: string, width: number): string {
70
+ const rightW = visibleWidth(right);
71
+ const maxLeft = Math.max(0, width - rightW - 1);
72
+ const leftClamped = truncateToWidth(left, maxLeft);
73
+ const gap = Math.max(1, width - visibleWidth(leftClamped) - rightW);
74
+ return truncateToWidth(leftClamped + " ".repeat(gap) + right, width);
75
+ }
76
+
77
+ export class FleetList {
78
+ private ui: FleetUICtx | undefined;
79
+ private tui: any | undefined;
80
+ private inputUnsub: (() => void) | undefined;
81
+ private widgetRegistered = false;
82
+ private timer: ReturnType<typeof setInterval> | undefined;
83
+
84
+ private enabled = true;
85
+ /** Whether arrow keys currently navigate the list (vs. flow to the editor). */
86
+ private active = false;
87
+ /** 0 = `main`, 1..N = subagents. */
88
+ private selectedIndex = 0;
89
+ /** Set while a conversation overlay is open; calling it closes the overlay. */
90
+ private viewerClose: (() => void) | undefined;
91
+ private viewingAgentId: string | undefined;
92
+
93
+ constructor(
94
+ private manager: AgentManager,
95
+ private agentActivity: Map<string, AgentActivity>,
96
+ ) {}
97
+
98
+ // ---- Lifecycle ----
99
+
100
+ setEnabled(enabled: boolean): void {
101
+ if (enabled === this.enabled) return;
102
+ this.enabled = enabled;
103
+ if (!enabled) this.active = false;
104
+ this.update();
105
+ }
106
+
107
+ /** Capture the UI context and (re)register the global input handler. */
108
+ setUICtx(ui: FleetUICtx): void {
109
+ if (ui === this.ui) return;
110
+ this.inputUnsub?.();
111
+ this.ui = ui;
112
+ this.widgetRegistered = false;
113
+ this.tui = undefined;
114
+ this.inputUnsub = ui.onTerminalInput(data => this.handleKey(data));
115
+ }
116
+
117
+ /** Ensure the re-render timer is running (called when an agent spawns). */
118
+ ensureTimer(): void {
119
+ if (!this.timer) this.timer = setInterval(() => this.update(), TICK_MS);
120
+ }
121
+
122
+ /**
123
+ * Called when an agent finishes. The viewer (if open on it) stays open so the
124
+ * final output remains readable, and the row lingers in the list — just refresh.
125
+ */
126
+ onAgentFinished(_id: string): void {
127
+ this.update();
128
+ }
129
+
130
+ dispose(): void {
131
+ if (this.timer) { clearInterval(this.timer); this.timer = undefined; }
132
+ this.inputUnsub?.();
133
+ this.inputUnsub = undefined;
134
+ if (this.viewerClose) { this.viewerClose(); this.viewerClose = undefined; }
135
+ this.viewingAgentId = undefined;
136
+ if (this.ui && this.widgetRegistered) this.ui.setWidget(FLEET_KEY, undefined);
137
+ this.widgetRegistered = false;
138
+ this.tui = undefined;
139
+ this.active = false;
140
+ // Null last so a `viewerClose()` microtask above can't re-register the widget.
141
+ this.ui = undefined;
142
+ }
143
+
144
+ /** Re-register/refresh the below-editor widget; clears it when no agents remain. */
145
+ update(): void {
146
+ if (!this.ui) return;
147
+ const hasAgents = this.enabled && this.agentRecords().length > 0;
148
+
149
+ if (!hasAgents) {
150
+ if (this.widgetRegistered) {
151
+ this.ui.setWidget(FLEET_KEY, undefined);
152
+ this.widgetRegistered = false;
153
+ this.tui = undefined;
154
+ }
155
+ if (this.timer) { clearInterval(this.timer); this.timer = undefined; }
156
+ this.active = false;
157
+ this.selectedIndex = 0;
158
+ return;
159
+ }
160
+
161
+ this.clampSelection();
162
+ this.ensureTimer(); // keep stats ticking whenever the list is shown (e.g. after a re-enable)
163
+
164
+ if (!this.widgetRegistered) {
165
+ this.ui.setWidget(FLEET_KEY, (tui, theme) => {
166
+ this.tui = tui;
167
+ return {
168
+ render: (w: number) => this.renderBar(w, theme),
169
+ invalidate: () => { this.widgetRegistered = false; this.tui = undefined; },
170
+ };
171
+ }, { placement: "belowEditor" });
172
+ this.widgetRegistered = true;
173
+ } else {
174
+ this.tui?.requestRender();
175
+ }
176
+ }
177
+
178
+ // ---- Roster ----
179
+
180
+ /**
181
+ * Agents shown in the list, ordered earliest-launched first so the ones you
182
+ * started sooner sit at the top. Every row is openable (has a session), so Enter
183
+ * never dead-ends. Included: running/queued, plus the agent currently being
184
+ * viewed, plus recently-finished ones (they linger briefly before dropping out).
185
+ * Pending agents with no session yet are hidden until they start.
186
+ * (`listAgents()` is newest-first, so we re-sort.)
187
+ */
188
+ private agentRecords(): AgentRecord[] {
189
+ const now = Date.now();
190
+ return this.manager.listAgents()
191
+ .filter(a => a.session && (
192
+ a.status === "running" || a.status === "queued"
193
+ || a.id === this.viewingAgentId
194
+ || (a.completedAt != null && now - a.completedAt < FINISHED_LINGER_MS)
195
+ ))
196
+ .sort((a, b) => a.startedAt - b.startedAt);
197
+ }
198
+
199
+ private roster(): FleetEntry[] {
200
+ return [{ kind: "main" }, ...this.agentRecords().map(record => ({ kind: "agent" as const, record }))];
201
+ }
202
+
203
+ private clampSelection(): void {
204
+ const max = this.roster().length - 1;
205
+ if (this.selectedIndex > max) this.selectedIndex = Math.max(0, max);
206
+ if (this.selectedIndex < 0) this.selectedIndex = 0;
207
+ }
208
+
209
+ // ---- Key handling ----
210
+
211
+ /** Returns `{consume:true}` to swallow a key, or undefined to let it through. */
212
+ handleKey(data: string): { consume?: boolean; data?: string } | undefined {
213
+ if (!this.enabled || !this.ui) return undefined;
214
+ // Input listeners receive BOTH key-press and key-release (the kitty protocol
215
+ // emits both, and matchesKey matches either) — act on press only, or every
216
+ // tap would move/fire twice. Repeats still pass through for held-key nav.
217
+ if (isKeyRelease(data)) return undefined;
218
+ // While an overlay is open, let it own all input.
219
+ if (this.viewerClose) return undefined;
220
+ // Input listeners fire BEFORE the focused component, and dialogs
221
+ // (ctx.ui.select/confirm/input, pi's own menus) swap the prompt editor out
222
+ // while getEditorText() still reads the detached — empty — editor. So when
223
+ // anything but the editor owns the keyboard, stay out of its keys (#123).
224
+ if (!this.editorHasFocus()) {
225
+ if (this.active) this.deactivate();
226
+ return undefined;
227
+ }
228
+
229
+ if (!this.active) {
230
+ // Activate: ↓ or ← at an empty prompt moves focus into the list.
231
+ const isActivator = matchesKey(data, "down") || matchesKey(data, "left");
232
+ if (isActivator && this.agentRecords().length > 0 && this.ui.getEditorText() === "") {
233
+ this.active = true;
234
+ this.selectedIndex = 0;
235
+ this.update();
236
+ return { consume: true };
237
+ }
238
+ return undefined;
239
+ }
240
+
241
+ // Active — arrows navigate, Enter opens, Esc / Up-past-top exits.
242
+ if (matchesKey(data, "down")) {
243
+ const max = this.roster().length - 1;
244
+ this.selectedIndex = Math.min(max, this.selectedIndex + 1);
245
+ this.update();
246
+ return { consume: true };
247
+ }
248
+ if (matchesKey(data, "up")) {
249
+ if (this.selectedIndex === 0) { this.deactivate(); return { consume: true }; }
250
+ this.selectedIndex -= 1;
251
+ this.update();
252
+ return { consume: true };
253
+ }
254
+ if (matchesKey(data, "escape")) { this.deactivate(); return { consume: true }; }
255
+ if (matchesKey(data, Key.enter)) { this.openSelected(); return { consume: true }; }
256
+
257
+ // Any other key cancels navigation and flows to the editor.
258
+ this.deactivate();
259
+ return undefined;
260
+ }
261
+
262
+ /**
263
+ * True when pi's prompt editor owns the keyboard. pi's editor is an `Editor`
264
+ * subclass (CustomEditor) while every dialog/selector is not, and the loader
265
+ * aliases pi-tui to pi's own copy, so `instanceof` is a reliable identity
266
+ * check. `focusedComponent` is TUI-private (no public accessor), hence the
267
+ * best-effort peek: unknowable focus (no tui seen yet, nothing focused)
268
+ * counts as the editor so activation keeps working.
269
+ */
270
+ private editorHasFocus(): boolean {
271
+ const focused = (this.tui as { focusedComponent?: unknown } | undefined)?.focusedComponent;
272
+ return focused == null || focused instanceof Editor;
273
+ }
274
+
275
+ private deactivate(): void {
276
+ this.active = false;
277
+ this.selectedIndex = 0;
278
+ this.update();
279
+ }
280
+
281
+ private openSelected(): void {
282
+ const entry = this.roster()[this.selectedIndex];
283
+ if (!entry || entry.kind === "main") {
284
+ // `main` = return to the prompt; the native transcript is already shown.
285
+ this.deactivate();
286
+ return;
287
+ }
288
+ const record = entry.record;
289
+ if (!this.ui) return;
290
+ if (!record.session) {
291
+ this.ui.notify(`Agent is ${record.status} — no session available.`, "info");
292
+ return;
293
+ }
294
+ const session = record.session;
295
+ const activity = this.agentActivity.get(record.id);
296
+ this.viewingAgentId = record.id;
297
+
298
+ void this.ui.custom<undefined>(
299
+ (tui, theme, keybindings, done) => {
300
+ this.viewerClose = () => done(undefined);
301
+ return new ConversationViewer(
302
+ tui,
303
+ session,
304
+ record,
305
+ activity,
306
+ theme,
307
+ done,
308
+ () => {
309
+ if (this.manager.abort(record.id)) this.ui?.notify(`Stopped "${record.description}".`, "info");
310
+ },
311
+ keybindings,
312
+ (message: string) => this.manager.steer(record.id, message),
313
+ );
314
+ },
315
+ {
316
+ overlay: true,
317
+ overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` },
318
+ },
319
+ ).then(() => this.clearViewer(), () => this.clearViewer());
320
+ }
321
+
322
+ /** Reset overlay state and return to the list (on close, auto-close, or error). */
323
+ private clearViewer(): void {
324
+ // Keep the cursor on the agent we were viewing — re-resolve by id so it
325
+ // still feels natural if the list reordered (an earlier agent finished)
326
+ // while the overlay was open. If that agent is gone, leave the index for
327
+ // update()'s clamp to settle.
328
+ if (this.viewingAgentId) {
329
+ const idx = this.roster().findIndex(e => e.kind === "agent" && e.record.id === this.viewingAgentId);
330
+ if (idx >= 0) this.selectedIndex = idx;
331
+ }
332
+ this.viewerClose = undefined;
333
+ this.viewingAgentId = undefined;
334
+ this.update();
335
+ }
336
+
337
+ // ---- Rendering ----
338
+
339
+ private renderBar(width: number, theme: Theme): string[] {
340
+ const agents = this.roster().slice(1) as AgentEntry[];
341
+ if (agents.length === 0) return [];
342
+ // Clamp locally so a render between a roster shrink and the next update()
343
+ // (e.g. on terminal resize) never loses the selection marker.
344
+ const sel = Math.min(this.selectedIndex, agents.length);
345
+
346
+ const hint = this.active
347
+ ? "↑↓ select · enter view · esc back"
348
+ : "esc to interrupt · ← for agents · ↓ to manage";
349
+ const lines: string[] = [];
350
+ lines.push(truncateToWidth(" " + theme.fg("dim", hint), width));
351
+ lines.push("");
352
+ lines.push(truncateToWidth(` ${this.bullet(0, sel, theme)} main`, width));
353
+
354
+ // Window the agent rows so the selected one stays visible.
355
+ const visible = Math.min(MAX_AGENT_ROWS, agents.length);
356
+ const selAgent = Math.max(0, sel - 1);
357
+ const start = selAgent < visible ? 0 : selAgent - visible + 1;
358
+ const hiddenBelow = agents.length - (start + visible);
359
+
360
+ if (start > 0) lines.push(rightAlign("", theme.fg("dim", `↑ ${start} more`), width));
361
+ for (let a = start; a < start + visible; a++) {
362
+ lines.push(this.renderAgentRow(a + 1, sel, agents[a].record, width, theme));
363
+ }
364
+ if (hiddenBelow > 0) lines.push(rightAlign("", theme.fg("dim", `↓ ${hiddenBelow} more`), width));
365
+
366
+ return lines;
367
+ }
368
+
369
+ private bullet(rosterIndex: number, sel: number, theme: Theme): string {
370
+ return rosterIndex === sel ? theme.fg("accent", "●") : theme.fg("dim", "○");
371
+ }
372
+
373
+ private renderAgentRow(rosterIndex: number, sel: number, record: AgentRecord, width: number, theme: Theme): string {
374
+ const left = ` ${this.bullet(rosterIndex, sel, theme)} ${theme.fg("muted", getDisplayName(record.type))} ${record.description}`;
375
+ const tokens = getLifetimeTotal(this.agentActivity.get(record.id)?.lifetimeUsage ?? record.lifetimeUsage);
376
+ const elapsedMs = (record.completedAt ?? Date.now()) - record.startedAt; // freezes once finished
377
+ const right = theme.fg("dim", `${formatFleetElapsed(elapsedMs)} · ${formatFleetTokens(tokens)}`);
378
+ return rightAlign(left, right, width);
379
+ }
380
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * schedule-menu.ts — `/agents → Scheduled jobs` submenu.
3
+ *
4
+ * Minimal v1 surface: list scheduled jobs, select one to inspect details +
5
+ * confirm cancellation. No create wizard (the `Agent` tool's `schedule` param
6
+ * is the canonical creation path), no toggle/cleanup (cancel is enough for
7
+ * "I scheduled something dumb, get rid of it"). Add management surfaces here
8
+ * if real demand emerges.
9
+ */
10
+
11
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
12
+ import type { SubagentScheduler } from "../schedule.js";
13
+ import type { ScheduledSubagent } from "../types.js";
14
+
15
+ /** Format an ISO timestamp as relative time ("in 4h", "2d ago", "—"). */
16
+ function relTime(iso: string | undefined, now = Date.now()): string {
17
+ if (!iso) return "—";
18
+ const t = new Date(iso).getTime();
19
+ if (Number.isNaN(t)) return "—";
20
+ const diff = t - now;
21
+ const abs = Math.abs(diff);
22
+ const future = diff > 0;
23
+ if (abs < 60_000) return future ? "in <1m" : "<1m ago";
24
+ const m = Math.round(abs / 60_000);
25
+ if (m < 60) return future ? `in ${m}m` : `${m}m ago`;
26
+ const h = Math.round(abs / 3_600_000);
27
+ if (h < 24) return future ? `in ${h}h` : `${h}h ago`;
28
+ const d = Math.round(abs / 86_400_000);
29
+ return future ? `in ${d}d` : `${d}d ago`;
30
+ }
31
+
32
+ /** One-line status icon. */
33
+ function statusIcon(j: ScheduledSubagent): string {
34
+ if (!j.enabled) return "✗";
35
+ if (j.lastStatus === "error") return "!";
36
+ if (j.lastStatus === "running") return "⋯";
37
+ return "✓";
38
+ }
39
+
40
+ /** Compact selectable row — name, schedule, agent type, next/last run, count. */
41
+ function formatJob(j: ScheduledSubagent, scheduler: SubagentScheduler): string {
42
+ const next = scheduler.getNextRun(j.id);
43
+ return [
44
+ statusIcon(j),
45
+ j.name.padEnd(18).slice(0, 18),
46
+ j.schedule.padEnd(14).slice(0, 14),
47
+ `[${j.subagent_type}]`,
48
+ `next ${relTime(next)}`,
49
+ `last ${relTime(j.lastRun)}`,
50
+ `runs ${j.runCount}`,
51
+ ].join(" ");
52
+ }
53
+
54
+ /** Multi-line details block for the cancel confirm. */
55
+ function formatDetails(j: ScheduledSubagent, scheduler: SubagentScheduler): string {
56
+ const next = scheduler.getNextRun(j.id) ?? "—";
57
+ return [
58
+ `name: ${j.name}`,
59
+ `schedule: ${j.schedule} (${j.scheduleType})`,
60
+ `agent: ${j.subagent_type}`,
61
+ `prompt: ${j.prompt.slice(0, 200)}${j.prompt.length > 200 ? "…" : ""}`,
62
+ `created: ${j.createdAt}`,
63
+ `last run: ${j.lastRun ?? "—"} (${j.lastStatus ?? "—"})`,
64
+ `next run: ${next}`,
65
+ `runs: ${j.runCount}`,
66
+ ].join("\n");
67
+ }
68
+
69
+ /**
70
+ * List scheduled jobs; selecting one opens a cancel-confirm with details.
71
+ * Returns when the user backs out or after a cancellation.
72
+ */
73
+ export async function showSchedulesMenu(
74
+ ctx: ExtensionCommandContext,
75
+ scheduler: SubagentScheduler,
76
+ ): Promise<void> {
77
+ if (!scheduler.isActive()) {
78
+ ctx.ui.notify("Scheduler is not active in this session.", "warning");
79
+ return;
80
+ }
81
+
82
+ const jobs = scheduler.list();
83
+ if (jobs.length === 0) {
84
+ ctx.ui.notify("No scheduled jobs.", "info");
85
+ return;
86
+ }
87
+
88
+ const labels = jobs.map(j => formatJob(j, scheduler));
89
+ const choice = await ctx.ui.select(
90
+ `Scheduled jobs (${jobs.length}) — select to cancel`,
91
+ labels,
92
+ );
93
+ if (!choice) return;
94
+
95
+ const idx = labels.indexOf(choice);
96
+ if (idx < 0) return;
97
+ const job = jobs[idx];
98
+
99
+ const ok = await ctx.ui.confirm(`Cancel "${job.name}"?`, formatDetails(job, scheduler));
100
+ if (!ok) return;
101
+
102
+ scheduler.removeJob(job.id);
103
+ ctx.ui.notify(`Cancelled "${job.name}".`, "info");
104
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * viewer-keys.ts — Scroll key matchers for the conversation viewer.
3
+ *
4
+ * Resolves `tui.select.*` through the user's keybindings when pi provides a
5
+ * manager, falling back to the previous hardcoded keys otherwise. The viewer's
6
+ * k/j and shift+arrow aliases always work alongside whatever is bound.
7
+ */
8
+
9
+ import { type KeyId, matchesKey } from "@earendil-works/pi-tui";
10
+
11
+ /** The `tui.select.*` keybinding ids the viewer resolves. */
12
+ export type ViewerScrollKeybinding =
13
+ | "tui.select.up"
14
+ | "tui.select.down"
15
+ | "tui.select.pageUp"
16
+ | "tui.select.pageDown";
17
+
18
+ /** Structural subset of pi-tui's `KeybindingsManager` (which satisfies it). */
19
+ export interface ViewerKeybindings {
20
+ matches(data: string, keybinding: ViewerScrollKeybinding): boolean;
21
+ }
22
+
23
+ export interface ViewerKeys {
24
+ scrollUp(data: string): boolean;
25
+ scrollDown(data: string): boolean;
26
+ pageUp(data: string): boolean;
27
+ pageDown(data: string): boolean;
28
+ }
29
+
30
+ export function createViewerKeys(keybindings?: ViewerKeybindings): ViewerKeys {
31
+ const matches = (data: string, id: ViewerScrollKeybinding, fallback: KeyId): boolean =>
32
+ keybindings ? keybindings.matches(data, id) : matchesKey(data, fallback);
33
+ return {
34
+ scrollUp: (data) => matches(data, "tui.select.up", "up") || matchesKey(data, "k"),
35
+ scrollDown: (data) => matches(data, "tui.select.down", "down") || matchesKey(data, "j"),
36
+ pageUp: (data) => matches(data, "tui.select.pageUp", "pageUp") || matchesKey(data, "shift+up"),
37
+ pageDown: (data) => matches(data, "tui.select.pageDown", "pageDown") || matchesKey(data, "shift+down"),
38
+ };
39
+ }
package/src/usage.ts ADDED
@@ -0,0 +1,60 @@
1
+ /** usage.ts — Token usage: shapes, accumulator operators, session-stats readers. */
2
+
3
+ /**
4
+ * Lifetime usage components, accumulated via `message_end` events. Survives
5
+ * compaction (which replaces session.state.messages and would reset any
6
+ * stats-derived sum). cacheRead is excluded because each turn's cacheRead is
7
+ * the cumulative cached prefix re-read on that one call — summing across
8
+ * turns counts the prefix N times. See issue #38.
9
+ */
10
+ export type LifetimeUsage = { input: number; output: number; cacheWrite: number };
11
+
12
+ /** Sum of lifetime usage components, or 0 if undefined. */
13
+ export function getLifetimeTotal(u?: LifetimeUsage): number {
14
+ return u ? u.input + u.output + u.cacheWrite : 0;
15
+ }
16
+
17
+ /** Add a usage delta into a target accumulator (mutates target). */
18
+ export function addUsage(into: LifetimeUsage, delta: LifetimeUsage): void {
19
+ into.input += delta.input;
20
+ into.output += delta.output;
21
+ into.cacheWrite += delta.cacheWrite;
22
+ }
23
+
24
+ /** Minimal shape we read from upstream `getSessionStats()`. */
25
+ export type SessionStatsLike = {
26
+ tokens: { input: number; output: number; cacheWrite: number };
27
+ contextUsage?: { percent: number | null };
28
+ };
29
+ export type SessionLike = { getSessionStats(): SessionStatsLike };
30
+
31
+ /**
32
+ * Session-scoped token count: input + output + cacheWrite as reported by
33
+ * upstream `getSessionStats().tokens` for the *current* session window.
34
+ *
35
+ * RESETS at compaction — upstream replaces `session.state.messages` and the
36
+ * stats are derived from that array. For a lifetime total that survives
37
+ * compaction, use `getLifetimeTotal(lifetimeUsage)` instead, which reads
38
+ * from an independent accumulator fed by `message_end` events.
39
+ *
40
+ * Avoids upstream's `tokens.total` field, which sums per-turn `cacheRead`
41
+ * and so counts the cumulative cached prefix N times across N turns
42
+ * (issue #38).
43
+ */
44
+ export function getSessionTokens(session: SessionLike | undefined): number {
45
+ if (!session) return 0;
46
+ try {
47
+ const t = session.getSessionStats().tokens;
48
+ return t.input + t.output + t.cacheWrite;
49
+ } catch { return 0; }
50
+ }
51
+
52
+ /**
53
+ * Context-window utilization (0–100), or null when unavailable
54
+ * (no model contextWindow, or post-compaction before the next response).
55
+ */
56
+ export function getSessionContextPercent(session: SessionLike | undefined): number | null {
57
+ if (!session) return null;
58
+ try { return session.getSessionStats().contextUsage?.percent ?? null; }
59
+ catch { return null; }
60
+ }