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