@clanker-code/pi-subagents 0.10.5

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 (130) hide show
  1. package/.plans/PLAN-next-changes.md +183 -0
  2. package/.plans/README.md +14 -0
  3. package/AGENTS.md +31 -0
  4. package/CHANGELOG.md +583 -0
  5. package/CLAUDE.md +1 -0
  6. package/LICENSE +21 -0
  7. package/README.md +630 -0
  8. package/RELEASE.md +39 -0
  9. package/dist/abort-resend.d.ts +35 -0
  10. package/dist/abort-resend.js +71 -0
  11. package/dist/agent-details.d.ts +17 -0
  12. package/dist/agent-details.js +22 -0
  13. package/dist/agent-manager.d.ts +132 -0
  14. package/dist/agent-manager.js +493 -0
  15. package/dist/agent-runner.d.ts +165 -0
  16. package/dist/agent-runner.js +732 -0
  17. package/dist/agent-tool-description.d.ts +9 -0
  18. package/dist/agent-tool-description.js +147 -0
  19. package/dist/agent-types.d.ts +60 -0
  20. package/dist/agent-types.js +157 -0
  21. package/dist/context.d.ts +12 -0
  22. package/dist/context.js +56 -0
  23. package/dist/cross-extension-rpc.d.ts +46 -0
  24. package/dist/cross-extension-rpc.js +76 -0
  25. package/dist/custom-agents.d.ts +14 -0
  26. package/dist/custom-agents.js +149 -0
  27. package/dist/default-agents.d.ts +7 -0
  28. package/dist/default-agents.js +119 -0
  29. package/dist/enabled-models.d.ts +49 -0
  30. package/dist/enabled-models.js +145 -0
  31. package/dist/env.d.ts +6 -0
  32. package/dist/env.js +28 -0
  33. package/dist/group-join.d.ts +32 -0
  34. package/dist/group-join.js +116 -0
  35. package/dist/index.d.ts +36 -0
  36. package/dist/index.js +1918 -0
  37. package/dist/invocation-config.d.ts +25 -0
  38. package/dist/invocation-config.js +19 -0
  39. package/dist/memory.d.ts +49 -0
  40. package/dist/memory.js +151 -0
  41. package/dist/model-resolver.d.ts +19 -0
  42. package/dist/model-resolver.js +62 -0
  43. package/dist/notifications.d.ts +6 -0
  44. package/dist/notifications.js +107 -0
  45. package/dist/output-file.d.ts +24 -0
  46. package/dist/output-file.js +86 -0
  47. package/dist/peek.d.ts +37 -0
  48. package/dist/peek.js +121 -0
  49. package/dist/prompts.d.ts +40 -0
  50. package/dist/prompts.js +95 -0
  51. package/dist/schedule-store.d.ts +38 -0
  52. package/dist/schedule-store.js +155 -0
  53. package/dist/schedule.d.ts +109 -0
  54. package/dist/schedule.js +338 -0
  55. package/dist/settings.d.ts +135 -0
  56. package/dist/settings.js +168 -0
  57. package/dist/skill-loader.d.ts +24 -0
  58. package/dist/skill-loader.js +93 -0
  59. package/dist/status-note.d.ts +13 -0
  60. package/dist/status-note.js +24 -0
  61. package/dist/types.d.ts +184 -0
  62. package/dist/types.js +7 -0
  63. package/dist/ui/agent-tool-rendering.d.ts +34 -0
  64. package/dist/ui/agent-tool-rendering.js +154 -0
  65. package/dist/ui/agent-widget-tree.d.ts +33 -0
  66. package/dist/ui/agent-widget-tree.js +130 -0
  67. package/dist/ui/agent-widget.d.ts +156 -0
  68. package/dist/ui/agent-widget.js +408 -0
  69. package/dist/ui/conversation-viewer.d.ts +47 -0
  70. package/dist/ui/conversation-viewer.js +290 -0
  71. package/dist/ui/menu-select.d.ts +20 -0
  72. package/dist/ui/menu-select.js +46 -0
  73. package/dist/ui/schedule-menu.d.ts +16 -0
  74. package/dist/ui/schedule-menu.js +99 -0
  75. package/dist/ui/viewer-keys.d.ts +20 -0
  76. package/dist/ui/viewer-keys.js +17 -0
  77. package/dist/usage.d.ts +50 -0
  78. package/dist/usage.js +49 -0
  79. package/dist/wait.d.ts +10 -0
  80. package/dist/wait.js +37 -0
  81. package/dist/worktree.d.ts +45 -0
  82. package/dist/worktree.js +160 -0
  83. package/docs/design/default-extension-tool-exposure.md +56 -0
  84. package/docs/superpowers/plans/2026-06-19-recursive-subagent-widget.md +600 -0
  85. package/docs/superpowers/specs/2026-06-19-recursive-subagent-widget-design.md +189 -0
  86. package/examples/agent-tool-description.md +45 -0
  87. package/package.json +56 -0
  88. package/reviews/proposal-structured-output-schema.md +135 -0
  89. package/reviews/recursive-subagent-widget-preview-rev2.png +0 -0
  90. package/reviews/recursive-subagent-widget-preview.html +137 -0
  91. package/reviews/recursive-subagent-widget-preview.png +0 -0
  92. package/reviews/subagent-features-comparison.md +350 -0
  93. package/src/abort-resend.ts +75 -0
  94. package/src/agent-details.ts +31 -0
  95. package/src/agent-manager.ts +596 -0
  96. package/src/agent-runner.ts +872 -0
  97. package/src/agent-tool-description.ts +163 -0
  98. package/src/agent-types.ts +189 -0
  99. package/src/context.ts +58 -0
  100. package/src/cross-extension-rpc.ts +122 -0
  101. package/src/custom-agents.ts +160 -0
  102. package/src/default-agents.ts +123 -0
  103. package/src/enabled-models.ts +180 -0
  104. package/src/env.ts +33 -0
  105. package/src/group-join.ts +141 -0
  106. package/src/index.ts +2115 -0
  107. package/src/invocation-config.ts +42 -0
  108. package/src/memory.ts +165 -0
  109. package/src/model-resolver.ts +81 -0
  110. package/src/notifications.ts +120 -0
  111. package/src/output-file.ts +96 -0
  112. package/src/peek.ts +155 -0
  113. package/src/prompts.ts +129 -0
  114. package/src/schedule-store.ts +153 -0
  115. package/src/schedule.ts +365 -0
  116. package/src/settings.ts +289 -0
  117. package/src/skill-loader.ts +102 -0
  118. package/src/status-note.ts +25 -0
  119. package/src/types.ts +195 -0
  120. package/src/ui/agent-tool-rendering.ts +175 -0
  121. package/src/ui/agent-widget-tree.ts +169 -0
  122. package/src/ui/agent-widget.ts +497 -0
  123. package/src/ui/conversation-viewer.ts +297 -0
  124. package/src/ui/menu-select.ts +68 -0
  125. package/src/ui/schedule-menu.ts +105 -0
  126. package/src/ui/viewer-keys.ts +39 -0
  127. package/src/usage.ts +60 -0
  128. package/src/wait.ts +44 -0
  129. package/src/worktree.ts +191 -0
  130. package/vitest.config.ts +25 -0
@@ -0,0 +1,290 @@
1
+ /**
2
+ * conversation-viewer.ts — Live conversation overlay for viewing agent sessions.
3
+ *
4
+ * Displays a scrollable, live-updating view of an agent's conversation.
5
+ * Subscribes to session events for real-time streaming updates.
6
+ */
7
+ import { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
8
+ import { extractText } from "../context.js";
9
+ import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
10
+ import { buildInvocationTags, describeActivity, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
11
+ import { createViewerKeys } from "./viewer-keys.js";
12
+ /** Base lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
13
+ const CHROME_LINES_BASE = 6;
14
+ const MIN_VIEWPORT = 3;
15
+ /** Height ceiling shared by the overlay's `maxHeight` and the viewer's internal viewport cap. */
16
+ export const VIEWPORT_HEIGHT_PCT = 70;
17
+ export class ConversationViewer {
18
+ tui;
19
+ session;
20
+ record;
21
+ activity;
22
+ theme;
23
+ done;
24
+ onStop;
25
+ scrollOffset = 0;
26
+ autoScroll = true;
27
+ unsubscribe;
28
+ lastInnerW = 0;
29
+ closed = false;
30
+ /** Two-press confirm guard for the stop key, so a stray key can't kill the agent. */
31
+ stopArmed = false;
32
+ keys;
33
+ constructor(tui, session, record, activity, theme, done,
34
+ /** Abort the agent shown here. Omitted → no stop affordance (e.g. read-only history). */
35
+ onStop,
36
+ /** User keybindings from `ctx.ui.custom()`. Omitted → hardcoded defaults. */
37
+ keybindings) {
38
+ this.tui = tui;
39
+ this.session = session;
40
+ this.record = record;
41
+ this.activity = activity;
42
+ this.theme = theme;
43
+ this.done = done;
44
+ this.onStop = onStop;
45
+ this.keys = createViewerKeys(keybindings);
46
+ this.unsubscribe = session.subscribe(() => {
47
+ if (this.closed)
48
+ return;
49
+ this.tui.requestRender();
50
+ });
51
+ }
52
+ handleInput(data) {
53
+ if (matchesKey(data, "escape") || matchesKey(data, "q")) {
54
+ this.closed = true;
55
+ this.done(undefined);
56
+ return;
57
+ }
58
+ // Stop/abort the agent (only while it can still be stopped). Two-press:
59
+ // first "x" arms, second confirms — any other key disarms.
60
+ if (matchesKey(data, "x")) {
61
+ if (this.isStoppable()) {
62
+ if (this.stopArmed) {
63
+ this.stopArmed = false;
64
+ this.onStop?.();
65
+ }
66
+ else {
67
+ this.stopArmed = true;
68
+ }
69
+ this.tui.requestRender();
70
+ }
71
+ return;
72
+ }
73
+ if (this.stopArmed)
74
+ this.stopArmed = false;
75
+ const totalLines = this.buildContentLines(this.lastInnerW).length;
76
+ const viewportHeight = this.viewportHeight();
77
+ const maxScroll = Math.max(0, totalLines - viewportHeight);
78
+ if (this.keys.scrollUp(data)) {
79
+ this.scrollOffset = Math.max(0, this.scrollOffset - 1);
80
+ this.autoScroll = this.scrollOffset >= maxScroll;
81
+ }
82
+ else if (this.keys.scrollDown(data)) {
83
+ this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
84
+ this.autoScroll = this.scrollOffset >= maxScroll;
85
+ }
86
+ else if (this.keys.pageUp(data)) {
87
+ this.scrollOffset = Math.max(0, this.scrollOffset - viewportHeight);
88
+ this.autoScroll = false;
89
+ }
90
+ else if (this.keys.pageDown(data)) {
91
+ this.scrollOffset = Math.min(maxScroll, this.scrollOffset + viewportHeight);
92
+ this.autoScroll = this.scrollOffset >= maxScroll;
93
+ }
94
+ else if (matchesKey(data, "home")) {
95
+ this.scrollOffset = 0;
96
+ this.autoScroll = false;
97
+ }
98
+ else if (matchesKey(data, "end")) {
99
+ this.scrollOffset = maxScroll;
100
+ this.autoScroll = true;
101
+ }
102
+ }
103
+ render(width) {
104
+ if (width < 6)
105
+ return []; // too narrow for any meaningful rendering
106
+ const th = this.theme;
107
+ const innerW = width - 4; // border + padding
108
+ this.lastInnerW = innerW;
109
+ const lines = [];
110
+ const pad = (s, len) => {
111
+ const vis = visibleWidth(s);
112
+ return s + " ".repeat(Math.max(0, len - vis));
113
+ };
114
+ const row = (content) => th.fg("border", "│") + " " + truncateToWidth(pad(content, innerW), innerW) + " " + th.fg("border", "│");
115
+ const hrTop = th.fg("border", `╭${"─".repeat(width - 2)}╮`);
116
+ const hrBot = th.fg("border", `╰${"─".repeat(width - 2)}╯`);
117
+ const hrMid = row(th.fg("dim", "─".repeat(innerW)));
118
+ // Header
119
+ lines.push(hrTop);
120
+ const name = getDisplayName(this.record.type);
121
+ const modeLabel = getPromptModeLabel(this.record.type);
122
+ const modeTag = modeLabel ? ` ${th.fg("dim", `(${modeLabel})`)}` : "";
123
+ const statusIcon = this.record.status === "running"
124
+ ? th.fg("accent", "●")
125
+ : this.record.status === "completed"
126
+ ? th.fg("success", "✓")
127
+ : this.record.status === "error"
128
+ ? th.fg("error", "✗")
129
+ : th.fg("dim", "○");
130
+ const duration = formatDuration(this.record.startedAt, this.record.completedAt);
131
+ const headerParts = [duration];
132
+ const toolUses = this.activity?.toolUses ?? this.record.toolUses;
133
+ if (toolUses > 0)
134
+ headerParts.unshift(`${toolUses} tool${toolUses === 1 ? "" : "s"}`);
135
+ const tokens = getLifetimeTotal(this.activity?.lifetimeUsage);
136
+ if (tokens > 0) {
137
+ const percent = getSessionContextPercent(this.activity?.session);
138
+ headerParts.push(formatSessionTokens(tokens, percent, th, this.record.compactionCount));
139
+ }
140
+ lines.push(row(`${statusIcon} ${th.bold(name)}${modeTag} ${th.fg("muted", this.record.description)} ${th.fg("dim", "·")} ${th.fg("dim", headerParts.join(" · "))}`));
141
+ const invocationLine = this.invocationLine();
142
+ if (invocationLine)
143
+ lines.push(row(invocationLine));
144
+ lines.push(hrMid);
145
+ // Content area — rebuild every render (live data, no cache needed)
146
+ const contentLines = this.buildContentLines(innerW);
147
+ const viewportHeight = this.viewportHeight();
148
+ const maxScroll = Math.max(0, contentLines.length - viewportHeight);
149
+ if (this.autoScroll) {
150
+ this.scrollOffset = maxScroll;
151
+ }
152
+ const visibleStart = Math.min(this.scrollOffset, maxScroll);
153
+ const visible = contentLines.slice(visibleStart, visibleStart + viewportHeight);
154
+ for (let i = 0; i < viewportHeight; i++) {
155
+ lines.push(row(visible[i] ?? ""));
156
+ }
157
+ // Footer
158
+ lines.push(hrMid);
159
+ const scrollPct = contentLines.length <= viewportHeight
160
+ ? "100%"
161
+ : `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
162
+ const footerLeft = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`);
163
+ const scrollHint = th.fg("dim", "↑↓ scroll · PgUp/PgDn or Shift+↑↓ · Esc close");
164
+ // Stop hint goes first in the right group so it survives right-edge
165
+ // truncation on narrow terminals (the scroll hint is the expendable part).
166
+ const footerRight = this.isStoppable()
167
+ ? (this.stopArmed ? th.fg("error", "x again to STOP") : th.fg("dim", "x stop")) +
168
+ th.fg("dim", " · ") + scrollHint
169
+ : scrollHint;
170
+ const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
171
+ lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight));
172
+ lines.push(hrBot);
173
+ return lines;
174
+ }
175
+ /** Stoppable only when a stop handler exists and the agent is still active. */
176
+ isStoppable() {
177
+ return !!this.onStop && (this.record.status === "running" || this.record.status === "queued");
178
+ }
179
+ invalidate() { }
180
+ dispose() {
181
+ this.closed = true;
182
+ if (this.unsubscribe) {
183
+ this.unsubscribe();
184
+ this.unsubscribe = undefined;
185
+ }
186
+ }
187
+ // ---- Private ----
188
+ viewportHeight() {
189
+ // Cap mirrors the overlay's maxHeight — otherwise the viewer would render
190
+ // more lines than the overlay shows and clip the footer.
191
+ const maxRows = Math.floor((this.tui.terminal.rows * VIEWPORT_HEIGHT_PCT) / 100);
192
+ return Math.max(MIN_VIEWPORT, maxRows - this.chromeLines());
193
+ }
194
+ chromeLines() {
195
+ return CHROME_LINES_BASE + (this.invocationLine() ? 1 : 0);
196
+ }
197
+ invocationLine() {
198
+ const { modelName, tags } = buildInvocationTags(this.record.invocation);
199
+ const parts = modelName ? [modelName, ...tags] : tags;
200
+ if (parts.length === 0)
201
+ return undefined;
202
+ return this.theme.fg("dim", ` ↳ ${parts.join(" · ")}`);
203
+ }
204
+ buildContentLines(width) {
205
+ if (width <= 0)
206
+ return [];
207
+ const th = this.theme;
208
+ const messages = this.session.messages;
209
+ const lines = [];
210
+ if (messages.length === 0) {
211
+ lines.push(th.fg("dim", "(waiting for first message...)"));
212
+ return lines;
213
+ }
214
+ let needsSeparator = false;
215
+ for (const msg of messages) {
216
+ if (msg.role === "user") {
217
+ const text = typeof msg.content === "string"
218
+ ? msg.content
219
+ : extractText(msg.content);
220
+ if (!text.trim())
221
+ continue;
222
+ if (needsSeparator)
223
+ lines.push(th.fg("dim", "───"));
224
+ lines.push(th.fg("accent", "[User]"));
225
+ for (const line of wrapTextWithAnsi(text.trim(), width)) {
226
+ lines.push(line);
227
+ }
228
+ }
229
+ else if (msg.role === "assistant") {
230
+ const textParts = [];
231
+ const toolCalls = [];
232
+ for (const c of msg.content) {
233
+ if (c.type === "text" && c.text)
234
+ textParts.push(c.text);
235
+ else if (c.type === "toolCall") {
236
+ toolCalls.push(c.name ?? c.toolName ?? "unknown");
237
+ }
238
+ }
239
+ if (needsSeparator)
240
+ lines.push(th.fg("dim", "───"));
241
+ lines.push(th.bold("[Assistant]"));
242
+ if (textParts.length > 0) {
243
+ for (const line of wrapTextWithAnsi(textParts.join("\n").trim(), width)) {
244
+ lines.push(line);
245
+ }
246
+ }
247
+ for (const name of toolCalls) {
248
+ lines.push(truncateToWidth(th.fg("muted", ` [Tool: ${name}]`), width));
249
+ }
250
+ }
251
+ else if (msg.role === "toolResult") {
252
+ const text = extractText(msg.content);
253
+ const truncated = text.length > 500 ? text.slice(0, 500) + "... (truncated)" : text;
254
+ if (!truncated.trim())
255
+ continue;
256
+ if (needsSeparator)
257
+ lines.push(th.fg("dim", "───"));
258
+ lines.push(th.fg("dim", "[Result]"));
259
+ for (const line of wrapTextWithAnsi(truncated.trim(), width)) {
260
+ lines.push(th.fg("dim", line));
261
+ }
262
+ }
263
+ else if (msg.role === "bashExecution") {
264
+ const bash = msg;
265
+ if (needsSeparator)
266
+ lines.push(th.fg("dim", "───"));
267
+ lines.push(truncateToWidth(th.fg("muted", ` $ ${bash.command}`), width));
268
+ if (bash.output?.trim()) {
269
+ const out = bash.output.length > 500
270
+ ? bash.output.slice(0, 500) + "... (truncated)"
271
+ : bash.output;
272
+ for (const line of wrapTextWithAnsi(out.trim(), width)) {
273
+ lines.push(th.fg("dim", line));
274
+ }
275
+ }
276
+ }
277
+ else {
278
+ continue;
279
+ }
280
+ needsSeparator = true;
281
+ }
282
+ // Streaming indicator for running agents
283
+ if (this.record.status === "running" && this.activity) {
284
+ const act = describeActivity(this.activity.activeTools, this.activity.responseText);
285
+ lines.push("");
286
+ lines.push(truncateToWidth(th.fg("accent", "▍ ") + th.fg("dim", act), width));
287
+ }
288
+ return lines.map(l => truncateToWidth(l, width));
289
+ }
290
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * menu-select.ts — Custom select dialog with left/right arrow navigation.
3
+ *
4
+ * Mirrors `ctx.ui.select()` but adds horizontal arrow semantics for nested
5
+ * menus: left arrow goes back (like Esc), right arrow selects (like Enter).
6
+ */
7
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
8
+ export interface MenuSelectOptions {
9
+ title: string;
10
+ options: string[];
11
+ /** Optional descriptions keyed by option string. */
12
+ descriptions?: Record<string, string>;
13
+ /** Maximum number of items visible at once. Defaults to all items. */
14
+ maxVisible?: number;
15
+ }
16
+ /**
17
+ * Show a selectable menu and return the chosen option, or `undefined` if the
18
+ * user backed out with Esc or the left arrow.
19
+ */
20
+ export declare function menuSelect(ctx: ExtensionCommandContext, opts: MenuSelectOptions): Promise<string | undefined>;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * menu-select.ts — Custom select dialog with left/right arrow navigation.
3
+ *
4
+ * Mirrors `ctx.ui.select()` but adds horizontal arrow semantics for nested
5
+ * menus: left arrow goes back (like Esc), right arrow selects (like Enter).
6
+ */
7
+ import { getSelectListTheme } from "@earendil-works/pi-coding-agent";
8
+ import { Container, matchesKey, SelectList, Spacer, Text } from "@earendil-works/pi-tui";
9
+ /**
10
+ * Show a selectable menu and return the chosen option, or `undefined` if the
11
+ * user backed out with Esc or the left arrow.
12
+ */
13
+ export async function menuSelect(ctx, opts) {
14
+ const items = opts.options.map((value) => ({
15
+ value,
16
+ label: value,
17
+ description: opts.descriptions?.[value],
18
+ }));
19
+ return ctx.ui.custom((_tui, theme, _kb, done) => {
20
+ const list = new SelectList(items, opts.maxVisible ?? items.length, getSelectListTheme());
21
+ list.onSelect = (item) => done(item.value);
22
+ list.onCancel = () => done(undefined);
23
+ const container = new Container();
24
+ container.addChild(new Text(theme.bold(opts.title), 0, 0));
25
+ container.addChild(new Spacer(1));
26
+ container.addChild(list);
27
+ return {
28
+ render: (w) => container.render(w),
29
+ invalidate: () => container.invalidate(),
30
+ handleInput: (data) => {
31
+ if (matchesKey(data, "left") || matchesKey(data, "escape")) {
32
+ done(undefined);
33
+ return;
34
+ }
35
+ if (matchesKey(data, "right") || matchesKey(data, "enter")) {
36
+ const selected = list.getSelectedItem();
37
+ if (selected) {
38
+ done(selected.value);
39
+ }
40
+ return;
41
+ }
42
+ list.handleInput(data);
43
+ },
44
+ };
45
+ });
46
+ }
@@ -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,99 @@
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 { menuSelect } from "./menu-select.js";
11
+ /** Format an ISO timestamp as relative time ("in 4h", "2d ago", "—"). */
12
+ function relTime(iso, now = Date.now()) {
13
+ if (!iso)
14
+ return "—";
15
+ const t = new Date(iso).getTime();
16
+ if (Number.isNaN(t))
17
+ return "—";
18
+ const diff = t - now;
19
+ const abs = Math.abs(diff);
20
+ const future = diff > 0;
21
+ if (abs < 60_000)
22
+ return future ? "in <1m" : "<1m ago";
23
+ const m = Math.round(abs / 60_000);
24
+ if (m < 60)
25
+ return future ? `in ${m}m` : `${m}m ago`;
26
+ const h = Math.round(abs / 3_600_000);
27
+ if (h < 24)
28
+ return future ? `in ${h}h` : `${h}h ago`;
29
+ const d = Math.round(abs / 86_400_000);
30
+ return future ? `in ${d}d` : `${d}d ago`;
31
+ }
32
+ /** One-line status icon. */
33
+ function statusIcon(j) {
34
+ if (!j.enabled)
35
+ return "✗";
36
+ if (j.lastStatus === "error")
37
+ return "!";
38
+ if (j.lastStatus === "running")
39
+ return "⋯";
40
+ return "✓";
41
+ }
42
+ /** Compact selectable row — name, schedule, agent type, next/last run, count. */
43
+ function formatJob(j, scheduler) {
44
+ const next = scheduler.getNextRun(j.id);
45
+ return [
46
+ statusIcon(j),
47
+ j.name.padEnd(18).slice(0, 18),
48
+ j.schedule.padEnd(14).slice(0, 14),
49
+ `[${j.subagent_type}]`,
50
+ `next ${relTime(next)}`,
51
+ `last ${relTime(j.lastRun)}`,
52
+ `runs ${j.runCount}`,
53
+ ].join(" ");
54
+ }
55
+ /** Multi-line details block for the cancel confirm. */
56
+ function formatDetails(j, scheduler) {
57
+ const next = scheduler.getNextRun(j.id) ?? "—";
58
+ return [
59
+ `name: ${j.name}`,
60
+ `schedule: ${j.schedule} (${j.scheduleType})`,
61
+ `agent: ${j.subagent_type}`,
62
+ `prompt: ${j.prompt.slice(0, 200)}${j.prompt.length > 200 ? "…" : ""}`,
63
+ `created: ${j.createdAt}`,
64
+ `last run: ${j.lastRun ?? "—"} (${j.lastStatus ?? "—"})`,
65
+ `next run: ${next}`,
66
+ `runs: ${j.runCount}`,
67
+ ].join("\n");
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(ctx, scheduler) {
74
+ if (!scheduler.isActive()) {
75
+ ctx.ui.notify("Scheduler is not active in this session.", "warning");
76
+ return;
77
+ }
78
+ const jobs = scheduler.list();
79
+ if (jobs.length === 0) {
80
+ ctx.ui.notify("No scheduled jobs.", "info");
81
+ return;
82
+ }
83
+ const labels = jobs.map(j => formatJob(j, scheduler));
84
+ const choice = await menuSelect(ctx, {
85
+ title: `Scheduled jobs (${jobs.length}) — select to cancel`,
86
+ options: labels,
87
+ });
88
+ if (!choice)
89
+ return;
90
+ const idx = labels.indexOf(choice);
91
+ if (idx < 0)
92
+ return;
93
+ const job = jobs[idx];
94
+ const ok = await ctx.ui.confirm(`Cancel "${job.name}"?`, formatDetails(job, scheduler));
95
+ if (!ok)
96
+ return;
97
+ scheduler.removeJob(job.id);
98
+ ctx.ui.notify(`Cancelled "${job.name}".`, "info");
99
+ }
@@ -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
+ }
package/dist/wait.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ export type WaitOutcome = "completed" | "timeout" | "aborted";
2
+ /** Human-readable "Xm Ys" for a duration in seconds. */
3
+ export declare function formatWaitTimeout(seconds: number): string;
4
+ /**
5
+ * Race an agent completion promise against the configured wait timeout and the
6
+ * parent abort signal. The subagent is never aborted here.
7
+ */
8
+ export declare function raceWait(promise: Promise<string>, signal: AbortSignal | undefined, timeoutSeconds: number): Promise<WaitOutcome>;
9
+ /** Message returned when a wait ends with the agent still running. */
10
+ export declare function waitTimeoutMessage(outcome: WaitOutcome, timeoutSeconds: number): string;
package/dist/wait.js ADDED
@@ -0,0 +1,37 @@
1
+ /** Human-readable "Xm Ys" for a duration in seconds. */
2
+ export function formatWaitTimeout(seconds) {
3
+ const m = Math.floor(seconds / 60);
4
+ const s = seconds % 60;
5
+ return m > 0 ? `${m}m${s > 0 ? ` ${s}s` : ""}` : `${s}s`;
6
+ }
7
+ /**
8
+ * Race an agent completion promise against the configured wait timeout and the
9
+ * parent abort signal. The subagent is never aborted here.
10
+ */
11
+ export function raceWait(promise, signal, timeoutSeconds) {
12
+ return new Promise((resolve) => {
13
+ let settled = false;
14
+ const finish = (outcome) => {
15
+ if (settled)
16
+ return;
17
+ settled = true;
18
+ clearTimeout(timer);
19
+ signal?.removeEventListener("abort", onAbort);
20
+ resolve(outcome);
21
+ };
22
+ const timer = setTimeout(() => finish("timeout"), timeoutSeconds * 1000);
23
+ const onAbort = () => finish("aborted");
24
+ signal?.addEventListener("abort", onAbort, { once: true });
25
+ promise.then(() => finish("completed"));
26
+ });
27
+ }
28
+ /** Message returned when a wait ends with the agent still running. */
29
+ export function waitTimeoutMessage(outcome, timeoutSeconds) {
30
+ if (outcome === "timeout") {
31
+ return `Agent is still running. The wait timed out after ${formatWaitTimeout(timeoutSeconds)} to avoid blocking the parent session longer than the configured limit.\nCall get_subagent_result with wait: true again to keep waiting, or omit wait to check status.`;
32
+ }
33
+ if (outcome === "aborted") {
34
+ return `Agent is still running. The wait was cancelled by the user (parent turn aborted). The subagent was NOT stopped — it continues in the background.\nCall get_subagent_result with wait: true again to keep waiting, use peek to check progress, or omit wait to check status.`;
35
+ }
36
+ return "Agent is still running. Use peek to check recent progress, wait: true to block until it finishes, or check back later.";
37
+ }