@jopqior/pi-subagents 1.0.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 (110) hide show
  1. package/CHANGELOG.md +2705 -0
  2. package/LICENSE +21 -0
  3. package/README.md +503 -0
  4. package/dist/public.d.ts +331 -0
  5. package/dist/settings.d.ts +82 -0
  6. package/docs/architecture/architecture.md +1566 -0
  7. package/docs/architecture/client-server-opportunities.md +127 -0
  8. package/docs/architecture/history/phase-1-api-boundary.md +8 -0
  9. package/docs/architecture/history/phase-10-structural-decomposition.md +141 -0
  10. package/docs/architecture/history/phase-11-closure-to-class.md +100 -0
  11. package/docs/architecture/history/phase-12-complexity-test-fixtures.md +55 -0
  12. package/docs/architecture/history/phase-13-remaining-smells.md +88 -0
  13. package/docs/architecture/history/phase-14-strip-policy.md +49 -0
  14. package/docs/architecture/history/phase-15-domain-model-evolution.md +73 -0
  15. package/docs/architecture/history/phase-16-invert-dependencies.md +144 -0
  16. package/docs/architecture/history/phase-17-core-consolidation.md +214 -0
  17. package/docs/architecture/history/phase-18-reconsider-ui.md +166 -0
  18. package/docs/architecture/history/phase-19-implement-ui-decisions.md +282 -0
  19. package/docs/architecture/history/phase-2-remove-scheduling.md +9 -0
  20. package/docs/architecture/history/phase-20-result-delivery.md +245 -0
  21. package/docs/architecture/history/phase-21-classification-model-boundary.md +107 -0
  22. package/docs/architecture/history/phase-3-remove-rpc-groupjoin.md +11 -0
  23. package/docs/architecture/history/phase-4-implement-service.md +8 -0
  24. package/docs/architecture/history/phase-5-decompose-index.md +42 -0
  25. package/docs/architecture/history/phase-7-encapsulation.md +173 -0
  26. package/docs/architecture/history/phase-8-testability.md +103 -0
  27. package/docs/architecture/history/phase-9-observation-ctx.md +122 -0
  28. package/docs/comparison-with-upstream.md +77 -0
  29. package/docs/configuration.md +364 -0
  30. package/docs/decisions/0001-deferred-patches.md +80 -0
  31. package/docs/decisions/0002-extensions-on-a-minimal-core.md +125 -0
  32. package/docs/decisions/0003-publish-bundled-type-declarations.md +71 -0
  33. package/docs/decisions/0004-reconsider-ui-direction.md +279 -0
  34. package/docs/decisions/0005-subagent-record-admission-policy.md +106 -0
  35. package/docs/decisions/0006-inherited-prompt-is-identity-only.md +104 -0
  36. package/docs/decisions/0007-transcript-viewer-is-not-an-overlay.md +228 -0
  37. package/docs/decisions/0008-inherited-region-is-shared-parts.md +81 -0
  38. package/docs/decisions/0009-portable-inheritance-is-provider-scoped.md +116 -0
  39. package/package.json +91 -0
  40. package/src/config/agent-types.ts +135 -0
  41. package/src/config/custom-agents.ts +151 -0
  42. package/src/config/default-agents.ts +121 -0
  43. package/src/config/invocation-config.ts +167 -0
  44. package/src/config/thinking-level.ts +58 -0
  45. package/src/debug.ts +14 -0
  46. package/src/handlers/index.ts +3 -0
  47. package/src/handlers/interrupt.ts +58 -0
  48. package/src/handlers/lifecycle.ts +71 -0
  49. package/src/handlers/widget-events.ts +49 -0
  50. package/src/index.ts +292 -0
  51. package/src/layered-settings.ts +105 -0
  52. package/src/lifecycle/child-lifecycle.ts +115 -0
  53. package/src/lifecycle/child-shutdown.ts +105 -0
  54. package/src/lifecycle/concurrency-limiter.ts +55 -0
  55. package/src/lifecycle/create-subagent-session.ts +335 -0
  56. package/src/lifecycle/parent-snapshot.ts +119 -0
  57. package/src/lifecycle/run-listeners.ts +37 -0
  58. package/src/lifecycle/selection-scope.ts +116 -0
  59. package/src/lifecycle/spawn-selection.ts +259 -0
  60. package/src/lifecycle/subagent-manager.ts +546 -0
  61. package/src/lifecycle/subagent-session.ts +347 -0
  62. package/src/lifecycle/subagent-state.ts +404 -0
  63. package/src/lifecycle/subagent.ts +885 -0
  64. package/src/lifecycle/turn-limits.ts +13 -0
  65. package/src/lifecycle/usage.ts +60 -0
  66. package/src/lifecycle/workspace-bracket.ts +76 -0
  67. package/src/lifecycle/workspace.ts +46 -0
  68. package/src/observation/composite-subagent-observer.ts +74 -0
  69. package/src/observation/notification.ts +430 -0
  70. package/src/observation/outcome-delivery.ts +239 -0
  71. package/src/observation/record-observer.ts +78 -0
  72. package/src/observation/renderer.ts +161 -0
  73. package/src/observation/subagent-events-observer.ts +148 -0
  74. package/src/runtime.ts +137 -0
  75. package/src/service/service-adapter.ts +201 -0
  76. package/src/service/service.ts +246 -0
  77. package/src/session/ask-parent-tool.ts +69 -0
  78. package/src/session/content-items.ts +53 -0
  79. package/src/session/context.ts +80 -0
  80. package/src/session/conversation.ts +49 -0
  81. package/src/session/env.ts +40 -0
  82. package/src/session/model-resolver.ts +126 -0
  83. package/src/session/notify-parent-tool.ts +83 -0
  84. package/src/session/package-exclusions.ts +75 -0
  85. package/src/session/prompts.ts +231 -0
  86. package/src/session/provider-inheritance.ts +56 -0
  87. package/src/session/selection-catalogue.ts +143 -0
  88. package/src/session/session-config.ts +202 -0
  89. package/src/session/session-dir.ts +38 -0
  90. package/src/settings.ts +447 -0
  91. package/src/tools/agent-tool.ts +305 -0
  92. package/src/tools/background-spawner.ts +83 -0
  93. package/src/tools/foreground-runner.ts +159 -0
  94. package/src/tools/get-result-renderer.ts +119 -0
  95. package/src/tools/get-result-report.ts +84 -0
  96. package/src/tools/get-result-tool.ts +192 -0
  97. package/src/tools/helpers.ts +118 -0
  98. package/src/tools/result-renderer.ts +153 -0
  99. package/src/tools/spawn-config.ts +192 -0
  100. package/src/tools/steer-tool.ts +109 -0
  101. package/src/types.ts +143 -0
  102. package/src/ui/agent-widget.ts +333 -0
  103. package/src/ui/bounded-lines.ts +45 -0
  104. package/src/ui/display.ts +180 -0
  105. package/src/ui/glyphs.ts +62 -0
  106. package/src/ui/session-navigation.ts +150 -0
  107. package/src/ui/session-navigator.ts +255 -0
  108. package/src/ui/subagents-settings.ts +179 -0
  109. package/src/ui/transcript-content.ts +374 -0
  110. package/src/ui/widget-renderer.ts +301 -0
@@ -0,0 +1,150 @@
1
+ /**
2
+ * session-navigation.ts — Pure selection and transcript-sourcing for native session navigation.
3
+ *
4
+ * Splits the unit-testable core of the `/subagents:sessions` command from its TUI
5
+ * wiring (`session-navigator.ts`): which subagents are navigable and how a picked
6
+ * agent's transcript is sourced (live, in this slice).
7
+ *
8
+ * The `TranscriptSource` seam decouples *how messages are sourced* (live record
9
+ * here; a file snapshot in a follow-up) from *how they render* — the renderer
10
+ * (`session-navigator.ts`, which mounts Pi's per-entry components) talks only to
11
+ * this seam. Rendering lives in the SDK/TUI module because the per-entry
12
+ * components require a `TUI`, `cwd`, and markdown theme.
13
+ */
14
+
15
+ import { buildSessionContext, parseSessionEntries, type SessionEntry, type ToolDefinition } from "@earendil-works/pi-coding-agent";
16
+ import type { AgentConfigLookup } from "#src/config/agent-types";
17
+ import { isRunningStatus, type SubagentStatus } from "#src/lifecycle/subagent-state";
18
+ import type { AgentSessionEvent, SessionMessage, SubagentType } from "#src/types";
19
+ import { formatDuration, getDisplayName } from "#src/ui/display";
20
+
21
+ // ─────────────────────────────────────────────────────────────────────────────
22
+
23
+ /** The record fields the navigator reads to label and live-source a transcript. */
24
+ export interface NavigableSubagent {
25
+ readonly id: string;
26
+ readonly type: SubagentType;
27
+ readonly description: string;
28
+ readonly status: SubagentStatus;
29
+ readonly startedAt: number;
30
+ readonly completedAt: number | undefined;
31
+ readonly toolUses: number;
32
+ readonly activeTools: ReadonlyMap<string, string>;
33
+ readonly responseText: string;
34
+ readonly agentMessages: readonly SessionMessage[];
35
+ /** Persisted transcript path, retained after the live session is released. */
36
+ readonly outputFile: string | undefined;
37
+ isSessionReady(): boolean;
38
+ subscribeToUpdates(fn: (event: AgentSessionEvent) => void): (() => void) | undefined;
39
+ getToolDefinition(name: string): ToolDefinition | undefined;
40
+ }
41
+
42
+ /**
43
+ * A navigable entry plus the label shown in the picker.
44
+ *
45
+ * A `live` entry sources its transcript from the in-memory record; a `snapshot`
46
+ * entry sources it from the persisted session file (the session was released by
47
+ * the retention sweep, but the record and its transcript pointer survive).
48
+ */
49
+ export type NavigationEntry =
50
+ | { readonly kind: "live"; readonly label: string; readonly record: NavigableSubagent }
51
+ | { readonly kind: "snapshot"; readonly label: string; readonly outputFile: string };
52
+
53
+ /** The fields `buildLabel` reads — shared by the live and snapshot (released-session) label paths. */
54
+ interface LabelFields {
55
+ readonly type: SubagentType;
56
+ readonly description: string;
57
+ readonly status: SubagentStatus;
58
+ readonly startedAt: number;
59
+ readonly completedAt: number | undefined;
60
+ readonly toolUses: number;
61
+ }
62
+
63
+ /** Running-agent streaming state, surfaced by a live source. */
64
+ export interface StreamingState {
65
+ readonly activeTools: ReadonlyMap<string, string>;
66
+ readonly responseText: string;
67
+ }
68
+
69
+ /** Liveness-agnostic transcript source consumed by the renderer. */
70
+ export interface TranscriptSource {
71
+ /** Current message history. */
72
+ getMessages(): readonly SessionMessage[];
73
+ /**
74
+ * Subscribe to changes; returns an unsubscribe, or undefined for a static
75
+ * snapshot. The session event is forwarded so a consumer can route on it —
76
+ * a streaming delta and a settled message warrant very different work.
77
+ */
78
+ subscribe(onChange: (event?: AgentSessionEvent) => void): (() => void) | undefined;
79
+ /** Running-agent streaming state, or undefined when not streaming. */
80
+ streaming(): StreamingState | undefined;
81
+ /** Resolve a registered tool definition by name, for Pi's tool-execution components. */
82
+ getToolDefinition(name: string): ToolDefinition | undefined;
83
+ }
84
+
85
+ /**
86
+ * Label every navigable subagent for the picker: records with a live session
87
+ * source their transcript in-memory (`live`); records whose session the
88
+ * retention sweep released but which retain a transcript pointer source it from
89
+ * disk (`snapshot`). Records with neither are not navigable. Live entries first.
90
+ */
91
+ export function listNavigableAgents(
92
+ agents: readonly NavigableSubagent[],
93
+ registry: AgentConfigLookup,
94
+ ): NavigationEntry[] {
95
+ const live: NavigationEntry[] = [];
96
+ const snapshots: NavigationEntry[] = [];
97
+ for (const record of agents) {
98
+ if (record.isSessionReady()) {
99
+ live.push({ kind: "live", record, label: buildLabel(record, registry) });
100
+ } else if (record.outputFile) {
101
+ snapshots.push({ kind: "snapshot", outputFile: record.outputFile, label: buildLabel(record, registry, true) });
102
+ }
103
+ }
104
+ return [...live, ...snapshots];
105
+ }
106
+
107
+ /**
108
+ * Source a transcript from a persisted child-session JSONL snapshot.
109
+ *
110
+ * For an agent whose live session the retention sweep released: the in-memory
111
+ * message history is gone, but the session file survives on disk (and the
112
+ * record retains its path). Reads the file, drops the `SessionHeader`, and resolves the
113
+ * message list via Pi's own parser. A static snapshot — no subscription, no
114
+ * streaming, no live tool registry. `readFile` is injected so this module makes
115
+ * no `fs` calls.
116
+ */
117
+ export function fileSnapshotSource(
118
+ outputFile: string,
119
+ readFile: (path: string) => string,
120
+ ): TranscriptSource {
121
+ const entries = parseSessionEntries(readFile(outputFile));
122
+ const sessionEntries = entries.filter((entry): entry is SessionEntry => entry.type !== "session");
123
+ const { messages } = buildSessionContext(sessionEntries);
124
+ return {
125
+ getMessages: () => messages,
126
+ subscribe: () => undefined,
127
+ streaming: () => undefined,
128
+ getToolDefinition: () => undefined,
129
+ };
130
+ }
131
+
132
+ /** Source a transcript live from an in-memory record (this slice's only source). */
133
+ export function liveSource(record: NavigableSubagent): TranscriptSource {
134
+ return {
135
+ getMessages: () => record.agentMessages,
136
+ subscribe: (onChange) => record.subscribeToUpdates(onChange),
137
+ streaming: () =>
138
+ isRunningStatus(record.status)
139
+ ? { activeTools: record.activeTools, responseText: record.responseText }
140
+ : undefined,
141
+ getToolDefinition: (name) => record.getToolDefinition(name),
142
+ };
143
+ }
144
+
145
+ function buildLabel(fields: LabelFields, registry: AgentConfigLookup, released = false): string {
146
+ const name = getDisplayName(fields.type, registry);
147
+ const duration = formatDuration(fields.startedAt, fields.completedAt);
148
+ const marker = released ? " · session released (snapshot)" : "";
149
+ return `${name} (${fields.description}) · ${fields.toolUses} tools · ${fields.status} · ${duration}${marker}`;
150
+ }
@@ -0,0 +1,255 @@
1
+ /**
2
+ * session-navigator.ts — The `/subagents:sessions` command: pick a subagent and
3
+ * read its transcript through Pi's own per-entry session components.
4
+ *
5
+ * SDK/TUI consumer half of native session navigation. The unit-testable core
6
+ * (selection, sourcing) lives in `session-navigation.ts`; this module wires that
7
+ * core to the command picker and a read-only scrollable pane, and owns the
8
+ * renderer — it mounts Pi's interactive components (`AssistantMessageComponent`,
9
+ * `ToolExecutionComponent`, …) into a `Container`, mirroring Pi's own
10
+ * `renderSessionContext` mapping. Rendering lives here, not in the pure module,
11
+ * because the components require a `TUI`, `cwd`, and markdown theme.
12
+ *
13
+ * The pane is strictly read-only — steering stays in the `steer_subagent` tool
14
+ * and the widget. It consumes a `TranscriptSource`, so a released agent's disk
15
+ * snapshot (`fileSnapshotSource`) swaps in without touching the renderer or the pane.
16
+ *
17
+ * It mounts through `ui.custom`'s non-overlay path deliberately: Pi's regular-mode
18
+ * renderer composites overlays into the buffer that backs scrollback, so an overlay
19
+ * mount bakes this pane's chrome into terminal history. See
20
+ * `docs/decisions/0007-transcript-viewer-is-not-an-overlay.md`.
21
+ */
22
+
23
+ import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
24
+ import {
25
+ type Component,
26
+ type MarkdownTheme,
27
+ matchesKey,
28
+ type TUI,
29
+ truncateToWidth,
30
+ visibleWidth,
31
+ } from "@earendil-works/pi-tui";
32
+ import type { AgentConfigLookup } from "#src/config/agent-types";
33
+ import type { Theme } from "#src/ui/display";
34
+ import { fileSnapshotSource, listNavigableAgents, liveSource, type NavigableSubagent, type TranscriptSource } from "#src/ui/session-navigation";
35
+ import { TranscriptContent } from "#src/ui/transcript-content";
36
+
37
+ // ─────────────────────────────────────────────────────────────────────────────
38
+
39
+ /** Chrome lines: the header and the footer. The pane is docked, so it needs no frame. */
40
+ const CHROME_LINES = 2;
41
+ const MIN_VIEWPORT = 3;
42
+ const VIEWPORT_HEIGHT_PCT = 70;
43
+
44
+ /** Component factory shape Pi's `ui.custom` invokes to mount a component. */
45
+ export type CustomComponentFactory<R> = (
46
+ tui: TUI,
47
+ theme: Theme,
48
+ keybindings: unknown,
49
+ done: (result: R) => void,
50
+ ) => Component;
51
+
52
+ /** Narrow UI interface — only the `ctx.ui` methods the navigator calls. */
53
+ export interface SessionNavigatorUI {
54
+ select(title: string, options: string[]): Promise<string | undefined>;
55
+ notify(message: string, level: "info" | "warning" | "error"): void;
56
+ custom<R>(component: CustomComponentFactory<R>, options?: unknown): Promise<R>;
57
+ }
58
+
59
+ /** Parameters for one `/subagents:sessions` invocation. */
60
+ export interface SessionNavigatorParams {
61
+ ui: SessionNavigatorUI;
62
+ agents: readonly NavigableSubagent[];
63
+ registry: AgentConfigLookup;
64
+ /** Working directory for tool-call rendering (relative path display). */
65
+ cwd: string;
66
+ /** Reads a persisted session file for the file-snapshot source. */
67
+ readFile: (path: string) => string;
68
+ }
69
+
70
+ /** Options for the read-only transcript pane. */
71
+ export interface TranscriptPaneOptions {
72
+ tui: TUI;
73
+ theme: Theme;
74
+ source: TranscriptSource;
75
+ done: (result: undefined) => void;
76
+ cwd: string;
77
+ markdownTheme: MarkdownTheme;
78
+ }
79
+
80
+ /**
81
+ * Handler for the `/subagents:sessions` slash command.
82
+ *
83
+ * Lists navigable subagents, lets the operator pick one, and opens its transcript
84
+ * read-only. Receives the agent snapshot (`manager.listAgents()`) rather than the
85
+ * manager, so it stays a reactive consumer with no inbound call into the core.
86
+ */
87
+ export class SessionNavigatorHandler {
88
+ async handle({ ui, agents, registry, cwd, readFile }: SessionNavigatorParams): Promise<void> {
89
+ const entries = listNavigableAgents(agents, registry);
90
+ if (entries.length === 0) {
91
+ ui.notify("No subagent sessions to view.", "info");
92
+ return;
93
+ }
94
+
95
+ const choice = await ui.select(
96
+ "Subagent sessions",
97
+ entries.map((entry) => entry.label),
98
+ );
99
+ const entry = entries.find((candidate) => candidate.label === choice);
100
+ if (!entry) return;
101
+
102
+ let source: TranscriptSource;
103
+ try {
104
+ source = entry.kind === "live" ? liveSource(entry.record) : fileSnapshotSource(entry.outputFile, readFile);
105
+ } catch {
106
+ ui.notify("Could not read the session transcript file.", "error");
107
+ return;
108
+ }
109
+ const markdownTheme = getMarkdownTheme();
110
+ await ui.custom<undefined>(
111
+ (tui, theme, _keybindings, done) =>
112
+ new TranscriptPane({ tui, theme, source, done, cwd, markdownTheme }),
113
+ { overlay: false },
114
+ );
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Read-only scrollable transcript pane.
120
+ *
121
+ * Owns scroll state, chrome, and key handling; the rows it paints come from a
122
+ * `TranscriptContent` collaborator, which holds the transcript's components and
123
+ * refreshes them when the source changes (live agents).
124
+ */
125
+ export class TranscriptPane implements Component {
126
+ private scrollOffset = 0;
127
+ private autoScroll = true;
128
+ private unsubscribe: (() => void) | undefined;
129
+ private closed = false;
130
+
131
+ private readonly tui: TUI;
132
+ private readonly theme: Theme;
133
+ private readonly done: (result: undefined) => void;
134
+ private readonly content: TranscriptContent;
135
+ /** Width the host last rendered at; input must use the same layout. */
136
+ private renderedWidth: number | undefined;
137
+
138
+ constructor({ tui, theme, source, done, cwd, markdownTheme }: TranscriptPaneOptions) {
139
+ this.tui = tui;
140
+ this.theme = theme;
141
+ this.done = done;
142
+ this.content = new TranscriptContent({ tui, cwd, markdownTheme, source });
143
+ this.unsubscribe = source.subscribe((event) => {
144
+ if (this.closed) return;
145
+ this.content.apply(event);
146
+ this.tui.requestRender();
147
+ });
148
+ }
149
+
150
+ handleInput(data: string): void {
151
+ if (matchesKey(data, "escape") || matchesKey(data, "q")) {
152
+ this.closed = true;
153
+ this.done(undefined);
154
+ return;
155
+ }
156
+
157
+ const { viewportHeight, maxScroll } = this.scrollBounds(this.inputWidth());
158
+
159
+ if (matchesKey(data, "up") || matchesKey(data, "k")) {
160
+ this.scrollOffset = Math.max(0, this.scrollOffset - 1);
161
+ this.autoScroll = this.scrollOffset >= maxScroll;
162
+ } else if (matchesKey(data, "down") || matchesKey(data, "j")) {
163
+ this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
164
+ this.autoScroll = this.scrollOffset >= maxScroll;
165
+ } else if (matchesKey(data, "pageUp") || matchesKey(data, "shift+up")) {
166
+ this.scrollOffset = Math.max(0, this.scrollOffset - viewportHeight);
167
+ this.autoScroll = false;
168
+ } else if (matchesKey(data, "pageDown") || matchesKey(data, "shift+down")) {
169
+ this.scrollOffset = Math.min(maxScroll, this.scrollOffset + viewportHeight);
170
+ this.autoScroll = this.scrollOffset >= maxScroll;
171
+ } else if (matchesKey(data, "home")) {
172
+ this.scrollOffset = 0;
173
+ this.autoScroll = false;
174
+ } else if (matchesKey(data, "end")) {
175
+ this.scrollOffset = maxScroll;
176
+ this.autoScroll = true;
177
+ }
178
+ }
179
+
180
+ render(width: number): string[] {
181
+ if (width < 6) return [];
182
+ const th = this.theme;
183
+ this.renderedWidth = width;
184
+ const lines: string[] = [];
185
+
186
+ // No frame, so no padding either: a row padded to the full terminal width
187
+ // wraps onto the next terminal row.
188
+ const fit = (content: string): string => truncateToWidth(content, width);
189
+
190
+ lines.push(fit(th.bold("Subagent session")));
191
+
192
+ const { totalLines, viewportHeight, maxScroll } = this.scrollBounds(width);
193
+ if (this.autoScroll) this.scrollOffset = maxScroll;
194
+ const visibleStart = Math.min(this.scrollOffset, maxScroll);
195
+ const visible = this.content.slice(width, visibleStart, viewportHeight);
196
+ for (let i = 0; i < viewportHeight; i++) lines.push(fit(visible[i] ?? ""));
197
+
198
+ const scrollPct =
199
+ totalLines <= viewportHeight
200
+ ? "100%"
201
+ : `${Math.round(((visibleStart + viewportHeight) / totalLines) * 100)}%`;
202
+ const footerLeft = th.fg("dim", `${totalLines} lines · ${scrollPct}`);
203
+ const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn · Esc close");
204
+ const footerGap = Math.max(1, width - visibleWidth(footerLeft) - visibleWidth(footerRight));
205
+ lines.push(fit(footerLeft + " ".repeat(footerGap) + footerRight));
206
+
207
+ return lines;
208
+ }
209
+
210
+ // fallow-ignore-next-line unused-class-member
211
+ invalidate(): void {
212
+ this.content.invalidate();
213
+ }
214
+
215
+ dispose(): void {
216
+ this.closed = true;
217
+ if (this.unsubscribe) {
218
+ this.unsubscribe();
219
+ this.unsubscribe = undefined;
220
+ }
221
+ }
222
+
223
+ // ---- Private ----
224
+
225
+ /**
226
+ * Scroll geometry at a given layout width.
227
+ *
228
+ * The single place a width becomes a viewport height, so `render` and
229
+ * `handleInput` cannot disagree about how far the transcript scrolls.
230
+ */
231
+ private scrollBounds(width: number): { totalLines: number; viewportHeight: number; maxScroll: number } {
232
+ const totalLines = this.content.lineCount(width);
233
+ const viewportHeight = this.viewportHeight(totalLines);
234
+ return { totalLines, viewportHeight, maxScroll: Math.max(0, totalLines - viewportHeight) };
235
+ }
236
+
237
+ /**
238
+ * The width `handleInput` must lay out at: the one the host actually supplied,
239
+ * so scroll bounds match the layout on screen. Before the first paint there is
240
+ * none, so fall back to the full terminal width.
241
+ */
242
+ private inputWidth(): number {
243
+ return this.renderedWidth ?? this.tui.terminal.columns;
244
+ }
245
+
246
+ /**
247
+ * Rows the transcript gets: what it needs, capped at the pane's share of the
248
+ * terminal so a long or live transcript cannot crowd out the conversation, and
249
+ * floored so a short one still reads as a pane.
250
+ */
251
+ private viewportHeight(totalLines: number): number {
252
+ const cap = Math.floor((this.tui.terminal.rows * VIEWPORT_HEIGHT_PCT) / 100) - CHROME_LINES;
253
+ return Math.max(MIN_VIEWPORT, Math.min(totalLines, cap));
254
+ }
255
+ }
@@ -0,0 +1,179 @@
1
+ // ---- Narrow interfaces ----
2
+
3
+ /** The toast a settings mutation returns for the UI to display. */
4
+ export interface SettingsToast {
5
+ message: string;
6
+ level: "info" | "warning";
7
+ }
8
+
9
+ /** Narrow settings interface required by the subagents:settings command. */
10
+ export interface SubagentsSettingsManager {
11
+ readonly maxConcurrent: number;
12
+ readonly defaultMaxTurns: number | undefined;
13
+ readonly graceTurns: number;
14
+ readonly consumedSessionRetentionMinutes: number;
15
+ readonly unconsumedSessionRetentionMinutes: number;
16
+ readonly abortAllOnInterrupt: boolean;
17
+ readonly midRunUpdates: boolean;
18
+ applyMaxConcurrent(n: number): SettingsToast;
19
+ applyDefaultMaxTurns(n: number): SettingsToast;
20
+ applyGraceTurns(n: number): SettingsToast;
21
+ applyConsumedSessionRetentionMinutes(n: number): SettingsToast;
22
+ applyUnconsumedSessionRetentionMinutes(n: number): SettingsToast;
23
+ toggleAbortAllOnInterrupt(): SettingsToast;
24
+ toggleMidRunUpdates(): SettingsToast;
25
+ }
26
+
27
+ /** Narrow UI interface — only the ctx.ui methods the settings handler calls. */
28
+ export interface SubagentsSettingsUI {
29
+ select(title: string, options: string[]): Promise<string | undefined>;
30
+ input(title: string, defaultValue?: string): Promise<string | undefined>;
31
+ notify(message: string, level: "info" | "warning" | "error"): void;
32
+ }
33
+
34
+ // ---- Descriptor table ----
35
+
36
+ /** Fields every setting needs to render its line in the select list. */
37
+ interface SettingDescriptorBase {
38
+ /** Prefix used both to build the select option and to match the user's choice. */
39
+ label: string;
40
+ /** Current value rendered in the select option (e.g. "unlimited" for an unset default). */
41
+ currentDisplay: (settings: SubagentsSettingsManager) => string | number;
42
+ }
43
+
44
+ /** Describes one numeric setting's prompt, validation, and apply behavior. */
45
+ interface NumericSettingDescriptor extends SettingDescriptorBase {
46
+ kind: "numeric";
47
+ /** Title shown on the input prompt. */
48
+ inputTitle: string;
49
+ /** Value pre-filled into the input box. */
50
+ inputDefault: (settings: SubagentsSettingsManager) => string;
51
+ /** Minimum accepted integer, inclusive. */
52
+ minimum: number;
53
+ /** Warning shown when the parsed value is below the minimum. */
54
+ validationMessage: string;
55
+ /** Applies the validated value and returns the toast to display. */
56
+ apply: (settings: SubagentsSettingsManager, n: number) => SettingsToast;
57
+ }
58
+
59
+ /** Describes one boolean setting, flipped directly from the select list. */
60
+ interface ToggleSettingDescriptor extends SettingDescriptorBase {
61
+ kind: "toggle";
62
+ /** Flips the setting and returns the toast to display. */
63
+ toggle: (settings: SubagentsSettingsManager) => SettingsToast;
64
+ }
65
+
66
+ type SettingDescriptor = NumericSettingDescriptor | ToggleSettingDescriptor;
67
+
68
+ const SETTINGS: readonly SettingDescriptor[] = [
69
+ {
70
+ kind: "numeric",
71
+ label: "Max concurrency",
72
+ currentDisplay: (settings) => settings.maxConcurrent,
73
+ inputTitle: "Max concurrent background agents",
74
+ inputDefault: (settings) => String(settings.maxConcurrent),
75
+ minimum: 1,
76
+ validationMessage: "Must be a positive integer.",
77
+ apply: (settings, n) => settings.applyMaxConcurrent(n),
78
+ },
79
+ {
80
+ kind: "numeric",
81
+ label: "Default max turns",
82
+ currentDisplay: (settings) => settings.defaultMaxTurns ?? "unlimited",
83
+ inputTitle: "Default max turns before wrap-up (0 = unlimited)",
84
+ inputDefault: (settings) => String(settings.defaultMaxTurns ?? 0),
85
+ minimum: 0,
86
+ validationMessage: "Must be 0 (unlimited) or a positive integer.",
87
+ apply: (settings, n) => settings.applyDefaultMaxTurns(n),
88
+ },
89
+ {
90
+ kind: "numeric",
91
+ label: "Grace turns",
92
+ currentDisplay: (settings) => settings.graceTurns,
93
+ inputTitle: "Grace turns after wrap-up steer",
94
+ inputDefault: (settings) => String(settings.graceTurns),
95
+ minimum: 1,
96
+ validationMessage: "Must be a positive integer.",
97
+ apply: (settings, n) => settings.applyGraceTurns(n),
98
+ },
99
+ {
100
+ kind: "numeric",
101
+ label: "Consumed-session retention",
102
+ currentDisplay: (settings) => `${settings.consumedSessionRetentionMinutes} min`,
103
+ inputTitle: "Minutes to retain a consumed agent's session",
104
+ inputDefault: (settings) => String(settings.consumedSessionRetentionMinutes),
105
+ minimum: 1,
106
+ validationMessage: "Must be a positive integer.",
107
+ apply: (settings, n) => settings.applyConsumedSessionRetentionMinutes(n),
108
+ },
109
+ {
110
+ kind: "numeric",
111
+ label: "Unconsumed-session retention",
112
+ currentDisplay: (settings) => `${settings.unconsumedSessionRetentionMinutes} min`,
113
+ inputTitle: "Minutes to retain an unconsumed agent's session (safety cap)",
114
+ inputDefault: (settings) => String(settings.unconsumedSessionRetentionMinutes),
115
+ minimum: 1,
116
+ validationMessage: "Must be a positive integer.",
117
+ apply: (settings, n) => settings.applyUnconsumedSessionRetentionMinutes(n),
118
+ },
119
+ {
120
+ kind: "toggle",
121
+ label: "Abort all subagents on ESC",
122
+ currentDisplay: (settings) => (settings.abortAllOnInterrupt ? "on" : "off"),
123
+ toggle: (settings) => settings.toggleAbortAllOnInterrupt(),
124
+ },
125
+ {
126
+ kind: "toggle",
127
+ label: "Mid-run updates from background subagents",
128
+ currentDisplay: (settings) => (settings.midRunUpdates ? "on" : "off"),
129
+ toggle: (settings) => settings.toggleMidRunUpdates(),
130
+ },
131
+ ];
132
+
133
+ // ---- Class ----
134
+
135
+ /**
136
+ * Handler for the `/subagents:settings` slash command.
137
+ *
138
+ * Call `handle({ ui })` from the Pi command registration to open the interactive
139
+ * settings list. Lifted from `AgentsMenuHandler.showSettings`.
140
+ */
141
+ export class SubagentsSettingsHandler {
142
+ constructor(private readonly settings: SubagentsSettingsManager) {}
143
+
144
+ async handle({ ui }: { ui: SubagentsSettingsUI }): Promise<void> {
145
+ const options = SETTINGS.map(
146
+ (d) => `${d.label} (current: ${d.currentDisplay(this.settings)})`,
147
+ );
148
+ const choice = await ui.select("Settings", options);
149
+ if (!choice) return;
150
+
151
+ const descriptor = SETTINGS.find((d) => choice.startsWith(d.label));
152
+ if (!descriptor) return;
153
+
154
+ if (descriptor.kind === "toggle") {
155
+ const toast = descriptor.toggle(this.settings);
156
+ ui.notify(toast.message, toast.level);
157
+ return;
158
+ }
159
+
160
+ await this.promptNumeric(ui, descriptor);
161
+ }
162
+
163
+ /** Ask for a number, validate it against the descriptor, apply it, and notify. */
164
+ private async promptNumeric(
165
+ ui: SubagentsSettingsUI,
166
+ descriptor: NumericSettingDescriptor,
167
+ ): Promise<void> {
168
+ const val = await ui.input(descriptor.inputTitle, descriptor.inputDefault(this.settings));
169
+ if (!val) return;
170
+
171
+ const n = parseInt(val, 10);
172
+ if (n >= descriptor.minimum) {
173
+ const toast = descriptor.apply(this.settings, n);
174
+ ui.notify(toast.message, toast.level);
175
+ } else {
176
+ ui.notify(descriptor.validationMessage, "warning");
177
+ }
178
+ }
179
+ }