@ian-pascoe/pi-termctrl 0.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.
@@ -0,0 +1,113 @@
1
+ import type { MessageRenderer } from "@earendil-works/pi-coding-agent";
2
+ import { Text, type Component } from "@earendil-works/pi-tui";
3
+ import { Type, type Static } from "typebox";
4
+ import { Value } from "typebox/value";
5
+ import type { ExitNotice } from "./termctrl-registry.js";
6
+ import type { TerminalExit } from "./terminal-driver.js";
7
+
8
+ /** `customType` of Exit notification messages. */
9
+ export const EXIT_NOTIFICATION_TYPE = "pi-termctrl.exit";
10
+
11
+ const ExitNotificationDetailsSchema = Type.Object({
12
+ exits: Type.Array(
13
+ Type.Object({
14
+ id: Type.String(),
15
+ kind: Type.Union([Type.Literal("terminal"), Type.Literal("background_job")]),
16
+ command: Type.String(),
17
+ exit_code: Type.Union([Type.Number(), Type.Null()]),
18
+ signal: Type.Union([Type.String(), Type.Null()]),
19
+ duration_ms: Type.Number(),
20
+ }),
21
+ ),
22
+ });
23
+ /** Structured details carried by an Exit notification message. */
24
+ export type ExitNotificationDetails = Static<typeof ExitNotificationDetailsSchema>;
25
+
26
+ /** Describe how a process ended in words the agent and user both read. */
27
+ export function describeExitStatus(exit: TerminalExit): string {
28
+ if (exit.signal !== null && exit.code !== null)
29
+ return `exited with code ${exit.code} (${exit.signal})`;
30
+ if (exit.signal !== null) return `ended by ${exit.signal}`;
31
+ return `exited with code ${exit.code ?? "unknown"}`;
32
+ }
33
+
34
+ /** Format a duration as `850ms`, `12s`, `3m 4s` or `2h 5m`. */
35
+ export function formatDuration(milliseconds: number): string {
36
+ if (milliseconds < 1_000) return `${Math.max(0, Math.round(milliseconds))}ms`;
37
+ const seconds = Math.round(milliseconds / 1_000);
38
+ if (seconds < 60) return `${seconds}s`;
39
+ const minutes = Math.floor(seconds / 60);
40
+ if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
41
+ return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
42
+ }
43
+
44
+ function lastLines(output: string, count: number): string {
45
+ if (count === 0) return "";
46
+ const lines = output.replace(/\n+$/u, "").split("\n");
47
+ return lines.slice(-count).join("\n");
48
+ }
49
+
50
+ function headline(notice: ExitNotice): string {
51
+ const label = notice.kind === "terminal" ? "Terminal" : "Background job";
52
+ return `${label} ${notice.id} ${describeExitStatus(notice.exit)} after ${formatDuration(notice.durationMs)}: ${notice.command}`;
53
+ }
54
+
55
+ /** The text and details of one Exit notification message. */
56
+ export interface ExitNotificationMessage {
57
+ readonly content: string;
58
+ readonly details: ExitNotificationDetails;
59
+ }
60
+
61
+ /** Build the message text and details for one batch of Exit notifications. */
62
+ export function formatExitNotification(
63
+ notices: readonly ExitNotice[],
64
+ tailLines: number,
65
+ ): ExitNotificationMessage {
66
+ const content = notices
67
+ .map((notice) => {
68
+ const tail = lastLines(notice.output, tailLines);
69
+ const source = notice.kind === "terminal" ? "final screen" : "output";
70
+ return tail === ""
71
+ ? headline(notice)
72
+ : `${headline(notice)}\nLast lines of ${source}:\n${tail}`;
73
+ })
74
+ .join("\n\n");
75
+ return {
76
+ content,
77
+ details: {
78
+ exits: notices.map((notice) => ({
79
+ id: notice.id,
80
+ kind: notice.kind === "job" ? ("background_job" as const) : ("terminal" as const),
81
+ command: notice.command,
82
+ exit_code: notice.exit.code,
83
+ signal: notice.exit.signal,
84
+ duration_ms: notice.durationMs,
85
+ })),
86
+ },
87
+ };
88
+ }
89
+
90
+ type ExitNotificationRenderer = MessageRenderer<ExitNotificationDetails>;
91
+ type RenderedMessage = Parameters<ExitNotificationRenderer>[0];
92
+
93
+ function messageText(content: RenderedMessage["content"]): string {
94
+ if (Value.Check(Type.String(), content)) return content;
95
+ return content.map((part) => (part.type === "text" ? part.text : "")).join("");
96
+ }
97
+
98
+ /** Compact renderer: one line per exit, with the full text when expanded. */
99
+ export const renderExitNotification: ExitNotificationRenderer = (
100
+ message,
101
+ options,
102
+ theme,
103
+ ): Component => {
104
+ if (options.expanded || !Value.Check(ExitNotificationDetailsSchema, message.details)) {
105
+ return new Text(theme.fg("muted", messageText(message.content)), options.outputPad, 0);
106
+ }
107
+ const lines = message.details.exits.map((exit) => {
108
+ const failed = exit.exit_code !== 0 || exit.signal !== null;
109
+ const status = describeExitStatus({ code: exit.exit_code, signal: exit.signal });
110
+ return `${theme.fg(failed ? "warning" : "success", "■")} ${theme.bold(exit.id)} ${status} · ${formatDuration(exit.duration_ms)} · ${theme.fg("muted", exit.command)}`;
111
+ });
112
+ return new Text(lines.join("\n"), options.outputPad, 0);
113
+ };
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { default } from "./pi-termctrl-extension.js";
@@ -0,0 +1,186 @@
1
+ import {
2
+ getAgentDir,
3
+ getShellConfig,
4
+ SettingsManager,
5
+ type ExtensionAPI,
6
+ type ExtensionContext,
7
+ type ExtensionFactory,
8
+ type SessionShutdownEvent,
9
+ } from "@earendil-works/pi-coding-agent";
10
+ import { createBashReplacement, RunningBashCalls } from "./bash-replacement.js";
11
+ import {
12
+ EXIT_NOTIFICATION_TYPE,
13
+ formatExitNotification,
14
+ renderExitNotification,
15
+ } from "./exit-notification.js";
16
+ import { TermctrlPsController } from "./ps-panel.js";
17
+ import { resolveTermctrlBinary, type TermctrlBinaryResolution } from "./termctrl-binary.js";
18
+ import { createTermctrlDriver } from "./termctrl-driver.js";
19
+ import { TermctrlRegistry } from "./termctrl-registry.js";
20
+ import { resolveTermctrlSettings, type TerminalViewport } from "./pi-termctrl-settings.js";
21
+ import type { TerminalDriver } from "./terminal-driver.js";
22
+ import {
23
+ createTerminalListTool,
24
+ createTerminalSendTool,
25
+ createTerminalStartTool,
26
+ createTerminalStopTool,
27
+ type TerminalShell,
28
+ } from "./terminal-tools.js";
29
+
30
+ /** Collaborators tests replace; production uses Pi's agent directory and the packaged binary. */
31
+ export interface PiTermctrlExtensionOptions {
32
+ readonly getAgentDirectory?: () => string;
33
+ readonly resolveBinary?: () => TermctrlBinaryResolution;
34
+ readonly createDriver?: (binaryPath: string) => Promise<TerminalDriver>;
35
+ }
36
+
37
+ interface ActiveSession {
38
+ readonly owner: string;
39
+ readonly calls: RunningBashCalls;
40
+ readonly ps: TermctrlPsController;
41
+ readonly shell: () => TerminalShell;
42
+ readonly viewport: TerminalViewport;
43
+ }
44
+
45
+ /**
46
+ * Owns one Pi session's binding to the process-wide registry, its tools, and its UI. Terminal
47
+ * tools register at load so hosts that check tool availability before `session_start`, such as
48
+ * Minimal Subagents child agents, see them; tools that depend on settings register at
49
+ * `session_start`.
50
+ */
51
+ class PiTermctrlController {
52
+ private active: ActiveSession | undefined;
53
+ private readonly binary: TermctrlBinaryResolution;
54
+ private readonly registry: TermctrlRegistry;
55
+
56
+ constructor(
57
+ private readonly pi: ExtensionAPI,
58
+ private readonly options: PiTermctrlExtensionOptions,
59
+ ) {
60
+ const binary = (options.resolveBinary ?? resolveTermctrlBinary)();
61
+ const createDriver = options.createDriver ?? createTermctrlDriver;
62
+ this.binary = binary;
63
+ this.registry = TermctrlRegistry.acquire({
64
+ createDriver: () =>
65
+ binary.kind === "available"
66
+ ? createDriver(binary.path)
67
+ : Promise.reject(new Error(`termctrl is unavailable: ${binary.reason}`)),
68
+ });
69
+ }
70
+
71
+ register(): void {
72
+ this.pi.registerMessageRenderer(EXIT_NOTIFICATION_TYPE, renderExitNotification);
73
+ this.pi.registerCommand("ps", {
74
+ description: "Show and stop Terminals and Background jobs",
75
+ handler: async () => {
76
+ await this.active?.ps.open();
77
+ },
78
+ });
79
+ if (this.binary.kind === "available") {
80
+ const runtime = {
81
+ registry: this.registry,
82
+ shell: () => this.session().shell(),
83
+ viewport: () => this.session().viewport,
84
+ };
85
+ this.pi.registerTool(createTerminalStartTool(runtime));
86
+ this.pi.registerTool(createTerminalSendTool(runtime));
87
+ this.registerManagementTools();
88
+ }
89
+ this.pi.on("session_start", (_event, context) => this.start(context));
90
+ this.pi.on("session_shutdown", (event) => this.shutdown(event));
91
+ }
92
+
93
+ private registerManagementTools(): void {
94
+ this.pi.registerTool(createTerminalStopTool(this.registry));
95
+ this.pi.registerTool(createTerminalListTool(this.registry));
96
+ }
97
+
98
+ private session(): ActiveSession {
99
+ if (this.active === undefined) throw new Error("Pi Termctrl has no active session.");
100
+ return this.active;
101
+ }
102
+
103
+ private start(context: ExtensionContext): void {
104
+ const settingsManager = SettingsManager.create(
105
+ context.cwd,
106
+ (this.options.getAgentDirectory ?? getAgentDir)(),
107
+ { projectTrusted: context.isProjectTrusted() },
108
+ );
109
+ const settings = resolveTermctrlSettings(settingsManager);
110
+ if (settings.warnings.length > 0) {
111
+ context.ui.notify(`Pi Termctrl settings:\n- ${settings.warnings.join("\n- ")}`, "warning");
112
+ }
113
+ const registry = this.registry;
114
+ const owner = context.sessionManager.getSessionId();
115
+ registry.bindOwner(owner, (notices) => {
116
+ const message = formatExitNotification(notices, settings.exitTailLines);
117
+ this.pi.sendMessage(
118
+ {
119
+ customType: EXIT_NOTIFICATION_TYPE,
120
+ content: message.content,
121
+ display: true,
122
+ details: message.details,
123
+ },
124
+ { triggerTurn: true, deliverAs: "steer" },
125
+ );
126
+ });
127
+
128
+ const shellPath = settingsManager.getShellPath();
129
+ const commandPrefix = settingsManager.getShellCommandPrefix();
130
+ if (this.binary.kind === "missing") {
131
+ context.ui.notify(
132
+ `Pi Termctrl: Terminal tools are unavailable: ${this.binary.reason}`,
133
+ "warning",
134
+ );
135
+ if (settings.replaceBash) this.registerManagementTools();
136
+ }
137
+ const currentRegistry = () => TermctrlRegistry.current() ?? registry;
138
+ const calls = new RunningBashCalls(currentRegistry);
139
+ if (settings.replaceBash) {
140
+ this.pi.registerTool(
141
+ createBashReplacement({
142
+ cwd: context.cwd,
143
+ commandPrefix,
144
+ shellPath,
145
+ registry: currentRegistry,
146
+ calls,
147
+ }),
148
+ );
149
+ }
150
+ this.active = {
151
+ owner,
152
+ calls,
153
+ ps: new TermctrlPsController(registry, context),
154
+ shell: () => {
155
+ const shell = getShellConfig(shellPath);
156
+ return { shell: shell.shell, args: shell.args, commandPrefix };
157
+ },
158
+ viewport: settings.defaultViewport,
159
+ };
160
+ }
161
+
162
+ private async shutdown(event: SessionShutdownEvent): Promise<void> {
163
+ const active = this.active;
164
+ this.active = undefined;
165
+ if (active === undefined) return;
166
+ active.calls.dispose();
167
+ const reload = event.reason === "reload";
168
+ active.ps.dispose(!reload);
169
+ if (reload) {
170
+ this.registry.unbindOwner(active.owner);
171
+ return;
172
+ }
173
+ await this.registry.shutdownOwner(active.owner);
174
+ }
175
+ }
176
+
177
+ /** Compose the Pi Termctrl extension. Nothing starts until a Terminal or Background job is needed. */
178
+ export function createPiTermctrlExtension(
179
+ options: PiTermctrlExtensionOptions = {},
180
+ ): ExtensionFactory {
181
+ return (pi) => new PiTermctrlController(pi, options).register();
182
+ }
183
+
184
+ const piTermctrlExtension = createPiTermctrlExtension();
185
+
186
+ export default piTermctrlExtension;
@@ -0,0 +1,128 @@
1
+ import type { SettingsManager } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import { Value } from "typebox/value";
4
+
5
+ const VIEWPORT_LIMIT = 1_000;
6
+ const EXIT_TAIL_LIMIT = 1_000;
7
+
8
+ type JsonObject = { readonly [key: string]: JsonValue };
9
+ type JsonValue = null | boolean | number | string | readonly JsonValue[] | JsonObject;
10
+ type SettingsScope = "global" | "project";
11
+
12
+ /** A Terminal's size in character cells. */
13
+ export interface TerminalViewport {
14
+ readonly cols: number;
15
+ readonly rows: number;
16
+ }
17
+
18
+ /** The effective `settings.termctrl` values after layering global and trusted project settings. */
19
+ export interface ResolvedTermctrlSettings {
20
+ readonly replaceBash: boolean;
21
+ readonly defaultViewport: TerminalViewport;
22
+ readonly exitTailLines: number;
23
+ readonly warnings: readonly string[];
24
+ }
25
+
26
+ /** Pi's core settings type or a test document carrying the extension-owned `termctrl` value. */
27
+ export type TermctrlSettingsDocumentInput =
28
+ | ReturnType<SettingsManager["getGlobalSettings"]>
29
+ | { readonly termctrl?: JsonValue };
30
+
31
+ /** Reads Pi's already trust-filtered global and project settings documents. */
32
+ export interface TermctrlSettingsReader {
33
+ getGlobalSettings(): TermctrlSettingsDocumentInput;
34
+ getProjectSettings(): TermctrlSettingsDocumentInput;
35
+ }
36
+
37
+ interface TermctrlLayer {
38
+ replaceBash?: boolean;
39
+ cols?: number;
40
+ rows?: number;
41
+ exitTailLines?: number;
42
+ }
43
+
44
+ export const DEFAULT_TERMCTRL_SETTINGS: Omit<ResolvedTermctrlSettings, "warnings"> = {
45
+ replaceBash: true,
46
+ defaultViewport: { cols: 120, rows: 40 },
47
+ exitTailLines: 20,
48
+ };
49
+
50
+ const JsonObjectSchema = Type.Record(Type.String(), Type.Any());
51
+ const SettingsDocumentSchema = Type.Object({ termctrl: Type.Optional(Type.Any()) });
52
+ const BooleanSchema = Type.Boolean();
53
+ const ViewportDimensionSchema = Type.Integer({ minimum: 1, maximum: VIEWPORT_LIMIT });
54
+ const ExitTailLinesSchema = Type.Integer({ minimum: 0, maximum: EXIT_TAIL_LIMIT });
55
+
56
+ function isJsonObject(value: JsonValue): value is JsonObject {
57
+ return Value.Check(JsonObjectSchema, value);
58
+ }
59
+
60
+ function readLayer(
61
+ document: TermctrlSettingsDocumentInput,
62
+ scope: SettingsScope,
63
+ warnings: string[],
64
+ ): TermctrlLayer {
65
+ const layer: TermctrlLayer = {};
66
+ if (!Value.Check(SettingsDocumentSchema, document)) {
67
+ warnings.push(`${scope} settings: expected a JSON object`);
68
+ return layer;
69
+ }
70
+ const section: JsonValue | undefined = document.termctrl;
71
+ if (section === undefined) return layer;
72
+ if (!isJsonObject(section)) {
73
+ warnings.push(`${scope} termctrl: expected a JSON object`);
74
+ return layer;
75
+ }
76
+ for (const [key, value] of Object.entries(section)) {
77
+ const path = `${scope} termctrl.${key}`;
78
+ switch (key) {
79
+ case "replaceBash":
80
+ if (Value.Check(BooleanSchema, value)) layer.replaceBash = value;
81
+ else warnings.push(`${path}: expected a boolean`);
82
+ break;
83
+ case "exitTailLines":
84
+ if (Value.Check(ExitTailLinesSchema, value)) layer.exitTailLines = value;
85
+ else warnings.push(`${path}: expected an integer from 0 to ${EXIT_TAIL_LIMIT}`);
86
+ break;
87
+ case "defaultViewport":
88
+ readViewport(value, path, layer, warnings);
89
+ break;
90
+ default:
91
+ warnings.push(`${path}: unknown field`);
92
+ }
93
+ }
94
+ return layer;
95
+ }
96
+
97
+ function readViewport(value: JsonValue, path: string, layer: TermctrlLayer, warnings: string[]) {
98
+ if (!isJsonObject(value)) {
99
+ warnings.push(`${path}: expected a JSON object`);
100
+ return;
101
+ }
102
+ for (const [key, dimension] of Object.entries(value)) {
103
+ if (key !== "cols" && key !== "rows") {
104
+ warnings.push(`${path}.${key}: unknown field`);
105
+ continue;
106
+ }
107
+ if (Value.Check(ViewportDimensionSchema, dimension)) layer[key] = dimension;
108
+ else warnings.push(`${path}.${key}: expected an integer from 1 to ${VIEWPORT_LIMIT}`);
109
+ }
110
+ }
111
+
112
+ /** Resolve global and trusted-project `termctrl` settings, keeping valid fields around warnings. */
113
+ export function resolveTermctrlSettings(reader: TermctrlSettingsReader): ResolvedTermctrlSettings {
114
+ const warnings: string[] = [];
115
+ const globalLayer = readLayer(reader.getGlobalSettings(), "global", warnings);
116
+ const projectLayer = readLayer(reader.getProjectSettings(), "project", warnings);
117
+ const defaults = DEFAULT_TERMCTRL_SETTINGS;
118
+ return {
119
+ replaceBash: projectLayer.replaceBash ?? globalLayer.replaceBash ?? defaults.replaceBash,
120
+ defaultViewport: {
121
+ cols: projectLayer.cols ?? globalLayer.cols ?? defaults.defaultViewport.cols,
122
+ rows: projectLayer.rows ?? globalLayer.rows ?? defaults.defaultViewport.rows,
123
+ },
124
+ exitTailLines:
125
+ projectLayer.exitTailLines ?? globalLayer.exitTailLines ?? defaults.exitTailLines,
126
+ warnings,
127
+ };
128
+ }