@howaboua/pi-codex-conversion 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.
- package/LICENSE +21 -0
- package/README.md +99 -0
- package/available-tools.png +0 -0
- package/package.json +62 -0
- package/src/adapter/codex-model.ts +22 -0
- package/src/adapter/tool-set.ts +7 -0
- package/src/index.ts +112 -0
- package/src/patch/core.ts +220 -0
- package/src/patch/parser.ts +422 -0
- package/src/patch/paths.ts +56 -0
- package/src/patch/types.ts +44 -0
- package/src/prompt/build-system-prompt.ts +111 -0
- package/src/shell/parse.ts +297 -0
- package/src/shell/summary.ts +62 -0
- package/src/shell/tokenize.ts +125 -0
- package/src/shell/types.ts +10 -0
- package/src/tools/apply-patch-tool.ts +84 -0
- package/src/tools/codex-rendering.ts +95 -0
- package/src/tools/exec-command-state.ts +43 -0
- package/src/tools/exec-command-tool.ts +107 -0
- package/src/tools/exec-session-manager.ts +478 -0
- package/src/tools/unified-exec-format.ts +28 -0
- package/src/tools/view-image-tool.ts +171 -0
- package/src/tools/write-stdin-tool.ts +145 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export type ExecCommandStatus = "running" | "done";
|
|
2
|
+
|
|
3
|
+
export interface ExecCommandTracker {
|
|
4
|
+
getState(command: string): ExecCommandStatus;
|
|
5
|
+
recordStart(toolCallId: string, command: string): void;
|
|
6
|
+
recordPersistentSession(command: string): void;
|
|
7
|
+
recordEnd(toolCallId: string): void;
|
|
8
|
+
recordCommandFinished(command: string): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createExecCommandTracker(): ExecCommandTracker {
|
|
12
|
+
const commandByToolCallId = new Map<string, string>();
|
|
13
|
+
const executionStateByCommand = new Map<string, ExecCommandStatus>();
|
|
14
|
+
const persistentCommands = new Set<string>();
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
getState(command) {
|
|
18
|
+
return executionStateByCommand.get(command) ?? "running";
|
|
19
|
+
},
|
|
20
|
+
recordStart(toolCallId, command) {
|
|
21
|
+
commandByToolCallId.set(toolCallId, command);
|
|
22
|
+
executionStateByCommand.set(command, "running");
|
|
23
|
+
},
|
|
24
|
+
recordPersistentSession(command) {
|
|
25
|
+
persistentCommands.add(command);
|
|
26
|
+
executionStateByCommand.set(command, "running");
|
|
27
|
+
},
|
|
28
|
+
recordEnd(toolCallId) {
|
|
29
|
+
const command = commandByToolCallId.get(toolCallId);
|
|
30
|
+
if (!command) return;
|
|
31
|
+
// Pi renderers do not currently receive toolCallId, so we track the
|
|
32
|
+
// last-known state per command string for compact Exploring/Explored UI.
|
|
33
|
+
if (!persistentCommands.has(command)) {
|
|
34
|
+
executionStateByCommand.set(command, "done");
|
|
35
|
+
}
|
|
36
|
+
commandByToolCallId.delete(toolCallId);
|
|
37
|
+
},
|
|
38
|
+
recordCommandFinished(command) {
|
|
39
|
+
persistentCommands.delete(command);
|
|
40
|
+
executionStateByCommand.set(command, "done");
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { Type } from "@sinclair/typebox";
|
|
3
|
+
import { Text } from "@mariozechner/pi-tui";
|
|
4
|
+
import { renderExecCommandCall } from "./codex-rendering.ts";
|
|
5
|
+
import type { ExecCommandTracker } from "./exec-command-state.ts";
|
|
6
|
+
import type { ExecSessionManager, UnifiedExecResult } from "./exec-session-manager.ts";
|
|
7
|
+
import { formatUnifiedExecResult } from "./unified-exec-format.ts";
|
|
8
|
+
|
|
9
|
+
const EXEC_COMMAND_PARAMETERS = Type.Object({
|
|
10
|
+
cmd: Type.String({ description: "Shell command to execute." }),
|
|
11
|
+
workdir: Type.Optional(Type.String({ description: "Optional working directory; defaults to the current turn cwd." })),
|
|
12
|
+
shell: Type.Optional(Type.String({ description: "Optional shell binary; defaults to the user's shell." })),
|
|
13
|
+
tty: Type.Optional(
|
|
14
|
+
Type.Boolean({
|
|
15
|
+
description: "Whether to allocate a TTY for the command. Defaults to false (plain pipes); set to true to open a PTY and access TTY process.",
|
|
16
|
+
}),
|
|
17
|
+
),
|
|
18
|
+
yield_time_ms: Type.Optional(Type.Number({ description: "How long to wait in milliseconds for output before yielding." })),
|
|
19
|
+
max_output_tokens: Type.Optional(Type.Number({ description: "Maximum number of tokens to return. Excess output will be truncated." })),
|
|
20
|
+
login: Type.Optional(Type.Boolean({ description: "Whether to run the shell with -l/-i semantics. Defaults to true." })),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
interface ExecCommandParams {
|
|
24
|
+
cmd: string;
|
|
25
|
+
workdir?: string;
|
|
26
|
+
shell?: string;
|
|
27
|
+
tty?: boolean;
|
|
28
|
+
yield_time_ms?: number;
|
|
29
|
+
max_output_tokens?: number;
|
|
30
|
+
login?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseExecCommandParams(params: unknown): ExecCommandParams {
|
|
34
|
+
if (!params || typeof params !== "object") {
|
|
35
|
+
throw new Error("exec_command requires an object parameter");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const cmd = "cmd" in params ? params.cmd : undefined;
|
|
39
|
+
if (typeof cmd !== "string") {
|
|
40
|
+
throw new Error("exec_command requires a string 'cmd' parameter");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
cmd,
|
|
45
|
+
workdir: "workdir" in params && typeof params.workdir === "string" ? params.workdir : undefined,
|
|
46
|
+
shell: "shell" in params && typeof params.shell === "string" ? params.shell : undefined,
|
|
47
|
+
tty: "tty" in params && typeof params.tty === "boolean" ? params.tty : undefined,
|
|
48
|
+
yield_time_ms: "yield_time_ms" in params && typeof params.yield_time_ms === "number" ? params.yield_time_ms : undefined,
|
|
49
|
+
max_output_tokens:
|
|
50
|
+
"max_output_tokens" in params && typeof params.max_output_tokens === "number" ? params.max_output_tokens : undefined,
|
|
51
|
+
login: "login" in params && typeof params.login === "boolean" ? params.login : undefined,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isUnifiedExecResult(details: unknown): details is UnifiedExecResult {
|
|
56
|
+
return typeof details === "object" && details !== null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function registerExecCommandTool(pi: ExtensionAPI, tracker: ExecCommandTracker, sessions: ExecSessionManager): void {
|
|
60
|
+
pi.registerTool({
|
|
61
|
+
name: "exec_command",
|
|
62
|
+
label: "exec_command",
|
|
63
|
+
description: "Runs a command in a PTY, returning output or a session ID for ongoing interaction.",
|
|
64
|
+
promptSnippet: "Run a command.",
|
|
65
|
+
promptGuidelines: [
|
|
66
|
+
"Use exec_command for search, listing files, and local text-file reads.",
|
|
67
|
+
"Prefer rg or rg --files when possible.",
|
|
68
|
+
"Keep tty disabled unless the command truly needs interactive terminal behavior.",
|
|
69
|
+
],
|
|
70
|
+
parameters: EXEC_COMMAND_PARAMETERS,
|
|
71
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
72
|
+
if (signal?.aborted) {
|
|
73
|
+
throw new Error("exec_command aborted");
|
|
74
|
+
}
|
|
75
|
+
const typedParams = parseExecCommandParams(params);
|
|
76
|
+
const result = await sessions.exec(typedParams, ctx.cwd, signal);
|
|
77
|
+
if (result.session_id !== undefined) {
|
|
78
|
+
tracker.recordPersistentSession(typedParams.cmd);
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
content: [{ type: "text", text: formatUnifiedExecResult(result, typedParams.cmd) }],
|
|
82
|
+
details: result,
|
|
83
|
+
};
|
|
84
|
+
},
|
|
85
|
+
renderCall(args, theme) {
|
|
86
|
+
const command = typeof args.cmd === "string" ? args.cmd : "";
|
|
87
|
+
return new Text(renderExecCommandCall(command, tracker.getState(command), theme), 0, 0);
|
|
88
|
+
},
|
|
89
|
+
renderResult(result, { expanded, isPartial }, theme) {
|
|
90
|
+
if (isPartial || !expanded) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const details = isUnifiedExecResult(result.details) ? result.details : undefined;
|
|
95
|
+
const content = result.content.find((item) => item.type === "text");
|
|
96
|
+
const output = details?.output ?? (content?.type === "text" ? content.text : "");
|
|
97
|
+
let text = theme.fg("dim", output || "(no output)");
|
|
98
|
+
if (details?.session_id !== undefined) {
|
|
99
|
+
text += `\n${theme.fg("accent", `Session ${details.session_id} still running`)}`;
|
|
100
|
+
}
|
|
101
|
+
if (details?.exit_code !== undefined) {
|
|
102
|
+
text += `\n${theme.fg("muted", `Exit code: ${details.exit_code}`)}`;
|
|
103
|
+
}
|
|
104
|
+
return new Text(text, 0, 0);
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
}
|
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { spawn, type ChildProcessByStdio } from "node:child_process";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import type { Readable } from "node:stream";
|
|
5
|
+
import * as pty from "node-pty";
|
|
6
|
+
|
|
7
|
+
export interface UnifiedExecResult {
|
|
8
|
+
chunk_id: string;
|
|
9
|
+
wall_time_seconds: number;
|
|
10
|
+
output: string;
|
|
11
|
+
exit_code?: number;
|
|
12
|
+
session_id?: number;
|
|
13
|
+
original_token_count?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ExecCommandInput {
|
|
17
|
+
cmd: string;
|
|
18
|
+
workdir?: string;
|
|
19
|
+
shell?: string;
|
|
20
|
+
tty?: boolean;
|
|
21
|
+
yield_time_ms?: number;
|
|
22
|
+
max_output_tokens?: number;
|
|
23
|
+
login?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface WriteStdinInput {
|
|
27
|
+
session_id: number;
|
|
28
|
+
chars?: string;
|
|
29
|
+
yield_time_ms?: number;
|
|
30
|
+
max_output_tokens?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface BaseExecSession {
|
|
34
|
+
id: number;
|
|
35
|
+
command: string;
|
|
36
|
+
buffer: string;
|
|
37
|
+
emittedBuffer: string;
|
|
38
|
+
exitCode: number | null | undefined;
|
|
39
|
+
listeners: Set<() => void>;
|
|
40
|
+
interactive: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface PipeExecSession extends BaseExecSession {
|
|
44
|
+
kind: "pipe";
|
|
45
|
+
child: ChildProcessByStdio<null, Readable, Readable>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface PtyExecSession extends BaseExecSession {
|
|
49
|
+
kind: "pty";
|
|
50
|
+
child: pty.IPty;
|
|
51
|
+
terminalCommitted: string;
|
|
52
|
+
terminalLine: string[];
|
|
53
|
+
terminalCursor: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type ExecSession = PipeExecSession | PtyExecSession;
|
|
57
|
+
|
|
58
|
+
export interface ExecSessionManager {
|
|
59
|
+
exec(input: ExecCommandInput, cwd: string, signal?: AbortSignal): Promise<UnifiedExecResult>;
|
|
60
|
+
write(input: WriteStdinInput): Promise<UnifiedExecResult>;
|
|
61
|
+
hasSession(sessionId: number): boolean;
|
|
62
|
+
getSessionCommand(sessionId: number): string | undefined;
|
|
63
|
+
onSessionExit(listener: (sessionId: number, command: string) => void): () => void;
|
|
64
|
+
shutdown(): void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const DEFAULT_EXEC_YIELD_TIME_MS = 10_000;
|
|
68
|
+
const DEFAULT_WRITE_YIELD_TIME_MS = 250;
|
|
69
|
+
const DEFAULT_MAX_OUTPUT_TOKENS = 10_000;
|
|
70
|
+
const MIN_YIELD_TIME_MS = 250;
|
|
71
|
+
const MAX_YIELD_TIME_MS = 30_000;
|
|
72
|
+
const MAX_COMMAND_HISTORY = 256;
|
|
73
|
+
|
|
74
|
+
function resolveWorkdir(baseCwd: string, workdir?: string): string {
|
|
75
|
+
if (!workdir) return baseCwd;
|
|
76
|
+
return resolve(baseCwd, workdir);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function resolveShell(shell?: string): string {
|
|
80
|
+
return shell || process.env.SHELL || "/bin/bash";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function clampYieldTime(yieldTimeMs: number | undefined, fallback: number): number {
|
|
84
|
+
const value = yieldTimeMs ?? fallback;
|
|
85
|
+
return Math.min(MAX_YIELD_TIME_MS, Math.max(MIN_YIELD_TIME_MS, value));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function maxCharsForTokens(maxOutputTokens = DEFAULT_MAX_OUTPUT_TOKENS): number {
|
|
89
|
+
return Math.max(256, maxOutputTokens * 4);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function stripTerminalControlSequences(text: string, preserveCsi = false): string {
|
|
93
|
+
const withoutOscAndDcs = text
|
|
94
|
+
.replace(/\u001B\][^\u0007\u001B]*(?:\u0007|\u001B\\)/g, "")
|
|
95
|
+
.replace(/\u001B[P_X^][\s\S]*?\u001B\\/g, "");
|
|
96
|
+
if (preserveCsi) {
|
|
97
|
+
return withoutOscAndDcs;
|
|
98
|
+
}
|
|
99
|
+
return withoutOscAndDcs.replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, "").replace(/\u001B[@-_]/g, "");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function sanitizeBinaryOutput(text: string, preserveBackspace = false): string {
|
|
103
|
+
return Array.from(text)
|
|
104
|
+
.filter((char) => {
|
|
105
|
+
const code = char.codePointAt(0);
|
|
106
|
+
if (code === undefined) return false;
|
|
107
|
+
if (code === 0x09 || code === 0x0a || code === 0x0d) return true;
|
|
108
|
+
if (preserveBackspace && code === 0x08) return true;
|
|
109
|
+
if (code <= 0x1f) return false;
|
|
110
|
+
if (code >= 0xfff9 && code <= 0xfffb) return false;
|
|
111
|
+
return true;
|
|
112
|
+
})
|
|
113
|
+
.join("");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function normalizePipeOutput(text: string): string {
|
|
117
|
+
return sanitizeBinaryOutput(stripTerminalControlSequences(text)).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function writeTerminalChar(session: PtyExecSession, char: string): void {
|
|
121
|
+
if (session.terminalCursor > session.terminalLine.length) {
|
|
122
|
+
session.terminalLine.push(...Array.from({ length: session.terminalCursor - session.terminalLine.length }, () => " "));
|
|
123
|
+
}
|
|
124
|
+
session.terminalLine[session.terminalCursor] = char;
|
|
125
|
+
session.terminalCursor += 1;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function applyTerminalOutput(session: PtyExecSession, text: string): string {
|
|
129
|
+
const sanitized = stripTerminalControlSequences(text, true);
|
|
130
|
+
if (sanitized.length === 0) {
|
|
131
|
+
return session.terminalCommitted + session.terminalLine.join("");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
for (let index = 0; index < sanitized.length; index += 1) {
|
|
135
|
+
const char = sanitized[index]!;
|
|
136
|
+
if (char === "\u001b") {
|
|
137
|
+
if (sanitized[index + 1] === "[") {
|
|
138
|
+
let sequenceEnd = index + 2;
|
|
139
|
+
while (sequenceEnd < sanitized.length) {
|
|
140
|
+
const code = sanitized.charCodeAt(sequenceEnd);
|
|
141
|
+
if (code >= 0x40 && code <= 0x7e) {
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
sequenceEnd += 1;
|
|
145
|
+
}
|
|
146
|
+
if (sequenceEnd >= sanitized.length) {
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
const params = sanitized.slice(index + 2, sequenceEnd);
|
|
150
|
+
const finalByte = sanitized[sequenceEnd];
|
|
151
|
+
if (finalByte === "K") {
|
|
152
|
+
const mode = Number(params || "0");
|
|
153
|
+
if (mode === 0) {
|
|
154
|
+
session.terminalLine = session.terminalLine.slice(0, session.terminalCursor);
|
|
155
|
+
} else if (mode === 1) {
|
|
156
|
+
session.terminalLine = [
|
|
157
|
+
...Array.from({ length: Math.min(session.terminalCursor, session.terminalLine.length) }, () => " "),
|
|
158
|
+
...session.terminalLine.slice(session.terminalCursor),
|
|
159
|
+
];
|
|
160
|
+
} else if (mode === 2) {
|
|
161
|
+
session.terminalLine = [];
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
index = sequenceEnd;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const next = sanitized[index + 1];
|
|
169
|
+
if (next && /[()*+,\-./]/.test(next) && index + 2 < sanitized.length) {
|
|
170
|
+
index += 2;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (next) {
|
|
174
|
+
index += 1;
|
|
175
|
+
}
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const code = char.codePointAt(0);
|
|
180
|
+
if (code !== undefined && code <= 0x1f && char !== "\t" && char !== "\n" && char !== "\r" && char !== "\b") {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
switch (char) {
|
|
185
|
+
case "\r":
|
|
186
|
+
session.terminalCursor = 0;
|
|
187
|
+
break;
|
|
188
|
+
case "\n":
|
|
189
|
+
session.terminalCommitted += `${session.terminalLine.join("")}\n`;
|
|
190
|
+
session.terminalLine = [];
|
|
191
|
+
session.terminalCursor = 0;
|
|
192
|
+
break;
|
|
193
|
+
case "\b":
|
|
194
|
+
session.terminalCursor = Math.max(0, session.terminalCursor - 1);
|
|
195
|
+
break;
|
|
196
|
+
default:
|
|
197
|
+
writeTerminalChar(session, char);
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return session.terminalCommitted + session.terminalLine.join("");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function computePtyDelta(previous: string, current: string): string {
|
|
206
|
+
if (current.startsWith(previous)) {
|
|
207
|
+
return current.slice(previous.length);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const lineStart = previous.lastIndexOf("\n") + 1;
|
|
211
|
+
const stablePrefix = previous.slice(0, lineStart);
|
|
212
|
+
if (current.startsWith(stablePrefix)) {
|
|
213
|
+
return `\r${current.slice(lineStart)}`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return current;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function generateChunkId(): string {
|
|
220
|
+
return randomBytes(3).toString("hex");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function consumeOutput(session: ExecSession, maxOutputTokens?: number): { output: string; original_token_count?: number } {
|
|
224
|
+
const text =
|
|
225
|
+
session.kind === "pty" ? computePtyDelta(session.emittedBuffer, session.buffer) : session.buffer.slice(session.emittedBuffer.length);
|
|
226
|
+
session.emittedBuffer = session.buffer;
|
|
227
|
+
if (text.length === 0) {
|
|
228
|
+
return { output: "" };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const maxChars = maxCharsForTokens(maxOutputTokens);
|
|
232
|
+
const originalTokenCount = Math.ceil(text.length / 4);
|
|
233
|
+
if (text.length <= maxChars) {
|
|
234
|
+
return { output: text, original_token_count: originalTokenCount };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
output: text.slice(-maxChars),
|
|
239
|
+
original_token_count: originalTokenCount,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function registerAbortHandler(signal: AbortSignal | undefined, onAbort: () => void): () => void {
|
|
244
|
+
if (!signal) {
|
|
245
|
+
return () => {};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (signal.aborted) {
|
|
249
|
+
onAbort();
|
|
250
|
+
return () => {};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const abortListener = () => onAbort();
|
|
254
|
+
signal.addEventListener("abort", abortListener, { once: true });
|
|
255
|
+
return () => signal.removeEventListener("abort", abortListener);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function createExecSessionManager(): ExecSessionManager {
|
|
259
|
+
let nextSessionId = 1;
|
|
260
|
+
const sessions = new Map<number, ExecSession>();
|
|
261
|
+
const commandHistory = new Map<number, string>();
|
|
262
|
+
const exitListeners = new Set<(sessionId: number, command: string) => void>();
|
|
263
|
+
|
|
264
|
+
function rememberCommand(sessionId: number, command: string): void {
|
|
265
|
+
commandHistory.set(sessionId, command);
|
|
266
|
+
if (commandHistory.size <= MAX_COMMAND_HISTORY) {
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const oldest = commandHistory.keys().next().value;
|
|
270
|
+
if (oldest !== undefined) {
|
|
271
|
+
commandHistory.delete(oldest);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function notify(session: ExecSession): void {
|
|
276
|
+
for (const listener of session.listeners) {
|
|
277
|
+
listener();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function finalizeSession(session: ExecSession): void {
|
|
282
|
+
for (const listener of exitListeners) {
|
|
283
|
+
listener(session.id, session.command);
|
|
284
|
+
}
|
|
285
|
+
notify(session);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function appendOutput(session: ExecSession, text: string): void {
|
|
289
|
+
if (text.length === 0) return;
|
|
290
|
+
session.buffer =
|
|
291
|
+
session.kind === "pty" ? applyTerminalOutput(session, text) : `${session.buffer}${normalizePipeOutput(text)}`;
|
|
292
|
+
notify(session);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function waitForActivity(session: ExecSession, yieldTimeMs: number): Promise<number> {
|
|
296
|
+
if (session.buffer !== session.emittedBuffer || session.exitCode !== undefined && session.exitCode !== null) {
|
|
297
|
+
return Promise.resolve(0);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const startedAt = Date.now();
|
|
301
|
+
return new Promise((resolvePromise) => {
|
|
302
|
+
const onWake = () => {
|
|
303
|
+
cleanup();
|
|
304
|
+
resolvePromise(Date.now() - startedAt);
|
|
305
|
+
};
|
|
306
|
+
const timeout = setTimeout(onWake, yieldTimeMs);
|
|
307
|
+
const cleanup = () => {
|
|
308
|
+
clearTimeout(timeout);
|
|
309
|
+
session.listeners.delete(onWake);
|
|
310
|
+
};
|
|
311
|
+
session.listeners.add(onWake);
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function makeResult(session: ExecSession, waitMs: number, maxOutputTokens?: number): UnifiedExecResult {
|
|
316
|
+
const consumed = consumeOutput(session, maxOutputTokens);
|
|
317
|
+
const result: UnifiedExecResult = {
|
|
318
|
+
chunk_id: generateChunkId(),
|
|
319
|
+
wall_time_seconds: waitMs / 1000,
|
|
320
|
+
output: consumed.output,
|
|
321
|
+
};
|
|
322
|
+
if (consumed.original_token_count !== undefined) {
|
|
323
|
+
result.original_token_count = consumed.original_token_count;
|
|
324
|
+
}
|
|
325
|
+
if (session.exitCode === undefined || session.exitCode === null) {
|
|
326
|
+
result.session_id = session.id;
|
|
327
|
+
} else {
|
|
328
|
+
result.exit_code = session.exitCode;
|
|
329
|
+
if (session.emittedBuffer === session.buffer) {
|
|
330
|
+
sessions.delete(session.id);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return result;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function createPipeSession(input: ExecCommandInput, workdir: string, shell: string, signal?: AbortSignal): PipeExecSession {
|
|
337
|
+
const login = input.login ?? true;
|
|
338
|
+
const shellArgs = login ? ["-lc", input.cmd] : ["-c", input.cmd];
|
|
339
|
+
const child = spawn(shell, shellArgs, {
|
|
340
|
+
cwd: workdir,
|
|
341
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
342
|
+
env: process.env,
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
const session: PipeExecSession = {
|
|
346
|
+
kind: "pipe",
|
|
347
|
+
id: nextSessionId++,
|
|
348
|
+
command: input.cmd,
|
|
349
|
+
child,
|
|
350
|
+
buffer: "",
|
|
351
|
+
emittedBuffer: "",
|
|
352
|
+
exitCode: undefined,
|
|
353
|
+
listeners: new Set(),
|
|
354
|
+
interactive: false,
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
child.stdout.on("data", (data: Buffer) => {
|
|
358
|
+
appendOutput(session, data.toString("utf8"));
|
|
359
|
+
});
|
|
360
|
+
child.stderr.on("data", (data: Buffer) => {
|
|
361
|
+
appendOutput(session, data.toString("utf8"));
|
|
362
|
+
});
|
|
363
|
+
child.on("close", (code) => {
|
|
364
|
+
session.exitCode = code ?? 0;
|
|
365
|
+
finalizeSession(session);
|
|
366
|
+
});
|
|
367
|
+
child.on("error", (error) => {
|
|
368
|
+
appendOutput(session, `${error.message}\n`);
|
|
369
|
+
session.exitCode = 1;
|
|
370
|
+
finalizeSession(session);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
registerAbortHandler(signal, () => {
|
|
374
|
+
if (session.exitCode === undefined) {
|
|
375
|
+
child.kill("SIGTERM");
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
return session;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function createPtySession(input: ExecCommandInput, workdir: string, shell: string, signal?: AbortSignal): PtyExecSession {
|
|
383
|
+
const login = input.login ?? true;
|
|
384
|
+
const shellArgs = login ? ["-lc", input.cmd] : ["-c", input.cmd];
|
|
385
|
+
const child = pty.spawn(shell, shellArgs, {
|
|
386
|
+
cwd: workdir,
|
|
387
|
+
env: process.env,
|
|
388
|
+
name: process.env.TERM || "xterm-256color",
|
|
389
|
+
cols: 80,
|
|
390
|
+
rows: 24,
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
const session: PtyExecSession = {
|
|
394
|
+
kind: "pty",
|
|
395
|
+
id: nextSessionId++,
|
|
396
|
+
command: input.cmd,
|
|
397
|
+
child,
|
|
398
|
+
buffer: "",
|
|
399
|
+
emittedBuffer: "",
|
|
400
|
+
exitCode: undefined,
|
|
401
|
+
listeners: new Set(),
|
|
402
|
+
interactive: true,
|
|
403
|
+
terminalCommitted: "",
|
|
404
|
+
terminalLine: [],
|
|
405
|
+
terminalCursor: 0,
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
child.onData((data) => {
|
|
409
|
+
appendOutput(session, data);
|
|
410
|
+
});
|
|
411
|
+
child.onExit(({ exitCode }) => {
|
|
412
|
+
session.exitCode = exitCode ?? 0;
|
|
413
|
+
finalizeSession(session);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
registerAbortHandler(signal, () => {
|
|
417
|
+
if (session.exitCode === undefined) {
|
|
418
|
+
child.kill();
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
return session;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return {
|
|
426
|
+
exec: async (input, cwd, signal) => {
|
|
427
|
+
const shell = resolveShell(input.shell);
|
|
428
|
+
const workdir = resolveWorkdir(cwd, input.workdir);
|
|
429
|
+
const session = input.tty
|
|
430
|
+
? createPtySession(input, workdir, shell, signal)
|
|
431
|
+
: createPipeSession(input, workdir, shell, signal);
|
|
432
|
+
sessions.set(session.id, session);
|
|
433
|
+
rememberCommand(session.id, session.command);
|
|
434
|
+
|
|
435
|
+
const waitedMs = await waitForActivity(session, clampYieldTime(input.yield_time_ms, DEFAULT_EXEC_YIELD_TIME_MS));
|
|
436
|
+
return makeResult(session, waitedMs, input.max_output_tokens);
|
|
437
|
+
},
|
|
438
|
+
write: async (input) => {
|
|
439
|
+
const session = sessions.get(input.session_id);
|
|
440
|
+
if (!session) {
|
|
441
|
+
throw new Error(`Unknown process id ${input.session_id}`);
|
|
442
|
+
}
|
|
443
|
+
if (input.chars && input.chars.length > 0) {
|
|
444
|
+
if (!session.interactive) {
|
|
445
|
+
throw new Error("stdin is closed for this session; rerun exec_command with tty=true to keep stdin open");
|
|
446
|
+
}
|
|
447
|
+
if (session.kind === "pty") {
|
|
448
|
+
session.child.write(input.chars);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
const waitedMs =
|
|
452
|
+
session.exitCode === undefined
|
|
453
|
+
? await waitForActivity(session, clampYieldTime(input.yield_time_ms, DEFAULT_WRITE_YIELD_TIME_MS))
|
|
454
|
+
: 0;
|
|
455
|
+
return makeResult(session, waitedMs, input.max_output_tokens);
|
|
456
|
+
},
|
|
457
|
+
hasSession: (sessionId) => sessions.has(sessionId),
|
|
458
|
+
getSessionCommand: (sessionId) => sessions.get(sessionId)?.command ?? commandHistory.get(sessionId),
|
|
459
|
+
onSessionExit: (listener) => {
|
|
460
|
+
exitListeners.add(listener);
|
|
461
|
+
return () => exitListeners.delete(listener);
|
|
462
|
+
},
|
|
463
|
+
shutdown: () => {
|
|
464
|
+
for (const session of sessions.values()) {
|
|
465
|
+
if (session.exitCode !== undefined) {
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
if (session.kind === "pty") {
|
|
469
|
+
session.child.kill();
|
|
470
|
+
} else {
|
|
471
|
+
session.child.kill("SIGTERM");
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
sessions.clear();
|
|
475
|
+
commandHistory.clear();
|
|
476
|
+
},
|
|
477
|
+
};
|
|
478
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { UnifiedExecResult } from "./exec-session-manager.ts";
|
|
2
|
+
|
|
3
|
+
export function formatUnifiedExecResult(result: UnifiedExecResult, command?: string): string {
|
|
4
|
+
const sections: string[] = [];
|
|
5
|
+
|
|
6
|
+
if (command) {
|
|
7
|
+
sections.push(`Command: ${command}`);
|
|
8
|
+
}
|
|
9
|
+
if (result.chunk_id) {
|
|
10
|
+
sections.push(`Chunk ID: ${result.chunk_id}`);
|
|
11
|
+
}
|
|
12
|
+
sections.push(`Wall time: ${result.wall_time_seconds.toFixed(4)} seconds`);
|
|
13
|
+
|
|
14
|
+
if (result.exit_code !== undefined) {
|
|
15
|
+
sections.push(`Process exited with code ${result.exit_code}`);
|
|
16
|
+
}
|
|
17
|
+
if (result.session_id !== undefined) {
|
|
18
|
+
sections.push(`Process running with session ID ${result.session_id}`);
|
|
19
|
+
}
|
|
20
|
+
if (result.original_token_count !== undefined) {
|
|
21
|
+
sections.push(`Original token count: ${result.original_token_count}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
sections.push("Output:");
|
|
25
|
+
sections.push(result.output);
|
|
26
|
+
|
|
27
|
+
return sections.join("\n");
|
|
28
|
+
}
|