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/src/output.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OutputBuffer — bounded head+tail in-memory capture of one process stream.
|
|
3
|
+
*
|
|
4
|
+
* A stable prefix and rolling suffix are retained; once output exceeds the cap,
|
|
5
|
+
* bytes from the middle are omitted. A single oversized chunk is split only on
|
|
6
|
+
* UTF-8 code point boundaries. An optional spill callback receives every chunk
|
|
7
|
+
* in order before retention, so the caller can keep a complete on-disk copy.
|
|
8
|
+
*
|
|
9
|
+
* Plain TS by design: this is push-based accumulation driven by node stream
|
|
10
|
+
* 'data' callbacks, not stream transformation.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { OutputView } from "./domain.ts";
|
|
14
|
+
|
|
15
|
+
function utf8Prefix(raw: Buffer, maxBytes: number) {
|
|
16
|
+
if (raw.length <= maxBytes) return raw;
|
|
17
|
+
let end = Math.max(0, maxBytes);
|
|
18
|
+
while (end > 0 && (raw[end] & 0xc0) === 0x80) end--;
|
|
19
|
+
// Copy the bounded slice so retaining it cannot pin one giant source Buffer.
|
|
20
|
+
return Buffer.from(raw.subarray(0, end));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function utf8Tail(raw: Buffer, maxBytes: number) {
|
|
24
|
+
if (raw.length <= maxBytes) return raw;
|
|
25
|
+
let start = raw.length - Math.max(0, maxBytes);
|
|
26
|
+
while (start < raw.length && (raw[start] & 0xc0) === 0x80) start++;
|
|
27
|
+
// Copy the bounded slice so retaining it cannot pin one giant source Buffer.
|
|
28
|
+
return Buffer.from(raw.subarray(start));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class OutputBuffer {
|
|
32
|
+
private headChunks: Buffer[] = [];
|
|
33
|
+
private tailChunks: Buffer[] = [];
|
|
34
|
+
private headBytes = 0;
|
|
35
|
+
private tailBytes = 0;
|
|
36
|
+
/** Once any byte spills past the head budget, later bytes can only be tail. */
|
|
37
|
+
private headSealed = false;
|
|
38
|
+
private cachedView: OutputView | undefined;
|
|
39
|
+
|
|
40
|
+
/** Bumped on every push; lets the UI cache derived line layouts. */
|
|
41
|
+
version = 0;
|
|
42
|
+
totalBytes = 0;
|
|
43
|
+
private _spillPath?: string;
|
|
44
|
+
|
|
45
|
+
get spillPath() {
|
|
46
|
+
return this._spillPath;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
set spillPath(value: string | undefined) {
|
|
50
|
+
if (this._spillPath === value) return;
|
|
51
|
+
this._spillPath = value;
|
|
52
|
+
this.cachedView = undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private readonly maxRetainedBytes: number;
|
|
56
|
+
private readonly headBudget: number;
|
|
57
|
+
private readonly tailBudget: number;
|
|
58
|
+
private readonly spill?: (chunk: string) => unknown;
|
|
59
|
+
|
|
60
|
+
constructor(
|
|
61
|
+
maxRetainedBytes: number,
|
|
62
|
+
spill?: (chunk: string) => unknown,
|
|
63
|
+
headRetainedBytes = Math.floor(maxRetainedBytes / 8),
|
|
64
|
+
) {
|
|
65
|
+
this.maxRetainedBytes = Math.max(0, maxRetainedBytes);
|
|
66
|
+
this.headBudget = Math.min(
|
|
67
|
+
this.maxRetainedBytes,
|
|
68
|
+
Math.max(0, headRetainedBytes),
|
|
69
|
+
);
|
|
70
|
+
this.tailBudget = this.maxRetainedBytes - this.headBudget;
|
|
71
|
+
this.spill = spill;
|
|
72
|
+
this.headSealed = this.headBudget === 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
push(chunk: string) {
|
|
76
|
+
if (chunk.length === 0) return true;
|
|
77
|
+
|
|
78
|
+
const raw = Buffer.from(chunk, "utf8");
|
|
79
|
+
this.totalBytes += raw.length;
|
|
80
|
+
const spillAccepted = this.spill?.(chunk) !== false;
|
|
81
|
+
let remainder = raw;
|
|
82
|
+
|
|
83
|
+
if (!this.headSealed) {
|
|
84
|
+
const available = this.headBudget - this.headBytes;
|
|
85
|
+
const prefix = utf8Prefix(raw, available);
|
|
86
|
+
if (prefix.length > 0) {
|
|
87
|
+
this.headChunks.push(prefix);
|
|
88
|
+
this.headBytes += prefix.length;
|
|
89
|
+
}
|
|
90
|
+
remainder = raw.subarray(prefix.length);
|
|
91
|
+
if (remainder.length > 0) this.headSealed = true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
this.pushTail(remainder);
|
|
95
|
+
this.cachedView = undefined;
|
|
96
|
+
this.version++;
|
|
97
|
+
return spillAccepted;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private pushTail(raw: Buffer) {
|
|
101
|
+
if (raw.length === 0 || this.tailBudget === 0) return;
|
|
102
|
+
|
|
103
|
+
if (raw.length >= this.tailBudget) {
|
|
104
|
+
const kept = utf8Tail(raw, this.tailBudget);
|
|
105
|
+
this.tailChunks = kept.length > 0 ? [kept] : [];
|
|
106
|
+
this.tailBytes = kept.length;
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
this.tailChunks.push(raw);
|
|
111
|
+
this.tailBytes += raw.length;
|
|
112
|
+
while (this.tailBytes > this.tailBudget && this.tailChunks.length > 0) {
|
|
113
|
+
const excess = this.tailBytes - this.tailBudget;
|
|
114
|
+
const first = this.tailChunks[0];
|
|
115
|
+
if (first.length <= excess) {
|
|
116
|
+
this.tailChunks.shift();
|
|
117
|
+
this.tailBytes -= first.length;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let start = excess;
|
|
122
|
+
while (start < first.length && (first[start] & 0xc0) === 0x80) start++;
|
|
123
|
+
if (start >= first.length) {
|
|
124
|
+
this.tailChunks.shift();
|
|
125
|
+
this.tailBytes -= first.length;
|
|
126
|
+
} else {
|
|
127
|
+
this.tailChunks[0] = first.subarray(start);
|
|
128
|
+
this.tailBytes -= start;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
view(): OutputView {
|
|
134
|
+
if (this.cachedView) return this.cachedView;
|
|
135
|
+
|
|
136
|
+
const head = Buffer.concat(this.headChunks, this.headBytes).toString("utf8");
|
|
137
|
+
const tail = Buffer.concat(this.tailChunks, this.tailBytes).toString("utf8");
|
|
138
|
+
const truncatedBytes = Math.max(
|
|
139
|
+
0,
|
|
140
|
+
this.totalBytes - this.headBytes - this.tailBytes,
|
|
141
|
+
);
|
|
142
|
+
const text =
|
|
143
|
+
truncatedBytes === 0
|
|
144
|
+
? `${head}${tail}`
|
|
145
|
+
: [
|
|
146
|
+
head,
|
|
147
|
+
`... ${truncatedBytes} bytes omitted ...`,
|
|
148
|
+
tail,
|
|
149
|
+
]
|
|
150
|
+
.filter((part) => part.length > 0)
|
|
151
|
+
.join("\n");
|
|
152
|
+
|
|
153
|
+
this.cachedView = {
|
|
154
|
+
text,
|
|
155
|
+
head,
|
|
156
|
+
tail,
|
|
157
|
+
totalBytes: this.totalBytes,
|
|
158
|
+
truncatedBytes,
|
|
159
|
+
spillPath: this._spillPath,
|
|
160
|
+
};
|
|
161
|
+
return this.cachedView;
|
|
162
|
+
}
|
|
163
|
+
}
|
package/src/prompt.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/** Model-facing strings and bounded output formatting for the bash override. */
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_MAX_BYTES,
|
|
5
|
+
DEFAULT_MAX_LINES,
|
|
6
|
+
formatSize,
|
|
7
|
+
truncateHead,
|
|
8
|
+
truncateTail,
|
|
9
|
+
} from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { formatElapsed, formatExit, type TerminalSnapshot } from "./domain.ts";
|
|
11
|
+
import {
|
|
12
|
+
DEFAULT_YIELD_TIME_MS,
|
|
13
|
+
MAX_RUNNING,
|
|
14
|
+
MAX_RUNTIME_TIMEOUT_SECONDS,
|
|
15
|
+
MAX_YIELD_TIME_MS,
|
|
16
|
+
MIN_YIELD_TIME_MS,
|
|
17
|
+
} from "./manager.ts";
|
|
18
|
+
|
|
19
|
+
/** Output returned by the initial bash call. */
|
|
20
|
+
export const BASH_STDOUT_MAX = 16 * 1024;
|
|
21
|
+
export const BASH_STDERR_MAX = 8 * 1024;
|
|
22
|
+
/** Partial updates during the initial foreground wait. */
|
|
23
|
+
const PROGRESS_STDOUT_MAX = 8 * 1024;
|
|
24
|
+
const PROGRESS_STDERR_MAX = 4 * 1024;
|
|
25
|
+
/** Completion follow-up output. Keep this concise; /ps has the detailed view. */
|
|
26
|
+
export const RESULT_STDOUT_MAX = 8 * 1024;
|
|
27
|
+
export const RESULT_STDERR_MAX = 4 * 1024;
|
|
28
|
+
const BASH_STDOUT_MAX_LINES = 400;
|
|
29
|
+
const BASH_STDERR_MAX_LINES = 200;
|
|
30
|
+
const PROGRESS_STDOUT_MAX_LINES = 100;
|
|
31
|
+
const PROGRESS_STDERR_MAX_LINES = 50;
|
|
32
|
+
const RESULT_STDOUT_MAX_LINES = 40;
|
|
33
|
+
const RESULT_STDERR_MAX_LINES = 20;
|
|
34
|
+
|
|
35
|
+
export const BASH_TOOL_DESCRIPTION =
|
|
36
|
+
"Execute a Bash command with no interactive stdin through one managed execution path. " +
|
|
37
|
+
`Waits up to ${DEFAULT_YIELD_TIME_MS} ms by default: commands that finish return their final output; commands still running continue as a session-scoped background terminal and return an id. ` +
|
|
38
|
+
"A yielded command notifies you automatically exactly once when it exits; do not poll it. The user can inspect or stop it in /ps. " +
|
|
39
|
+
`Output is bounded head+tail with full private spill logs. Max ${MAX_RUNNING} commands can remain running at once.`;
|
|
40
|
+
|
|
41
|
+
export const BASH_PROMPT_SNIPPET =
|
|
42
|
+
"Execute Bash commands; long-running commands automatically continue in the background and notify on exit";
|
|
43
|
+
|
|
44
|
+
export const BASH_PROMPT_GUIDELINES = [
|
|
45
|
+
"Use bash for all shell commands; it automatically yields long-running commands instead of requiring a separate background tool.",
|
|
46
|
+
"bash processes receive no interactive stdin — never use bash for commands requiring prompts or terminal interaction.",
|
|
47
|
+
"When bash returns a background terminal id, keep working instead of polling; its final result arrives automatically, and the user manages it with /ps.",
|
|
48
|
+
"Inspect PI_* environment variables for current model and session details.",
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
export const BASH_PARAMETER_DESCRIPTIONS = {
|
|
52
|
+
command:
|
|
53
|
+
"Bash command to execute. It receives no interactive stdin; commands that prompt for input will see EOF.",
|
|
54
|
+
title:
|
|
55
|
+
"Optional short name shown in /ps. Defaults to a bounded one-line form of the command.",
|
|
56
|
+
workingDir: "Working directory relative to the current directory, or an absolute path (default: current working directory)",
|
|
57
|
+
yieldTimeMs:
|
|
58
|
+
`How long to wait for completion before returning a background terminal id (default ${DEFAULT_YIELD_TIME_MS} ms, range ${MIN_YIELD_TIME_MS}-${MAX_YIELD_TIME_MS} ms).`,
|
|
59
|
+
timeout:
|
|
60
|
+
`Optional hard total runtime timeout in seconds (no default, maximum ${MAX_RUNTIME_TIMEOUT_SECONDS}). Unlike yield_time_ms, this terminates the process tree.`,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** One metadata line: `bt-1 [running] "dev server" (pid 12345, 3m12s, exit -, /path)`. */
|
|
64
|
+
export function describeTerminal(snap: TerminalSnapshot) {
|
|
65
|
+
const details = [
|
|
66
|
+
`pid ${snap.pid ?? "?"}`,
|
|
67
|
+
formatElapsed(snap),
|
|
68
|
+
snap.status === "running" ? "exit -" : formatExit(snap),
|
|
69
|
+
snap.cwd,
|
|
70
|
+
`stdout ${formatSize(snap.stdout.totalBytes)}, stderr ${formatSize(snap.stderr.totalBytes)}`,
|
|
71
|
+
];
|
|
72
|
+
if (snap.timeoutMs !== undefined) {
|
|
73
|
+
details.push(`timeout ${snap.timeoutMs / 1000}s`);
|
|
74
|
+
}
|
|
75
|
+
return `${snap.id} [${snap.status}] "${snap.title}" (${details.join(", ")})`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Bounded model-facing view that preserves both startup context and recent
|
|
80
|
+
* output. The retained in-memory middle may already be omitted; spillPath is
|
|
81
|
+
* the authoritative complete stream whenever spilling succeeded.
|
|
82
|
+
*/
|
|
83
|
+
function outputSection(
|
|
84
|
+
label: string,
|
|
85
|
+
view: TerminalSnapshot["stdout"],
|
|
86
|
+
maxBytes: number,
|
|
87
|
+
maxLines: number,
|
|
88
|
+
) {
|
|
89
|
+
if (view.totalBytes === 0) return `${label}: (empty)`;
|
|
90
|
+
|
|
91
|
+
const byteLimit = Math.min(maxBytes, DEFAULT_MAX_BYTES);
|
|
92
|
+
const lineLimit = Math.min(maxLines, DEFAULT_MAX_LINES);
|
|
93
|
+
if (view.truncatedBytes === 0) {
|
|
94
|
+
const completeCheck = truncateTail(view.text, {
|
|
95
|
+
maxBytes: byteLimit,
|
|
96
|
+
maxLines: lineLimit,
|
|
97
|
+
});
|
|
98
|
+
if (!completeCheck.truncated) {
|
|
99
|
+
return `${label}:\n${completeCheck.content}`;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const headBytes = Math.max(1, Math.floor(byteLimit / 4));
|
|
104
|
+
const tailBytes = Math.max(1, byteLimit - headBytes);
|
|
105
|
+
const headLines = Math.max(1, Math.floor(lineLimit / 4));
|
|
106
|
+
const tailLines = Math.max(1, lineLimit - headLines);
|
|
107
|
+
const start = truncateHead(view.head, {
|
|
108
|
+
maxBytes: headBytes,
|
|
109
|
+
maxLines: headLines,
|
|
110
|
+
});
|
|
111
|
+
const endSource = view.tail || view.head;
|
|
112
|
+
const end = truncateTail(endSource, {
|
|
113
|
+
maxBytes: tailBytes,
|
|
114
|
+
maxLines: tailLines,
|
|
115
|
+
});
|
|
116
|
+
const shownBytes = start.outputBytes + end.outputBytes;
|
|
117
|
+
const omittedBytes = Math.max(0, view.totalBytes - shownBytes);
|
|
118
|
+
const parts = [start.content];
|
|
119
|
+
if (omittedBytes > 0) {
|
|
120
|
+
parts.push(`... ${formatSize(omittedBytes)} omitted ...`);
|
|
121
|
+
}
|
|
122
|
+
if (end.content && end.content !== start.content) parts.push(end.content);
|
|
123
|
+
|
|
124
|
+
const where = view.spillPath
|
|
125
|
+
? `Full log: ${view.spillPath}`
|
|
126
|
+
: "Retained output is available in the /ps viewer";
|
|
127
|
+
return `${label}:\n${parts.filter(Boolean).join("\n")}\n[${label} bounded head+tail: showing ${formatSize(shownBytes)} of ${formatSize(view.totalBytes)}. ${where}]`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function appendOutput(
|
|
131
|
+
text: string,
|
|
132
|
+
snap: TerminalSnapshot,
|
|
133
|
+
stdoutBytes: number,
|
|
134
|
+
stdoutLines: number,
|
|
135
|
+
stderrBytes: number,
|
|
136
|
+
stderrLines: number,
|
|
137
|
+
) {
|
|
138
|
+
text += `\n\n${outputSection("stdout", snap.stdout, stdoutBytes, stdoutLines)}`;
|
|
139
|
+
if (snap.stderr.totalBytes > 0) {
|
|
140
|
+
text += `\n\n${outputSection("stderr", snap.stderr, stderrBytes, stderrLines)}`;
|
|
141
|
+
}
|
|
142
|
+
return text;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Streaming tool-row update while bash is still in its initial wait. */
|
|
146
|
+
export function buildBashProgress(snap: TerminalSnapshot) {
|
|
147
|
+
return appendOutput(
|
|
148
|
+
`Running as terminal ${snap.id} "${snap.title}" (pid ${snap.pid ?? "?"}, ${formatElapsed(snap)}). It will automatically yield if it outlives the initial wait.`,
|
|
149
|
+
snap,
|
|
150
|
+
PROGRESS_STDOUT_MAX,
|
|
151
|
+
PROGRESS_STDOUT_MAX_LINES,
|
|
152
|
+
PROGRESS_STDERR_MAX,
|
|
153
|
+
PROGRESS_STDERR_MAX_LINES,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Result of the initial bash wait, whether final or yielded. */
|
|
158
|
+
export function buildBashResult(snap: TerminalSnapshot) {
|
|
159
|
+
let text =
|
|
160
|
+
snap.status === "running"
|
|
161
|
+
? `Command is still running as background terminal ${snap.id} "${snap.title}" (pid ${snap.pid ?? "?"}). It has no interactive stdin; do not poll it. The final result will arrive automatically, and the user can inspect or stop it with /ps.`
|
|
162
|
+
: snap.status === "timed_out"
|
|
163
|
+
? `Command timed out after ${formatElapsed(snap)}.`
|
|
164
|
+
: `Command finished in ${formatElapsed(snap)} (${formatExit(snap)}).`;
|
|
165
|
+
text += `\n${describeTerminal(snap)}`;
|
|
166
|
+
if (snap.errorText) text += `\nError: ${snap.errorText}`;
|
|
167
|
+
return appendOutput(
|
|
168
|
+
text,
|
|
169
|
+
snap,
|
|
170
|
+
BASH_STDOUT_MAX,
|
|
171
|
+
BASH_STDOUT_MAX_LINES,
|
|
172
|
+
BASH_STDERR_MAX,
|
|
173
|
+
BASH_STDERR_MAX_LINES,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Async completion follow-up injected only after bash yielded. */
|
|
178
|
+
export function buildTerminalResultMessage(snap: TerminalSnapshot) {
|
|
179
|
+
const how =
|
|
180
|
+
snap.status === "killed"
|
|
181
|
+
? "was killed"
|
|
182
|
+
: snap.status === "timed_out"
|
|
183
|
+
? "timed out"
|
|
184
|
+
: `exited (${formatExit(snap)})`;
|
|
185
|
+
let text = `Background terminal ${snap.id} "${snap.title}" ${how} after ${formatElapsed(snap)}.`;
|
|
186
|
+
if (snap.errorText) text += `\nError: ${snap.errorText}`;
|
|
187
|
+
return appendOutput(
|
|
188
|
+
text,
|
|
189
|
+
snap,
|
|
190
|
+
RESULT_STDOUT_MAX,
|
|
191
|
+
RESULT_STDOUT_MAX_LINES,
|
|
192
|
+
RESULT_STDERR_MAX,
|
|
193
|
+
RESULT_STDERR_MAX_LINES,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deferred one-shot delivery map (same semantics as subagents'): a settled
|
|
3
|
+
* terminal's result is held here until it is either drained into a follow-up
|
|
4
|
+
* message or consumed by bash when its initial wait already
|
|
5
|
+
* returned the settlement. Keyed by id, so double delivery is
|
|
6
|
+
* structurally impossible — whoever drains first wins.
|
|
7
|
+
*/
|
|
8
|
+
export function createDeferredResultDelivery<T extends { id: string }>() {
|
|
9
|
+
const pending = new Map<string, T>();
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
defer(result: T) {
|
|
13
|
+
pending.set(result.id, result);
|
|
14
|
+
},
|
|
15
|
+
consume(ids: Iterable<string>) {
|
|
16
|
+
for (const id of ids) pending.delete(id);
|
|
17
|
+
},
|
|
18
|
+
drain() {
|
|
19
|
+
const results = [...pending.values()];
|
|
20
|
+
pending.clear();
|
|
21
|
+
return results;
|
|
22
|
+
},
|
|
23
|
+
clear() {
|
|
24
|
+
pending.clear();
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The async entry-point boundary: one ManagedRuntime shared by every tool
|
|
3
|
+
* handler, disposed on session_shutdown (which runs the manager finalizer →
|
|
4
|
+
* disposeAll → every process tree is killed).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Cause, Exit, ManagedRuntime, Result, type Effect } from "effect";
|
|
8
|
+
import { TerminalManagerLive } from "./manager.ts";
|
|
9
|
+
|
|
10
|
+
export function createTerminalRuntime() {
|
|
11
|
+
return ManagedRuntime.make(TerminalManagerLive);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type TerminalRuntime = ReturnType<typeof createTerminalRuntime>;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Run an effect from an async tool handler. Typed failures and defects are
|
|
18
|
+
* converted to thrown Errors (what pi's tool contract expects); interruption
|
|
19
|
+
* (tool AbortSignal) throws `interruptMessage`.
|
|
20
|
+
*/
|
|
21
|
+
export async function runTool<A, E>(
|
|
22
|
+
runtime: TerminalRuntime,
|
|
23
|
+
effect: Effect.Effect<A, E>,
|
|
24
|
+
options: { signal?: AbortSignal; interruptMessage?: string } = {},
|
|
25
|
+
) {
|
|
26
|
+
const exit = await runtime.runPromiseExit(
|
|
27
|
+
effect,
|
|
28
|
+
options.signal ? { signal: options.signal } : undefined,
|
|
29
|
+
);
|
|
30
|
+
if (Exit.isSuccess(exit)) return exit.value;
|
|
31
|
+
if (Cause.hasInterruptsOnly(exit.cause)) {
|
|
32
|
+
throw new Error(options.interruptMessage ?? "Operation was aborted.");
|
|
33
|
+
}
|
|
34
|
+
// Preserve typed Effect failures so callers can make narrow, safety-aware
|
|
35
|
+
// decisions (notably: foreground fallback only when SpawnError proves that
|
|
36
|
+
// no child was created). Defects still become ordinary Errors.
|
|
37
|
+
const failure = Cause.findFail(exit.cause);
|
|
38
|
+
if (Result.isSuccess(failure)) throw failure.success.error;
|
|
39
|
+
const [first] = Cause.prettyErrors(exit.cause);
|
|
40
|
+
throw new Error(first?.message ?? Cause.pretty(exit.cause));
|
|
41
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output rendering for the /ps detail view: turns a captured stream's text
|
|
3
|
+
* into sanitized, wrapped display lines. Sanitization happens here — at
|
|
4
|
+
* render time, never at capture time — because raw ANSI/control characters
|
|
5
|
+
* desync the TUI renderer and smear the overlay.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
9
|
+
|
|
10
|
+
// OSC strings (window titles, hyperlinks, etc.) end in BEL or ST. Strip them
|
|
11
|
+
// before the generic escape/control pass so their payload never becomes
|
|
12
|
+
// visible text after only the leading ESC byte is removed.
|
|
13
|
+
// eslint-disable-next-line no-control-regex
|
|
14
|
+
const OSC_PATTERN =
|
|
15
|
+
/(?:\u001b\]|\u009d)(?:[^\u0007\u001b\u009c]|\u001b(?!\\))*(?:\u0007|\u001b\\|\u009c)/g;
|
|
16
|
+
// Standards-shaped CSI matcher: parameters are deliberately unbounded; a
|
|
17
|
+
// five-digit cursor movement is still one control sequence, not visible text.
|
|
18
|
+
// eslint-disable-next-line no-control-regex
|
|
19
|
+
const CSI_PATTERN = /(?:\u001b\[|\u009b)[0-?]*[ -/]*[@-~]/g;
|
|
20
|
+
// Remaining two-byte/charset escape forms (for example ESC ( 0).
|
|
21
|
+
// eslint-disable-next-line no-control-regex
|
|
22
|
+
const ESCAPE_PATTERN = /\u001b(?:[()][0-2A-Z]|[ -/]*[@-~])/g;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Strip raw ANSI codes, expand tabs, and drop control chars. Terminal-expanded
|
|
26
|
+
* tabs (and stray escapes) make lines wider than the width we declare to the
|
|
27
|
+
* TUI, which desyncs the renderer.
|
|
28
|
+
*/
|
|
29
|
+
export function sanitizeText(text: string) {
|
|
30
|
+
return text
|
|
31
|
+
.replace(OSC_PATTERN, "")
|
|
32
|
+
.replace(CSI_PATTERN, "")
|
|
33
|
+
.replace(ESCAPE_PATTERN, "")
|
|
34
|
+
.replaceAll("\t", " ")
|
|
35
|
+
.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, "");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Split, sanitize, and wrap a stream's text into display lines. */
|
|
39
|
+
export function buildOutputLines(text: string, width: number) {
|
|
40
|
+
const safeWidth = Math.max(10, width);
|
|
41
|
+
const out: string[] = [];
|
|
42
|
+
for (const raw of text.split("\n")) {
|
|
43
|
+
// Carriage-return progress lines (npm, cargo): keep only the final state.
|
|
44
|
+
const segments = raw.split("\r");
|
|
45
|
+
const finalSegment = segments.at(-1) ?? "";
|
|
46
|
+
const lastSegment =
|
|
47
|
+
finalSegment || [...segments].reverse().find((segment) => segment) || "";
|
|
48
|
+
const clean = sanitizeText(lastSegment);
|
|
49
|
+
if (clean.length === 0) {
|
|
50
|
+
out.push("");
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
out.push(...wrapTextWithAnsi(clean, safeWidth));
|
|
54
|
+
}
|
|
55
|
+
// Drop one trailing empty line from a trailing "\n" so the tail pin sits
|
|
56
|
+
// on the last real output line.
|
|
57
|
+
if (out.length > 0 && out[out.length - 1] === "") out.pop();
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Cache of wrapped lines keyed by (buffer version, width): a chatty process
|
|
63
|
+
* bumps the version per chunk, but renders between chunks (1Hz elapsed ticks,
|
|
64
|
+
* scrolling) must not re-wrap megabytes.
|
|
65
|
+
*/
|
|
66
|
+
export function createOutputLineCache() {
|
|
67
|
+
let key: string | undefined;
|
|
68
|
+
let lines: string[] = [];
|
|
69
|
+
return {
|
|
70
|
+
get(text: string, version: number, width: number) {
|
|
71
|
+
const nextKey = `${version}:${width}`;
|
|
72
|
+
if (key !== nextKey) {
|
|
73
|
+
key = nextKey;
|
|
74
|
+
lines = buildOutputLines(text, width);
|
|
75
|
+
}
|
|
76
|
+
return lines;
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|