pi-tian-background-terminals 0.1.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.
- package/LICENSE +21 -0
- package/README.md +124 -0
- package/docs/implementation-guide.md +430 -0
- package/index.ts +584 -0
- package/package.json +59 -0
- package/src/domain.ts +102 -0
- package/src/manager.ts +1070 -0
- package/src/output.ts +163 -0
- package/src/prompt.ts +195 -0
- package/src/result-delivery.ts +27 -0
- package/src/runtime.ts +41 -0
- package/src/ui/output-view.ts +79 -0
- package/src/ui/ps.ts +624 -0
package/index.ts
ADDED
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background terminals — execute no-stdin shell commands that automatically
|
|
3
|
+
* yield into the background when they outlive a bounded initial wait.
|
|
4
|
+
*
|
|
5
|
+
* One tool for the LLM:
|
|
6
|
+
* - bash: overrides Pi's built-in bash, returns final output when the command
|
|
7
|
+
* finishes promptly, otherwise returns a terminal id and notifies exactly
|
|
8
|
+
* once when it exits. Inspection and termination remain user-owned via /ps.
|
|
9
|
+
*
|
|
10
|
+
* While ≥1 process runs, a one-line widget above the editor shows
|
|
11
|
+
* "N background terminal(s) running • /ps to view". `/ps` opens a two-stage
|
|
12
|
+
* full-screen overlay (list → read-only detail with stdout/stderr toggle).
|
|
13
|
+
*
|
|
14
|
+
* Architecture: Effect v4 core (manager service behind one ManagedRuntime);
|
|
15
|
+
* this file is the async boundary where tool handlers run effects via
|
|
16
|
+
* runTool. Node stream plumbing inside the manager is plain callbacks.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import * as fs from "node:fs";
|
|
20
|
+
import * as path from "node:path";
|
|
21
|
+
import type {
|
|
22
|
+
ExtensionAPI,
|
|
23
|
+
ExtensionContext,
|
|
24
|
+
ExtensionUIContext,
|
|
25
|
+
} from "@earendil-works/pi-coding-agent";
|
|
26
|
+
import {
|
|
27
|
+
createBashToolDefinition,
|
|
28
|
+
getMarkdownTheme,
|
|
29
|
+
SettingsManager,
|
|
30
|
+
} from "@earendil-works/pi-coding-agent";
|
|
31
|
+
import { Markdown, Text } from "@earendil-works/pi-tui";
|
|
32
|
+
import { Type } from "typebox";
|
|
33
|
+
import { SpawnError, type TerminalSnapshot } from "./src/domain.ts";
|
|
34
|
+
import {
|
|
35
|
+
DEFAULT_YIELD_TIME_MS,
|
|
36
|
+
MAX_RUNTIME_TIMEOUT_SECONDS,
|
|
37
|
+
MAX_YIELD_TIME_MS,
|
|
38
|
+
MIN_YIELD_TIME_MS,
|
|
39
|
+
TerminalManager,
|
|
40
|
+
type TerminalManagerShape,
|
|
41
|
+
} from "./src/manager.ts";
|
|
42
|
+
import {
|
|
43
|
+
BASH_PARAMETER_DESCRIPTIONS,
|
|
44
|
+
BASH_PROMPT_GUIDELINES,
|
|
45
|
+
BASH_PROMPT_SNIPPET,
|
|
46
|
+
BASH_TOOL_DESCRIPTION,
|
|
47
|
+
buildBashProgress,
|
|
48
|
+
buildBashResult,
|
|
49
|
+
buildTerminalResultMessage,
|
|
50
|
+
describeTerminal,
|
|
51
|
+
} from "./src/prompt.ts";
|
|
52
|
+
import { createDeferredResultDelivery } from "./src/result-delivery.ts";
|
|
53
|
+
import {
|
|
54
|
+
createTerminalRuntime,
|
|
55
|
+
runTool,
|
|
56
|
+
type TerminalRuntime,
|
|
57
|
+
} from "./src/runtime.ts";
|
|
58
|
+
import { sanitizeText } from "./src/ui/output-view.ts";
|
|
59
|
+
import { openTerminalPicker } from "./src/ui/ps.ts";
|
|
60
|
+
|
|
61
|
+
const WIDGET_KEY = "background-terminals";
|
|
62
|
+
const UPDATE_THROTTLE_MS = 100;
|
|
63
|
+
const SESSION_ENV_KEYS = [
|
|
64
|
+
"PI_SESSION_ID",
|
|
65
|
+
"PI_SESSION_FILE",
|
|
66
|
+
"PI_PROVIDER",
|
|
67
|
+
"PI_MODEL",
|
|
68
|
+
"PI_REASONING_LEVEL",
|
|
69
|
+
] as const;
|
|
70
|
+
|
|
71
|
+
export interface BackgroundTerminalsDependencies {
|
|
72
|
+
readonly createRuntime?: typeof createTerminalRuntime;
|
|
73
|
+
readonly createForegroundBash?: typeof createBashToolDefinition;
|
|
74
|
+
readonly resolveShellSettings?: (ctx: ExtensionContext) => {
|
|
75
|
+
readonly shellPath?: string;
|
|
76
|
+
readonly commandPrefix?: string;
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Dependency injection is public only so the pre-spawn fallback is testable. */
|
|
81
|
+
export function createBackgroundTerminalsExtension(
|
|
82
|
+
dependencies: BackgroundTerminalsDependencies = {},
|
|
83
|
+
) {
|
|
84
|
+
const makeRuntime = dependencies.createRuntime ?? createTerminalRuntime;
|
|
85
|
+
const makeForegroundBash =
|
|
86
|
+
dependencies.createForegroundBash ?? createBashToolDefinition;
|
|
87
|
+
const resolveShellSettings =
|
|
88
|
+
dependencies.resolveShellSettings ??
|
|
89
|
+
((ctx: ExtensionContext) => {
|
|
90
|
+
const settings = SettingsManager.create(ctx.cwd, undefined, {
|
|
91
|
+
projectTrusted: ctx.isProjectTrusted(),
|
|
92
|
+
});
|
|
93
|
+
return {
|
|
94
|
+
shellPath: settings.getShellPath(),
|
|
95
|
+
commandPrefix: settings.getShellCommandPrefix(),
|
|
96
|
+
};
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
return function backgroundTerminals(pi: ExtensionAPI) {
|
|
100
|
+
let runtime: TerminalRuntime | undefined;
|
|
101
|
+
let managerPromise: Promise<TerminalManagerShape> | undefined;
|
|
102
|
+
let sessionContext: ExtensionContext | undefined;
|
|
103
|
+
let ui: ExtensionUIContext | undefined;
|
|
104
|
+
let unsubStatus: (() => void) | undefined;
|
|
105
|
+
const resultDelivery = createDeferredResultDelivery<TerminalSnapshot>();
|
|
106
|
+
|
|
107
|
+
const getRuntime = () => (runtime ??= makeRuntime());
|
|
108
|
+
|
|
109
|
+
/** Resolve the manager service once per runtime and wire the extension hooks. */
|
|
110
|
+
const getManager = () => {
|
|
111
|
+
managerPromise ??= getRuntime()
|
|
112
|
+
.runPromise(TerminalManager)
|
|
113
|
+
.then((manager) => {
|
|
114
|
+
manager.view.setOnSettled(onSettled);
|
|
115
|
+
unsubStatus?.();
|
|
116
|
+
unsubStatus = manager.view.subscribe(() => updateWidget(manager));
|
|
117
|
+
updateWidget(manager);
|
|
118
|
+
return manager;
|
|
119
|
+
});
|
|
120
|
+
return managerPromise;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/** One-line widget directly above the editor, only while ≥1 is running.
|
|
124
|
+
* Called on every manager notification (including per-output-chunk), so it
|
|
125
|
+
* only touches setWidget when the running count actually changes —
|
|
126
|
+
* replacing the widget factory hundreds of times a second would churn
|
|
127
|
+
* component creation for no visible difference. */
|
|
128
|
+
let widgetRunning = 0;
|
|
129
|
+
const updateWidget = (manager: TerminalManagerShape) => {
|
|
130
|
+
if (!ui) return;
|
|
131
|
+
try {
|
|
132
|
+
const running = manager.view
|
|
133
|
+
.list()
|
|
134
|
+
.filter((snap) => snap.status === "running").length;
|
|
135
|
+
if (running === widgetRunning) return;
|
|
136
|
+
widgetRunning = running;
|
|
137
|
+
if (running === 0) {
|
|
138
|
+
ui.setWidget(WIDGET_KEY, undefined);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
ui.setWidget(WIDGET_KEY, (_tui, theme) => {
|
|
142
|
+
const line =
|
|
143
|
+
theme.fg("warning", "■ ") +
|
|
144
|
+
theme.fg(
|
|
145
|
+
"text",
|
|
146
|
+
`${running} background terminal${running === 1 ? "" : "s"} running`,
|
|
147
|
+
) +
|
|
148
|
+
theme.fg("dim", " • ") +
|
|
149
|
+
theme.fg("accent", "/ps") +
|
|
150
|
+
theme.fg("dim", " to view");
|
|
151
|
+
return { render: () => [line], invalidate: () => {} };
|
|
152
|
+
});
|
|
153
|
+
} catch {
|
|
154
|
+
// UI may be unavailable (print/RPC modes or teardown).
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const deliverResult = (snap: TerminalSnapshot) => {
|
|
159
|
+
try {
|
|
160
|
+
pi.sendMessage(
|
|
161
|
+
{
|
|
162
|
+
customType: "background-terminal-result",
|
|
163
|
+
content: buildTerminalResultMessage(snap),
|
|
164
|
+
display: true,
|
|
165
|
+
details: {
|
|
166
|
+
id: snap.id,
|
|
167
|
+
title: snap.title,
|
|
168
|
+
status: snap.status,
|
|
169
|
+
exitCode: snap.exitCode,
|
|
170
|
+
signal: snap.signal,
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
// followUp: queued until the agent has no more tool calls — never
|
|
174
|
+
// interrupts a mid-turn stream. triggerTurn: wakes the model
|
|
175
|
+
// immediately iff idle; if busy, the queued follow-up is delivered
|
|
176
|
+
// when the current run settles. Either way exactly one delivery.
|
|
177
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
178
|
+
);
|
|
179
|
+
return true;
|
|
180
|
+
} catch (error) {
|
|
181
|
+
// Session may be shutting down, but retain the snapshot so any later
|
|
182
|
+
// agent-settled flush can retry instead of silently dropping it.
|
|
183
|
+
console.error("background-terminals: failed to deliver result", error);
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const flushResults = () => {
|
|
189
|
+
for (const snap of resultDelivery.drain()) {
|
|
190
|
+
if (!deliverResult(snap)) resultDelivery.defer(snap);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const onSettled = (snap: TerminalSnapshot, consumed: boolean) => {
|
|
195
|
+
if (consumed) {
|
|
196
|
+
// The initial bash wait is returning this settlement itself.
|
|
197
|
+
resultDelivery.consume([snap.id]);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
// Defer a deep-enough copy: the live snapshot's output views keep
|
|
201
|
+
// mutating (late flushes) after settle.
|
|
202
|
+
resultDelivery.defer({
|
|
203
|
+
...snap,
|
|
204
|
+
stdout: { ...snap.stdout },
|
|
205
|
+
stderr: { ...snap.stderr },
|
|
206
|
+
});
|
|
207
|
+
if (sessionContext?.isIdle()) flushResults();
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
pi.on("session_start", (_event, ctx) => {
|
|
211
|
+
sessionContext = ctx;
|
|
212
|
+
if (ctx.hasUI) ui = ctx.ui;
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Drain deferred results when the agent settles: together with the
|
|
216
|
+
// isIdle() fast path above and the Map-keyed delivery (drain clears),
|
|
217
|
+
// double delivery is structurally impossible — whoever drains first wins.
|
|
218
|
+
pi.on("agent_settled", flushResults);
|
|
219
|
+
|
|
220
|
+
// /new, /resume, /fork, /reload, and quit all emit session_shutdown for
|
|
221
|
+
// the old extension instance. Processes never survive a session
|
|
222
|
+
// transition: disposing the runtime runs the manager finalizer →
|
|
223
|
+
// disposeAll → every entry scope → SIGTERM→SIGKILL tree kill, each close
|
|
224
|
+
// bounded so a wedged process cannot hang shutdown.
|
|
225
|
+
pi.on("session_shutdown", async () => {
|
|
226
|
+
sessionContext = undefined;
|
|
227
|
+
resultDelivery.clear();
|
|
228
|
+
unsubStatus?.();
|
|
229
|
+
unsubStatus = undefined;
|
|
230
|
+
try {
|
|
231
|
+
ui?.setWidget(WIDGET_KEY, undefined);
|
|
232
|
+
} catch {
|
|
233
|
+
// UI may already be gone.
|
|
234
|
+
}
|
|
235
|
+
widgetRunning = 0;
|
|
236
|
+
ui = undefined;
|
|
237
|
+
const closing = runtime;
|
|
238
|
+
runtime = undefined;
|
|
239
|
+
managerPromise = undefined;
|
|
240
|
+
await closing?.dispose();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// --- Tool --------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
pi.registerTool({
|
|
246
|
+
// Registering the built-in name is Pi's supported override mechanism.
|
|
247
|
+
// The model sees one canonical shell tool, not a second execution lane.
|
|
248
|
+
name: "bash",
|
|
249
|
+
label: "bash",
|
|
250
|
+
description: BASH_TOOL_DESCRIPTION,
|
|
251
|
+
promptSnippet: BASH_PROMPT_SNIPPET,
|
|
252
|
+
promptGuidelines: BASH_PROMPT_GUIDELINES,
|
|
253
|
+
parameters: Type.Object({
|
|
254
|
+
command: Type.String({
|
|
255
|
+
description: BASH_PARAMETER_DESCRIPTIONS.command,
|
|
256
|
+
}),
|
|
257
|
+
timeout: Type.Optional(
|
|
258
|
+
Type.Number({
|
|
259
|
+
exclusiveMinimum: 0,
|
|
260
|
+
maximum: MAX_RUNTIME_TIMEOUT_SECONDS,
|
|
261
|
+
description: BASH_PARAMETER_DESCRIPTIONS.timeout,
|
|
262
|
+
}),
|
|
263
|
+
),
|
|
264
|
+
title: Type.Optional(
|
|
265
|
+
Type.String({
|
|
266
|
+
description: BASH_PARAMETER_DESCRIPTIONS.title,
|
|
267
|
+
}),
|
|
268
|
+
),
|
|
269
|
+
working_dir: Type.Optional(
|
|
270
|
+
Type.String({
|
|
271
|
+
description: BASH_PARAMETER_DESCRIPTIONS.workingDir,
|
|
272
|
+
}),
|
|
273
|
+
),
|
|
274
|
+
yield_time_ms: Type.Optional(
|
|
275
|
+
Type.Integer({
|
|
276
|
+
minimum: MIN_YIELD_TIME_MS,
|
|
277
|
+
maximum: MAX_YIELD_TIME_MS,
|
|
278
|
+
description: BASH_PARAMETER_DESCRIPTIONS.yieldTimeMs,
|
|
279
|
+
}),
|
|
280
|
+
),
|
|
281
|
+
}),
|
|
282
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
283
|
+
// Preserve the exact command text. Trimming here can break heredocs and
|
|
284
|
+
// multiline scripts; trim only for validation and the display title.
|
|
285
|
+
const command = params.command;
|
|
286
|
+
if (!command.trim()) throw new Error("command must not be empty.");
|
|
287
|
+
|
|
288
|
+
if (
|
|
289
|
+
params.timeout !== undefined &&
|
|
290
|
+
(!Number.isFinite(params.timeout) ||
|
|
291
|
+
params.timeout <= 0 ||
|
|
292
|
+
params.timeout > MAX_RUNTIME_TIMEOUT_SECONDS)
|
|
293
|
+
) {
|
|
294
|
+
throw new Error(
|
|
295
|
+
`timeout must be a finite number of seconds in (0, ${MAX_RUNTIME_TIMEOUT_SECONDS}].`,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const cwd = path.resolve(ctx.cwd, params.working_dir ?? ".");
|
|
300
|
+
try {
|
|
301
|
+
if (!fs.statSync(cwd).isDirectory()) {
|
|
302
|
+
throw new Error("not a directory");
|
|
303
|
+
}
|
|
304
|
+
} catch {
|
|
305
|
+
throw new Error(`working_dir is not a directory: ${cwd}`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Preserve Pi's built-in shellPath and shellCommandPrefix settings even
|
|
309
|
+
// though this extension replaces the built-in definition.
|
|
310
|
+
const { shellPath, commandPrefix } = resolveShellSettings(ctx);
|
|
311
|
+
const executionCommand = commandPrefix
|
|
312
|
+
? `${commandPrefix}\n${command}`
|
|
313
|
+
: command;
|
|
314
|
+
|
|
315
|
+
// Collapse whitespace before bounding: titles render in one-line rows.
|
|
316
|
+
const title =
|
|
317
|
+
(params.title ?? command).replace(/\s+/g, " ").trim().slice(0, 80) ||
|
|
318
|
+
"command";
|
|
319
|
+
|
|
320
|
+
const runForegroundFallback = async (
|
|
321
|
+
reason: unknown,
|
|
322
|
+
resetManagedRuntime: boolean,
|
|
323
|
+
) => {
|
|
324
|
+
if (resetManagedRuntime) {
|
|
325
|
+
const brokenRuntime = runtime;
|
|
326
|
+
runtime = undefined;
|
|
327
|
+
managerPromise = undefined;
|
|
328
|
+
await brokenRuntime?.dispose().catch(() => {});
|
|
329
|
+
}
|
|
330
|
+
const reasonText = reason instanceof Error ? reason.message : String(reason);
|
|
331
|
+
const warning =
|
|
332
|
+
`[Managed bash unavailable before spawn; using Pi's foreground bash fallback. ` +
|
|
333
|
+
`Automatic yielding and /ps tracking are unavailable for this call. Reason: ${reasonText.slice(0, 500)}]`;
|
|
334
|
+
if (ctx.hasUI) ctx.ui.notify(warning, "warning");
|
|
335
|
+
|
|
336
|
+
const fallback = makeForegroundBash(cwd, {
|
|
337
|
+
shellPath,
|
|
338
|
+
commandPrefix,
|
|
339
|
+
});
|
|
340
|
+
try {
|
|
341
|
+
const result = await fallback.execute(
|
|
342
|
+
toolCallId,
|
|
343
|
+
{ command, timeout: params.timeout },
|
|
344
|
+
signal,
|
|
345
|
+
onUpdate,
|
|
346
|
+
ctx,
|
|
347
|
+
);
|
|
348
|
+
return {
|
|
349
|
+
...result,
|
|
350
|
+
content: [
|
|
351
|
+
{ type: "text" as const, text: warning },
|
|
352
|
+
...result.content,
|
|
353
|
+
],
|
|
354
|
+
};
|
|
355
|
+
} catch (error) {
|
|
356
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
357
|
+
throw new Error(`${warning}\n\n${message}`);
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
let manager: TerminalManagerShape;
|
|
362
|
+
try {
|
|
363
|
+
manager = await getManager();
|
|
364
|
+
} catch (managerError) {
|
|
365
|
+
// Manager resolution precedes start(), so no child can exist yet.
|
|
366
|
+
return await runForegroundFallback(managerError, true);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const env = { ...process.env };
|
|
370
|
+
for (const key of SESSION_ENV_KEYS) delete env[key];
|
|
371
|
+
env.PI_SESSION_ID = ctx.sessionManager.getSessionId();
|
|
372
|
+
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
373
|
+
if (sessionFile) env.PI_SESSION_FILE = sessionFile;
|
|
374
|
+
if (ctx.model) {
|
|
375
|
+
env.PI_PROVIDER = ctx.model.provider;
|
|
376
|
+
env.PI_MODEL = ctx.model.id;
|
|
377
|
+
}
|
|
378
|
+
const thinkingLevel = pi.getThinkingLevel();
|
|
379
|
+
if (thinkingLevel) env.PI_REASONING_LEVEL = thinkingLevel;
|
|
380
|
+
|
|
381
|
+
let started: TerminalSnapshot;
|
|
382
|
+
try {
|
|
383
|
+
started = await runTool(
|
|
384
|
+
getRuntime(),
|
|
385
|
+
manager.start({
|
|
386
|
+
command,
|
|
387
|
+
executionCommand,
|
|
388
|
+
shellPath,
|
|
389
|
+
title,
|
|
390
|
+
cwd,
|
|
391
|
+
env,
|
|
392
|
+
timeoutMs:
|
|
393
|
+
params.timeout === undefined ? undefined : params.timeout * 1000,
|
|
394
|
+
}),
|
|
395
|
+
);
|
|
396
|
+
} catch (error) {
|
|
397
|
+
if (error instanceof SpawnError && error.fallbackSafe) {
|
|
398
|
+
return await runForegroundFallback(error, false);
|
|
399
|
+
}
|
|
400
|
+
// Concurrency, shutdown, asynchronous spawn failure, non-zero exit,
|
|
401
|
+
// timeout, and abort are never retried.
|
|
402
|
+
throw error;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
let updateTimer: NodeJS.Timeout | undefined;
|
|
406
|
+
let updateDirty = false;
|
|
407
|
+
let lastUpdateAt = 0;
|
|
408
|
+
const emitUpdate = () => {
|
|
409
|
+
if (!onUpdate || !updateDirty) return;
|
|
410
|
+
updateDirty = false;
|
|
411
|
+
lastUpdateAt = Date.now();
|
|
412
|
+
const snap = manager.view.get(started.id);
|
|
413
|
+
if (!snap || snap.status !== "running") return;
|
|
414
|
+
try {
|
|
415
|
+
onUpdate({
|
|
416
|
+
content: [{ type: "text", text: buildBashProgress(snap) }],
|
|
417
|
+
details: undefined,
|
|
418
|
+
});
|
|
419
|
+
} catch {
|
|
420
|
+
// A display update must never affect command execution.
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
const scheduleUpdate = () => {
|
|
424
|
+
if (!onUpdate) return;
|
|
425
|
+
updateDirty = true;
|
|
426
|
+
const delay = UPDATE_THROTTLE_MS - (Date.now() - lastUpdateAt);
|
|
427
|
+
if (delay <= 0) {
|
|
428
|
+
if (updateTimer) clearTimeout(updateTimer);
|
|
429
|
+
updateTimer = undefined;
|
|
430
|
+
emitUpdate();
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
updateTimer ??= setTimeout(() => {
|
|
434
|
+
updateTimer = undefined;
|
|
435
|
+
emitUpdate();
|
|
436
|
+
}, delay);
|
|
437
|
+
};
|
|
438
|
+
const unsubscribe = manager.view.subscribeTo(started.id, scheduleUpdate);
|
|
439
|
+
if (onUpdate) {
|
|
440
|
+
try {
|
|
441
|
+
onUpdate({ content: [], details: undefined });
|
|
442
|
+
} catch {
|
|
443
|
+
// Same display-only boundary.
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
let waited;
|
|
448
|
+
try {
|
|
449
|
+
waited = await runTool(
|
|
450
|
+
getRuntime(),
|
|
451
|
+
manager.waitForSettlement(
|
|
452
|
+
started.id,
|
|
453
|
+
params.yield_time_ms ?? DEFAULT_YIELD_TIME_MS,
|
|
454
|
+
),
|
|
455
|
+
{
|
|
456
|
+
signal,
|
|
457
|
+
interruptMessage: `Initial wait aborted; ${started.id} continues in the background and will report when it exits.`,
|
|
458
|
+
},
|
|
459
|
+
);
|
|
460
|
+
} finally {
|
|
461
|
+
unsubscribe();
|
|
462
|
+
if (updateTimer) clearTimeout(updateTimer);
|
|
463
|
+
}
|
|
464
|
+
const snap = waited.snapshot;
|
|
465
|
+
|
|
466
|
+
// A quick completion is returned by this tool call. Remove any already
|
|
467
|
+
// deferred result from the tiny start→wait registration race.
|
|
468
|
+
if (waited.settled || snap.status !== "running") {
|
|
469
|
+
resultDelivery.consume([snap.id]);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const text = buildBashResult(snap);
|
|
473
|
+
if (
|
|
474
|
+
snap.status === "failed" ||
|
|
475
|
+
snap.status === "timed_out" ||
|
|
476
|
+
snap.status === "killed"
|
|
477
|
+
) {
|
|
478
|
+
// Match Pi's built-in bash contract: unsuccessful foreground results
|
|
479
|
+
// are tool errors. Yielded failures arrive later as completion messages.
|
|
480
|
+
throw new Error(text);
|
|
481
|
+
}
|
|
482
|
+
return {
|
|
483
|
+
content: [{ type: "text", text }],
|
|
484
|
+
// Exact BashToolDetails-compatible shape. Paths remain in bounded text
|
|
485
|
+
// because stdout and stderr have separate complete spill files.
|
|
486
|
+
details: undefined,
|
|
487
|
+
};
|
|
488
|
+
},
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
// --- Result message rendering ------------------------------------------
|
|
492
|
+
|
|
493
|
+
pi.registerMessageRenderer(
|
|
494
|
+
"background-terminal-result",
|
|
495
|
+
(message, { expanded }, theme) => {
|
|
496
|
+
const details = (message.details ?? {}) as {
|
|
497
|
+
id?: string;
|
|
498
|
+
title?: string;
|
|
499
|
+
status?: string;
|
|
500
|
+
exitCode?: number;
|
|
501
|
+
signal?: string;
|
|
502
|
+
};
|
|
503
|
+
const failed = details.status === "failed";
|
|
504
|
+
const timedOut = details.status === "timed_out";
|
|
505
|
+
const killed = details.status === "killed";
|
|
506
|
+
const icon = failed || timedOut
|
|
507
|
+
? theme.fg("error", "x")
|
|
508
|
+
: killed
|
|
509
|
+
? theme.fg("muted", "■")
|
|
510
|
+
: theme.fg("success", "■");
|
|
511
|
+
const how = killed
|
|
512
|
+
? "killed"
|
|
513
|
+
: timedOut
|
|
514
|
+
? "timed out"
|
|
515
|
+
: (details.signal ?? `exit ${details.exitCode ?? "?"}`);
|
|
516
|
+
const header =
|
|
517
|
+
`${icon} ` +
|
|
518
|
+
theme.fg("accent", theme.bold(`terminal ${details.id ?? "?"}`)) +
|
|
519
|
+
theme.fg("muted", ` · ${details.title ?? ""} · ${how}`);
|
|
520
|
+
|
|
521
|
+
const content =
|
|
522
|
+
typeof message.content === "string" ? message.content : "";
|
|
523
|
+
// Remove only the summary line; the Error line (when present) is part
|
|
524
|
+
// of the actual result and must remain visible. The body carries raw
|
|
525
|
+
// process output — sanitize ANSI/control chars or the transcript smears.
|
|
526
|
+
const body = sanitizeText(content.split("\n").slice(1).join("\n").trim());
|
|
527
|
+
|
|
528
|
+
if (expanded) {
|
|
529
|
+
const md = new Markdown(`${body}`, 0, 0, getMarkdownTheme());
|
|
530
|
+
const container = new Text(header, 0, 0);
|
|
531
|
+
return {
|
|
532
|
+
render: (width: number) => [
|
|
533
|
+
...container.render(width),
|
|
534
|
+
...md.render(width),
|
|
535
|
+
],
|
|
536
|
+
invalidate: () => {
|
|
537
|
+
container.invalidate();
|
|
538
|
+
md.invalidate();
|
|
539
|
+
},
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const previewLines = body.split("\n").slice(0, 8);
|
|
544
|
+
let text = header;
|
|
545
|
+
for (const line of previewLines)
|
|
546
|
+
text += `\n${theme.fg("toolOutput", line)}`;
|
|
547
|
+
if (body.split("\n").length > 8)
|
|
548
|
+
text += `\n${theme.fg("dim", "... (ctrl+o to expand)")}`;
|
|
549
|
+
return new Text(text, 0, 0);
|
|
550
|
+
},
|
|
551
|
+
);
|
|
552
|
+
|
|
553
|
+
// --- Command ------------------------------------------------------------
|
|
554
|
+
|
|
555
|
+
pi.registerCommand("ps", {
|
|
556
|
+
description: "List and inspect background terminals",
|
|
557
|
+
handler: async (_args, ctx) => {
|
|
558
|
+
const manager = await getManager();
|
|
559
|
+
if (ctx.mode !== "tui") {
|
|
560
|
+
if (ctx.hasUI) {
|
|
561
|
+
const terminals = manager.view.list();
|
|
562
|
+
ctx.ui.notify(
|
|
563
|
+
terminals.length === 0
|
|
564
|
+
? "No background terminals."
|
|
565
|
+
: terminals.map((snap) => describeTerminal(snap)).join("\n"),
|
|
566
|
+
"info",
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
if (manager.view.size() === 0) {
|
|
572
|
+
ctx.ui.notify(
|
|
573
|
+
"No background terminals yet. Long bash runs appear here.",
|
|
574
|
+
"info",
|
|
575
|
+
);
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
await openTerminalPicker(ctx, manager.view);
|
|
579
|
+
},
|
|
580
|
+
});
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
export default createBackgroundTerminalsExtension();
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-tian-background-terminals",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Replace Pi's built-in Bash with automatic background yielding, exactly-once completion notifications, and a /ps viewer.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi",
|
|
8
|
+
"pi-package",
|
|
9
|
+
"pi-extension",
|
|
10
|
+
"pi-coding-agent",
|
|
11
|
+
"background",
|
|
12
|
+
"terminal",
|
|
13
|
+
"process",
|
|
14
|
+
"shell"
|
|
15
|
+
],
|
|
16
|
+
"author": "Tian Zuo",
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/TianZuo555/pi-tian-extensions.git",
|
|
21
|
+
"directory": "packages/pi-background-terminals"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/TianZuo555/pi-tian-extensions#background-terminals",
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/TianZuo555/pi-tian-extensions/issues"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"index.ts",
|
|
29
|
+
"src",
|
|
30
|
+
"docs"
|
|
31
|
+
],
|
|
32
|
+
"pi": {
|
|
33
|
+
"extensions": [
|
|
34
|
+
"./index.ts"
|
|
35
|
+
]
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"check": "tsc --noEmit -p .",
|
|
39
|
+
"prepare": "effect-tsgo patch",
|
|
40
|
+
"test": "node --test --experimental-strip-types index.test.ts manager.test.ts output.test.ts prompt.test.ts result-delivery.test.ts ps.test.ts"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"effect": "^4.0.0-beta.99"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"@earendil-works/pi-ai": "*",
|
|
47
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
48
|
+
"@earendil-works/pi-tui": "*",
|
|
49
|
+
"typebox": "*"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@effect/language-service": "^0.87.0",
|
|
53
|
+
"@effect/tsgo": "^0.24.2",
|
|
54
|
+
"typescript": "^7.0.2"
|
|
55
|
+
},
|
|
56
|
+
"publishConfig": {
|
|
57
|
+
"access": "public"
|
|
58
|
+
}
|
|
59
|
+
}
|