@nredd/pi-subagents 0.0.0-stage → 0.22.0-nredd.10

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 (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1444 -2
  3. package/dist/chunks/chunk-MSGGFB5Q.ts +6755 -0
  4. package/dist/chunks/chunk-MSGGFB5Q.ts.map +7 -0
  5. package/dist/chunks/conversation-viewer-A7AMRDVY.ts +15 -0
  6. package/dist/chunks/conversation-viewer-A7AMRDVY.ts.map +7 -0
  7. package/dist/index.ts +9874 -0
  8. package/dist/index.ts.map +7 -0
  9. package/package.json +82 -4
  10. package/src/abortable.ts +43 -0
  11. package/src/agent-color.ts +161 -0
  12. package/src/agent-file-toggle.ts +298 -0
  13. package/src/agent-manager.ts +2431 -0
  14. package/src/agent-runner.ts +1703 -0
  15. package/src/agent-source-badge.ts +75 -0
  16. package/src/agent-types.ts +346 -0
  17. package/src/child-context.ts +33 -0
  18. package/src/clock.ts +25 -0
  19. package/src/completion-outbox.ts +144 -0
  20. package/src/context.ts +58 -0
  21. package/src/cross-extension-rpc.ts +200 -0
  22. package/src/custom-agents.ts +441 -0
  23. package/src/default-agents.ts +126 -0
  24. package/src/detached-results.ts +150 -0
  25. package/src/enabled-models.ts +181 -0
  26. package/src/enterprise-local.ts +67 -0
  27. package/src/env.ts +33 -0
  28. package/src/error-message.ts +11 -0
  29. package/src/git-path.ts +66 -0
  30. package/src/group-join.ts +142 -0
  31. package/src/home-paths.ts +27 -0
  32. package/src/index.ts +5047 -0
  33. package/src/invocation-config.ts +158 -0
  34. package/src/memory.ts +179 -0
  35. package/src/mention-clone.ts +200 -0
  36. package/src/mention.ts +141 -0
  37. package/src/model-call.ts +83 -0
  38. package/src/model-resolver.ts +333 -0
  39. package/src/model-scope.ts +71 -0
  40. package/src/model-selection.ts +170 -0
  41. package/src/model-tiers.ts +176 -0
  42. package/src/nested-tools.ts +512 -0
  43. package/src/notification-result.ts +118 -0
  44. package/src/output-file.ts +186 -0
  45. package/src/package-resources.ts +639 -0
  46. package/src/plan-mode-children.ts +176 -0
  47. package/src/process-budget.ts +346 -0
  48. package/src/prompts.ts +161 -0
  49. package/src/quota-admission.ts +121 -0
  50. package/src/resource-loader.ts +65 -0
  51. package/src/schedule-store.ts +153 -0
  52. package/src/schedule.ts +514 -0
  53. package/src/settings.ts +851 -0
  54. package/src/skill-loader.ts +288 -0
  55. package/src/stale-ctx.ts +16 -0
  56. package/src/startup-error.ts +29 -0
  57. package/src/status-note.ts +113 -0
  58. package/src/structured-output.ts +130 -0
  59. package/src/types.ts +487 -0
  60. package/src/ui/agent-mention.ts +219 -0
  61. package/src/ui/agent-row.ts +245 -0
  62. package/src/ui/agent-widget.ts +722 -0
  63. package/src/ui/conversation-viewer.ts +837 -0
  64. package/src/ui/fallback-flag.ts +114 -0
  65. package/src/ui/fleet-list.ts +1020 -0
  66. package/src/ui/fleet-state.ts +154 -0
  67. package/src/ui/notification-view.ts +139 -0
  68. package/src/ui/schedule-menu.ts +105 -0
  69. package/src/ui/select-item.ts +45 -0
  70. package/src/ui/viewer-keys.ts +39 -0
  71. package/src/ui/workflow-card.ts +459 -0
  72. package/src/ui/workflow-dialog.ts +1138 -0
  73. package/src/ui/workflow-menu.ts +193 -0
  74. package/src/usage.ts +167 -0
  75. package/src/workflow/collisions.ts +123 -0
  76. package/src/workflow/entry.ts +47 -0
  77. package/src/workflow/host.ts +398 -0
  78. package/src/workflow/journal.ts +176 -0
  79. package/src/workflow/json-schema.ts +129 -0
  80. package/src/workflow/meta.ts +329 -0
  81. package/src/workflow/progress.ts +554 -0
  82. package/src/workflow/runtime.ts +1249 -0
  83. package/src/workflow/saved.ts +259 -0
  84. package/src/workflow/task.ts +317 -0
  85. package/src/workflow/tool-description.ts +200 -0
  86. package/src/workflow/worker-source.ts +903 -0
  87. package/src/worktree.ts +520 -0
  88. package/src/xml.ts +13 -0
@@ -0,0 +1,154 @@
1
+ /** Session-journal authority for bounded fleet alerts and settled disclosure choices. */
2
+ import { stripTerminalSequences } from "@earendil-works/pi-tui";
3
+
4
+ export const FLEET_STATE_ENTRY = "subagents:fleet-state";
5
+ export const FLEET_STATE_VERSION = 1;
6
+ export const MAX_FLEET_ALERT_DETAILS = 128;
7
+ export const MAX_COMPACT_ALERT_COUNT = 99;
8
+ export const MAX_ALERT_ID_LENGTH = 160;
9
+ export const MAX_ALERT_TYPE_LENGTH = 80;
10
+ export const MAX_ALERT_DESCRIPTION_LENGTH = 240;
11
+ const ALERT_FIELD_COUNT = 5;
12
+
13
+ export type FailureKind = "error" | "aborted" | "failed" | "killed";
14
+ /** No AgentRecord, result, session, promise or usage references may cross this boundary. */
15
+ export interface FleetAlert {
16
+ id: string;
17
+ type: string;
18
+ description: string;
19
+ state: FailureKind;
20
+ timestamp: number;
21
+ }
22
+ export interface FleetSnapshot {
23
+ version: typeof FLEET_STATE_VERSION;
24
+ collapsed: boolean;
25
+ alerts: FleetAlert[];
26
+ overflow: Record<FailureKind, number>;
27
+ /** Identity history lives in the existing journal, not an unbounded UI set. */
28
+ transition?: { alert: FleetAlert; acknowledged: boolean };
29
+ }
30
+ export interface FleetJournalEntry { type: string; customType?: string; data?: unknown }
31
+ const KINDS: readonly FailureKind[] = ["error", "aborted", "failed", "killed"];
32
+ const emptyOverflow = (): Record<FailureKind, number> => ({ error: 0, aborted: 0, failed: 0, killed: 0 });
33
+
34
+ /** Validate whole checkpoints; corrupt/unknown entries cannot partially replace valid state. */
35
+ function snapshot(data: unknown): FleetSnapshot | undefined {
36
+ if (!data || typeof data !== "object") return undefined;
37
+ const value = data as FleetSnapshot;
38
+ if (value.version !== FLEET_STATE_VERSION || typeof value.collapsed !== "boolean") return undefined;
39
+ // Earlier disclosure-only entries are valid empty checkpoints.
40
+ if (value.alerts === undefined && value.overflow === undefined) return { version: FLEET_STATE_VERSION, collapsed: value.collapsed, alerts: [], overflow: emptyOverflow() };
41
+ if (!Array.isArray(value.alerts) || value.alerts.length > MAX_FLEET_ALERT_DETAILS || !value.alerts.every(validAlert)
42
+ || new Set(value.alerts.map(alert => alert.id)).size !== value.alerts.length
43
+ || !value.overflow || !KINDS.every(kind => Number.isSafeInteger(value.overflow[kind]) && value.overflow[kind] >= 0)
44
+ || (value.transition !== undefined && (!value.transition || !validAlert(value.transition.alert) || typeof value.transition.acknowledged !== "boolean"))) return undefined;
45
+ return value;
46
+ }
47
+
48
+ function validAlert(value: unknown): value is FleetAlert {
49
+ if (!value || typeof value !== "object") return false;
50
+ const alert = value as FleetAlert;
51
+ return typeof alert.id === "string" && alert.id.length > 0 && alert.id.length <= MAX_ALERT_ID_LENGTH
52
+ && typeof alert.type === "string" && alert.type.length <= MAX_ALERT_TYPE_LENGTH
53
+ && typeof alert.description === "string" && alert.description.length <= MAX_ALERT_DESCRIPTION_LENGTH
54
+ && Object.keys(alert).length === ALERT_FIELD_COUNT
55
+ && KINDS.includes(alert.state) && Number.isFinite(alert.timestamp);
56
+ }
57
+
58
+ /** Bounded strings and plain values only, including when a detached spool supplied the input. */
59
+ export function fleetAlert(value: unknown): FleetAlert | undefined {
60
+ if (!value || typeof value !== "object") return undefined;
61
+ const record = value as { id?: unknown; type?: unknown; description?: unknown; status?: unknown; completedAt?: unknown; startedAt?: unknown };
62
+ if (typeof record.id !== "string" || !record.id || record.id.length > MAX_ALERT_ID_LENGTH || typeof record.type !== "string"
63
+ || typeof record.description !== "string" || !KINDS.includes(record.status as FailureKind)) return undefined;
64
+ const timestamp = record.completedAt ?? record.startedAt;
65
+ if (typeof timestamp !== "number" || !Number.isFinite(timestamp)) return undefined;
66
+ const clean = (text: string, limit: number) => stripTerminalSequences(text).replace(/[\x00-\x1f\x7f]/g, " ").slice(0, limit);
67
+ return { id: record.id, type: clean(record.type, MAX_ALERT_TYPE_LENGTH), description: clean(record.description, MAX_ALERT_DESCRIPTION_LENGTH), state: record.status as FailureKind, timestamp };
68
+ }
69
+
70
+ /**
71
+ * At most N detailed alerts plus four exact overflow counters in RAM. Each transition
72
+ * checkpoints bounded state. For an overflow id, scan the active branch backwards for
73
+ * its last transition (O(journal length) time, O(1) extra space). The host already owns
74
+ * that journal; no extra id/ack set, file, or global registry is retained here.
75
+ */
76
+ export class FleetJournal {
77
+ collapsed = true;
78
+ private readonly details = new Map<string, FleetAlert>();
79
+ private overflow = emptyOverflow();
80
+ private entries: () => readonly FleetJournalEntry[] = () => [];
81
+ private append: ((state: FleetSnapshot) => void) | undefined;
82
+ private settledCollapsed = true;
83
+
84
+ restore(entries: () => readonly FleetJournalEntry[], append: (state: FleetSnapshot) => void): void {
85
+ this.entries = entries;
86
+ this.append = append;
87
+ this.details.clear();
88
+ this.overflow = emptyOverflow();
89
+ this.collapsed = true;
90
+ const branch = entries();
91
+ for (let i = branch.length - 1; i >= 0; i--) {
92
+ const entry = branch[i];
93
+ if (entry.type !== "custom" || entry.customType !== FLEET_STATE_ENTRY) continue;
94
+ const state = snapshot(entry.data);
95
+ if (!state) continue;
96
+ this.collapsed = state.collapsed;
97
+ this.overflow = { ...state.overflow };
98
+ for (const alert of state.alerts) this.details.set(alert.id, { ...alert });
99
+ break;
100
+ }
101
+ this.settledCollapsed = this.collapsed;
102
+ }
103
+
104
+ get alerts(): readonly FleetAlert[] { return [...this.details.values()]; }
105
+ get overflowCount(): number { return KINDS.reduce((sum, kind) => sum + this.overflow[kind], 0); }
106
+ get count(): number { return this.details.size + this.overflowCount; }
107
+
108
+ /** Duplicate notices and polling are not new failures; acknowledged ids stay read. */
109
+ report(alert: FleetAlert): void {
110
+ const detail = this.details.get(alert.id);
111
+ const previous = detail ? { alert: detail, acknowledged: false } : this.previous(alert.id);
112
+ if (previous && previous.alert.timestamp >= alert.timestamp) return;
113
+ // A resumed agent keeps its id. Replace its unread attempt, or reopen an
114
+ // acknowledged identity only for a newer failure, never for a stale notice.
115
+ if (previous && !previous.acknowledged) {
116
+ if (detail) this.details.set(alert.id, alert);
117
+ else { this.overflow[previous.alert.state]--; this.overflow[alert.state]++; }
118
+ } else if (this.details.size < MAX_FLEET_ALERT_DETAILS) this.details.set(alert.id, alert);
119
+ else this.overflow[alert.state]++;
120
+ this.write({ alert, acknowledged: false });
121
+ }
122
+
123
+ acknowledge(id: string): void {
124
+ const detail = this.details.get(id);
125
+ const previous = detail ? { alert: detail, acknowledged: false } : this.previous(id);
126
+ if (!previous || previous.acknowledged) return;
127
+ if (!this.details.delete(id)) this.overflow[previous.alert.state]--;
128
+ this.write({ alert: previous.alert, acknowledged: true });
129
+ }
130
+
131
+ /** Debounced by the controller; a net-no-change burst writes nothing. */
132
+ flushDisclosure(): void {
133
+ if (this.collapsed !== this.settledCollapsed) this.write();
134
+ }
135
+
136
+ private previous(id: string): FleetSnapshot["transition"] {
137
+ const branch = this.entries();
138
+ for (let i = branch.length - 1; i >= 0; i--) {
139
+ const entry = branch[i];
140
+ if (entry.type !== "custom" || entry.customType !== FLEET_STATE_ENTRY) continue;
141
+ const data = entry.data as FleetSnapshot | undefined;
142
+ if (data?.version === FLEET_STATE_VERSION && data.transition?.alert?.id === id) {
143
+ const valid = snapshot(data);
144
+ if (valid) return valid.transition;
145
+ }
146
+ }
147
+ return undefined;
148
+ }
149
+
150
+ private write(transition?: FleetSnapshot["transition"]): void {
151
+ this.append?.({ version: FLEET_STATE_VERSION, collapsed: this.collapsed, alerts: [...this.details.values()], overflow: { ...this.overflow }, ...(transition ? { transition } : {}) });
152
+ this.settledCollapsed = this.collapsed;
153
+ }
154
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * notification-view.ts -- the `subagent-notification` message renderer.
3
+ *
4
+ * Core gives a custom message with a registered renderer a ▸/▾ gutter, collapses
5
+ * it to the renderer's first line, and toggles it on click. So the first line is
6
+ * the whole collapsed notice and no marker is drawn here:
7
+ *
8
+ * ✓ audit the RPC path · 12 tools · 33.8k token · ~$0.0042 · 41.2s
9
+ * 3 agents finished · ✓ a, ✓ b, ✗ c
10
+ *
11
+ * Expanded adds the stats, the result preview and the transcript as a link.
12
+ */
13
+
14
+ import { pathToFileURL } from "node:url";
15
+ import { type Component, hyperlink, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
16
+ import type { NotificationDetails } from "../types.js";
17
+ import { formatCost, formatMs, formatTokens, formatTurns, type Theme } from "./agent-widget.js";
18
+ import { fallbackFlag, fallbackFlagText, hasFallback } from "./fallback-flag.js";
19
+
20
+ /** Lines of the result preview shown when expanded. */
21
+ const EXPANDED_PREVIEW_LINES = 30;
22
+
23
+ const FAILED = new Set(["error", "stopped", "aborted"]);
24
+
25
+ function icon(d: NotificationDetails, theme: Theme): string {
26
+ return FAILED.has(d.status) ? theme.fg("error", "✗") : theme.fg("success", "✓");
27
+ }
28
+
29
+ /** The parts after the description: tools, tokens, cost, duration. */
30
+ function statParts(d: NotificationDetails, showCost: boolean): string[] {
31
+ const parts: string[] = [];
32
+ if (d.toolUses > 0) parts.push(`${d.toolUses} tool${d.toolUses === 1 ? "" : "s"}`);
33
+ if (d.totalTokens > 0) parts.push(formatTokens(d.totalTokens));
34
+ if (showCost) {
35
+ const cost = formatCost(d.totalCost ?? 0);
36
+ if (cost) parts.push(cost);
37
+ }
38
+ if (d.durationMs > 0) parts.push(formatMs(d.durationMs));
39
+ return parts;
40
+ }
41
+
42
+ /** `✓ desc · 12 tools · 33.8k token · ~$0.0042 · 41.2s`. */
43
+ export function noticeSummary(d: NotificationDetails, theme: Theme, showCost: boolean): string {
44
+ const dot = ` ${theme.fg("dim", "·")} `;
45
+ // Leading, before the unbounded description: the collapsed line is truncated
46
+ // from the right, and a substituted model must not be what gets cut.
47
+ const flag = fallbackFlag(theme, d) ? `${fallbackFlag(theme, d)} ` : "";
48
+ const head = `${icon(d, theme)} ${flag}${theme.bold(d.description)}`;
49
+ // Ahead of the stats for the same reason: truncation must not cut it.
50
+ const resume = d.resumeHint ? [theme.fg("warning", `resume ${d.id} to continue`)] : [];
51
+ const stats = statParts(d, showCost).map(p => theme.fg("dim", p));
52
+ return [head, ...resume, ...stats].join(dot);
53
+ }
54
+
55
+ /** `3 agents finished · ✓ a, ✓ b, ✗ c`. */
56
+ export function groupSummary(all: NotificationDetails[], theme: Theme): string {
57
+ const names = all.map(d => `${icon(d, theme)} ${d.description}`).join(theme.fg("dim", ", "));
58
+ // In the heading, ahead of the member list that truncation eats from the end,
59
+ // so a fallback member listed last is still counted.
60
+ const groups = new Map<string, { carrier: NotificationDetails; count: number }>();
61
+ for (const carrier of all.filter(d => d.modelTier !== undefined || hasFallback(d))) {
62
+ const key = `${hasFallback(carrier)}:${fallbackFlagText(1, carrier)}`;
63
+ const group = groups.get(key);
64
+ if (group) group.count++;
65
+ else groups.set(key, { carrier, count: 1 });
66
+ }
67
+ const flags = [...groups.values()].map(({ carrier, count }) => fallbackFlag(theme, carrier, count)).join(" ");
68
+ const flag = flags ? ` ${flags}` : "";
69
+ return `${theme.bold(`${all.length} agents finished`)}${flag} ${theme.fg("dim", "·")} ${names}`;
70
+ }
71
+
72
+ /** The transcript as an OSC 8 hyperlink, so a click opens it. */
73
+ function transcriptLine(path: string, theme: Theme): string {
74
+ return `${hyperlink("transcript ↗", pathToFileURL(path).href)} ${theme.fg("dim", path)}`;
75
+ }
76
+
77
+ /** Detail lines for one agent, shown only when expanded. */
78
+ function detailLines(d: NotificationDetails, theme: Theme, showCost: boolean, indent: string): string[] {
79
+ const lines: string[] = [];
80
+ const stats: string[] = [];
81
+ if (d.status !== "completed") {
82
+ stats.push(d.status === "steered" ? "completed (steered)" : d.status);
83
+ }
84
+ if (d.turnCount > 0) stats.push(formatTurns(d.turnCount, d.maxTurns));
85
+ stats.push(...statParts(d, showCost));
86
+ if (stats.length > 0) lines.push(indent + theme.fg("dim", stats.join(" · ")));
87
+ if (d.error) lines.push(indent + theme.fg("error", d.error));
88
+ if (d.resumeHint) lines.push(indent + theme.fg("warning", d.resumeHint));
89
+ if (d.modelNote) lines.push(indent + theme.fg("warning", d.modelNote));
90
+ for (const line of d.resultPreview.split("\n").slice(0, EXPANDED_PREVIEW_LINES)) {
91
+ lines.push(indent + theme.fg("dim", line));
92
+ }
93
+ if (d.outputFile) lines.push(indent + transcriptLine(d.outputFile, theme));
94
+ return lines;
95
+ }
96
+
97
+ /** First line truncated (core folds on it), the rest wrapped. */
98
+ class NoticeView implements Component {
99
+ constructor(
100
+ private readonly first: string,
101
+ private readonly rest: string[],
102
+ ) {}
103
+
104
+ render(width: number): string[] {
105
+ return [truncateToWidth(this.first, width), ...this.rest.flatMap(line => wrapTextWithAnsi(line, width))];
106
+ }
107
+
108
+ invalidate(): void { /* static */ }
109
+ }
110
+
111
+ export function renderNotification(
112
+ details: NotificationDetails,
113
+ expanded: boolean,
114
+ theme: Theme,
115
+ showCost: boolean,
116
+ ): Component {
117
+ const all = [details, ...(details.others ?? [])];
118
+ if (all.length === 1) {
119
+ return new NoticeView(
120
+ noticeSummary(details, theme, showCost),
121
+ expanded ? detailLines(details, theme, showCost, " ") : [],
122
+ );
123
+ }
124
+ const rest: string[] = [];
125
+ if (expanded) {
126
+ for (const d of all) {
127
+ rest.push(noticeSummary(d, theme, showCost));
128
+ rest.push(...detailLines(d, theme, showCost, " "));
129
+ }
130
+ if (showCost) {
131
+ const total = formatCost(all.reduce((sum, a) => sum + (a.totalCost ?? 0), 0));
132
+ if (total) {
133
+ const tokens = all.reduce((sum, a) => sum + a.totalTokens, 0);
134
+ rest.push(theme.fg("dim", `${formatTokens(tokens)} · ${total} in total`));
135
+ }
136
+ }
137
+ }
138
+ return new NoticeView(groupSummary(all, theme), rest);
139
+ }
@@ -0,0 +1,105 @@
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
+ import { selectItem } from "./select-item.js";
15
+
16
+ /** Format an ISO timestamp as relative time ("in 4h", "2d ago", "—"). */
17
+ function relTime(iso: string | undefined, now = Date.now()): string {
18
+ if (!iso) return "—";
19
+ const t = new Date(iso).getTime();
20
+ if (Number.isNaN(t)) return "—";
21
+ const diff = t - now;
22
+ const abs = Math.abs(diff);
23
+ const future = diff > 0;
24
+ if (abs < 60_000) return future ? "in <1m" : "<1m ago";
25
+ const m = Math.round(abs / 60_000);
26
+ if (m < 60) return future ? `in ${m}m` : `${m}m ago`;
27
+ const h = Math.round(abs / 3_600_000);
28
+ if (h < 24) 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
+
33
+ /** One-line status icon. */
34
+ function statusIcon(j: ScheduledSubagent): string {
35
+ if (!j.enabled) return "✗";
36
+ if (j.lastStatus === "error") return "!";
37
+ if (j.lastStatus === "running") return "⋯";
38
+ return "✓";
39
+ }
40
+
41
+ /** Compact selectable row — name, schedule, agent type, next/last run, count. */
42
+ function formatJob(j: ScheduledSubagent, scheduler: SubagentScheduler): string {
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
+
55
+ /** Multi-line details block for the cancel confirm. */
56
+ function formatDetails(j: ScheduledSubagent, scheduler: SubagentScheduler): string {
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
+ /**
71
+ * List scheduled jobs; selecting one opens a cancel-confirm with details.
72
+ * Returns when the user backs out or after a cancellation.
73
+ */
74
+ export async function showSchedulesMenu(
75
+ ctx: ExtensionCommandContext,
76
+ scheduler: SubagentScheduler,
77
+ ): Promise<void> {
78
+ if (!scheduler.isActive()) {
79
+ ctx.ui.notify("Scheduler is not active in this session.", "warning");
80
+ return;
81
+ }
82
+
83
+ const jobs = scheduler.list();
84
+ if (jobs.length === 0) {
85
+ ctx.ui.notify("No scheduled jobs.", "info");
86
+ return;
87
+ }
88
+
89
+ // Numbered + item-paired: two jobs whose names agree in the first 18
90
+ // characters format identically, and matching the returned string back
91
+ // against a parallel label array cancelled whichever came first.
92
+ const job = await selectItem(
93
+ ctx.ui,
94
+ `Scheduled jobs (${jobs.length}) — select to cancel`,
95
+ jobs,
96
+ j => formatJob(j, scheduler),
97
+ );
98
+ if (!job) return;
99
+
100
+ const ok = await ctx.ui.confirm(`Cancel "${job.name}"?`, formatDetails(job, scheduler));
101
+ if (!ok) return;
102
+
103
+ scheduler.removeJob(job.id);
104
+ ctx.ui.notify(`Cancelled "${job.name}".`, "info");
105
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * select-item.ts — pick an item from a list via `ctx.ui.select`, safely.
3
+ *
4
+ * Pi's dialog API is `select(title, options: string[]) => Promise<string | undefined>`:
5
+ * strings in, string out, with no index or value form. Callers therefore have to
6
+ * map the returned string back to the item it came from, and the obvious way —
7
+ * `labels.indexOf(choice)` over a parallel array — silently resolves to the
8
+ * FIRST match whenever two rows format identically. Row formatters here truncate
9
+ * (job names to 18 chars, agent descriptions to whatever fits), and the text they
10
+ * truncate is LLM-authored, so collisions are ordinary rather than exotic.
11
+ *
12
+ * This numbers every row, which makes the labels unique by construction — no
13
+ * data-dependent branch that only executes in the case nobody exercises — and
14
+ * keeps each label paired with its item so a later edit that sorts or filters
15
+ * between building and resolving cannot desync them.
16
+ */
17
+
18
+ /** Minimal shape of the `ctx.ui` surface this needs. */
19
+ export interface SelectUI {
20
+ select(title: string, options: string[]): Promise<string | undefined>;
21
+ }
22
+
23
+ /**
24
+ * Show a numbered picker and return the chosen item (not its label).
25
+ *
26
+ * Returns undefined when the user escapes, or when the returned string is not
27
+ * one we offered.
28
+ */
29
+ export async function selectItem<T>(
30
+ ui: SelectUI,
31
+ title: string,
32
+ items: readonly T[],
33
+ format: (item: T, index: number) => string,
34
+ ): Promise<T | undefined> {
35
+ // Pad the number so a 10+ item list keeps its columns aligned.
36
+ const width = String(items.length).length;
37
+ const rows = items.map((item, i) => ({
38
+ item,
39
+ label: `${String(i + 1).padStart(width)}. ${format(item, i)}`,
40
+ }));
41
+
42
+ const choice = await ui.select(title, rows.map(r => r.label));
43
+ if (!choice) return undefined;
44
+ return rows.find(r => r.label === choice)?.item;
45
+ }
@@ -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
+ }