@haemmid/pi-processes 0.9.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/CHANGELOG.md +63 -0
- package/LICENSE +21 -0
- package/README.md +233 -0
- package/assets/demo-pi-web.gif +0 -0
- package/assets/hero.svg +141 -0
- package/package.json +73 -0
- package/src/config.ts +125 -0
- package/src/constants/index.ts +11 -0
- package/src/constants/types.ts +56 -0
- package/src/hooks/background-blocker.ts +309 -0
- package/src/hooks/cleanup.ts +8 -0
- package/src/hooks/index.ts +17 -0
- package/src/index.ts +23 -0
- package/src/manager.ts +504 -0
- package/src/tools/actions/clear.ts +20 -0
- package/src/tools/actions/index.ts +51 -0
- package/src/tools/actions/kill.ts +108 -0
- package/src/tools/actions/list.ts +48 -0
- package/src/tools/actions/logs.ts +86 -0
- package/src/tools/actions/output.ts +186 -0
- package/src/tools/actions/restart.ts +113 -0
- package/src/tools/actions/start.ts +87 -0
- package/src/tools/index.ts +82 -0
- package/src/utils/ansi.ts +67 -0
- package/src/utils/command-executor.ts +56 -0
- package/src/utils/format.ts +53 -0
- package/src/utils/index.ts +8 -0
- package/src/utils/process-group.ts +22 -0
- package/src/utils/shell-utils.ts +133 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type ExecuteResult, LIVE_STATUSES } from "../../constants";
|
|
2
|
+
import type { ProcessManager } from "../../manager";
|
|
3
|
+
import {
|
|
4
|
+
formatRuntime,
|
|
5
|
+
formatStatus,
|
|
6
|
+
sanitizeLine,
|
|
7
|
+
truncateCmd,
|
|
8
|
+
} from "../../utils";
|
|
9
|
+
|
|
10
|
+
export function executeList(manager: ProcessManager): ExecuteResult {
|
|
11
|
+
const processes = manager.list();
|
|
12
|
+
|
|
13
|
+
if (processes.length === 0) {
|
|
14
|
+
return {
|
|
15
|
+
content: [{ type: "text", text: "No background processes running" }],
|
|
16
|
+
details: {
|
|
17
|
+
action: "list",
|
|
18
|
+
success: true,
|
|
19
|
+
message: "No background processes running",
|
|
20
|
+
processes: [],
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const summary = processes
|
|
26
|
+
.map(
|
|
27
|
+
(p) =>
|
|
28
|
+
`${p.id} "${sanitizeLine(p.name)}": ${truncateCmd(sanitizeLine(p.command))} [${formatStatus(p)}] ${formatRuntime(p.startTime, p.endTime)}`,
|
|
29
|
+
)
|
|
30
|
+
.join("\n");
|
|
31
|
+
|
|
32
|
+
const hasLiveProcess = processes.some((process) =>
|
|
33
|
+
LIVE_STATUSES.has(process.status),
|
|
34
|
+
);
|
|
35
|
+
const waitNotice = hasLiveProcess
|
|
36
|
+
? "\n\nUse process output/logs only when you need a one-off status snapshot. Do not poll repeatedly just to wait."
|
|
37
|
+
: "";
|
|
38
|
+
const message = `${processes.length} process(es):\n${summary}${waitNotice}`;
|
|
39
|
+
return {
|
|
40
|
+
content: [{ type: "text", text: message }],
|
|
41
|
+
details: {
|
|
42
|
+
action: "list",
|
|
43
|
+
success: true,
|
|
44
|
+
message,
|
|
45
|
+
processes,
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { ExecuteResult } from "../../constants";
|
|
2
|
+
import type { ProcessManager } from "../../manager";
|
|
3
|
+
import { sanitizeLine } from "../../utils";
|
|
4
|
+
|
|
5
|
+
interface LogsParams {
|
|
6
|
+
id?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function executeLogs(
|
|
10
|
+
params: LogsParams,
|
|
11
|
+
manager: ProcessManager,
|
|
12
|
+
): ExecuteResult {
|
|
13
|
+
if (!params.id) {
|
|
14
|
+
return {
|
|
15
|
+
content: [{ type: "text", text: "Missing required parameter: id" }],
|
|
16
|
+
details: {
|
|
17
|
+
action: "logs",
|
|
18
|
+
success: false,
|
|
19
|
+
message: "Missing required parameter: id",
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const resolved = manager.resolve(params.id);
|
|
25
|
+
if (!resolved.ok) {
|
|
26
|
+
if (resolved.reason === "ambiguous") {
|
|
27
|
+
const choices = (resolved.matches ?? [])
|
|
28
|
+
.map((match) => `${match.id} ("${sanitizeLine(match.name)}")`)
|
|
29
|
+
.join(", ");
|
|
30
|
+
const message =
|
|
31
|
+
`Process name is ambiguous: ${params.id}. ` +
|
|
32
|
+
`Use an exact process ID instead. Matches: ${choices}`;
|
|
33
|
+
return {
|
|
34
|
+
content: [{ type: "text", text: message }],
|
|
35
|
+
details: {
|
|
36
|
+
action: "logs",
|
|
37
|
+
success: false,
|
|
38
|
+
message,
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const message = `Process not found: ${params.id}`;
|
|
44
|
+
return {
|
|
45
|
+
content: [{ type: "text", text: message }],
|
|
46
|
+
details: {
|
|
47
|
+
action: "logs",
|
|
48
|
+
success: false,
|
|
49
|
+
message,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const proc = resolved.info;
|
|
55
|
+
const logFiles = manager.getLogFiles(proc.id);
|
|
56
|
+
if (!logFiles) {
|
|
57
|
+
const message = `Could not get log files for: ${proc.id}`;
|
|
58
|
+
return {
|
|
59
|
+
content: [{ type: "text", text: message }],
|
|
60
|
+
details: {
|
|
61
|
+
action: "logs",
|
|
62
|
+
success: false,
|
|
63
|
+
message,
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const message = [
|
|
69
|
+
`Log files for "${sanitizeLine(proc.name)}" (${proc.id}):`,
|
|
70
|
+
` stdout: ${logFiles.stdoutFile}`,
|
|
71
|
+
` stderr: ${logFiles.stderrFile}`,
|
|
72
|
+
` combined: ${logFiles.combinedFile}`,
|
|
73
|
+
"",
|
|
74
|
+
"Use the read tool to inspect these files.",
|
|
75
|
+
].join("\n");
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
content: [{ type: "text", text: message }],
|
|
79
|
+
details: {
|
|
80
|
+
action: "logs",
|
|
81
|
+
success: true,
|
|
82
|
+
message,
|
|
83
|
+
logFiles,
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { configLoader } from "../../config";
|
|
2
|
+
import {
|
|
3
|
+
type ExecuteResult,
|
|
4
|
+
LIVE_STATUSES,
|
|
5
|
+
type ResolveProcessResult,
|
|
6
|
+
} from "../../constants";
|
|
7
|
+
import type { ProcessManager } from "../../manager";
|
|
8
|
+
import { formatStatus, sanitizeLine } from "../../utils";
|
|
9
|
+
|
|
10
|
+
const MAX_BYTES = 50 * 1024; // 50KB
|
|
11
|
+
|
|
12
|
+
interface OutputParams {
|
|
13
|
+
id?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function resolveProcessResult(
|
|
17
|
+
result: ResolveProcessResult,
|
|
18
|
+
action: "output" | "logs",
|
|
19
|
+
id: string,
|
|
20
|
+
): ExecuteResult | null {
|
|
21
|
+
if (result.ok) return null;
|
|
22
|
+
|
|
23
|
+
if (result.reason === "ambiguous") {
|
|
24
|
+
const choices = (result.matches ?? [])
|
|
25
|
+
.map((match) => `${match.id} ("${sanitizeLine(match.name)}")`)
|
|
26
|
+
.join(", ");
|
|
27
|
+
const message =
|
|
28
|
+
`Process name is ambiguous: ${id}. ` +
|
|
29
|
+
`Use an exact process ID instead. Matches: ${choices}`;
|
|
30
|
+
return {
|
|
31
|
+
content: [{ type: "text", text: message }],
|
|
32
|
+
details: {
|
|
33
|
+
action,
|
|
34
|
+
success: false,
|
|
35
|
+
message,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const message = `Process not found: ${id}`;
|
|
41
|
+
return {
|
|
42
|
+
content: [{ type: "text", text: message }],
|
|
43
|
+
details: {
|
|
44
|
+
action,
|
|
45
|
+
success: false,
|
|
46
|
+
message,
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function executeOutput(
|
|
52
|
+
params: OutputParams,
|
|
53
|
+
manager: ProcessManager,
|
|
54
|
+
): ExecuteResult {
|
|
55
|
+
if (!params.id) {
|
|
56
|
+
return {
|
|
57
|
+
content: [{ type: "text", text: "Missing required parameter: id" }],
|
|
58
|
+
details: {
|
|
59
|
+
action: "output",
|
|
60
|
+
success: false,
|
|
61
|
+
message: "Missing required parameter: id",
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const resolved = manager.resolve(params.id);
|
|
67
|
+
if (!resolved.ok) {
|
|
68
|
+
return resolveProcessResult(resolved, "output", params.id) as ExecuteResult;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const proc = resolved.info;
|
|
72
|
+
const { defaultTailLines } = configLoader.getConfig().output;
|
|
73
|
+
const output = manager.getOutput(proc.id, defaultTailLines);
|
|
74
|
+
if (!output) {
|
|
75
|
+
const message = `Could not read output for: ${proc.id}`;
|
|
76
|
+
return {
|
|
77
|
+
content: [{ type: "text", text: message }],
|
|
78
|
+
details: {
|
|
79
|
+
action: "output",
|
|
80
|
+
success: false,
|
|
81
|
+
message,
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const logFiles = manager.getLogFiles(proc.id);
|
|
87
|
+
const stdoutLines = output.stdout.length;
|
|
88
|
+
const stderrLines = output.stderr.length;
|
|
89
|
+
const message = `"${sanitizeLine(proc.name)}" (${proc.id}) [${formatStatus(proc)}]: ${stdoutLines} stdout lines, ${stderrLines} stderr lines`;
|
|
90
|
+
|
|
91
|
+
// Build sanitized text content, then truncate from the tail like bash does,
|
|
92
|
+
// so the agent sees the most recent output.
|
|
93
|
+
const outputParts: string[] = [message];
|
|
94
|
+
if (output.stdout.length > 0) {
|
|
95
|
+
outputParts.push("\nstdout:");
|
|
96
|
+
outputParts.push(...output.stdout.map(sanitizeLine));
|
|
97
|
+
}
|
|
98
|
+
if (output.stderr.length > 0) {
|
|
99
|
+
outputParts.push("\nstderr:");
|
|
100
|
+
outputParts.push(...output.stderr.map(sanitizeLine));
|
|
101
|
+
}
|
|
102
|
+
if (LIVE_STATUSES.has(proc.status)) {
|
|
103
|
+
outputParts.push(
|
|
104
|
+
"",
|
|
105
|
+
"[Process is still active. This was a one-off snapshot; use process list to check status.]",
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const fullText = outputParts.join("\n");
|
|
110
|
+
const { maxOutputLines } = configLoader.getConfig().output;
|
|
111
|
+
const contentText = truncateTail(fullText, logFiles, maxOutputLines);
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
content: [{ type: "text", text: contentText }],
|
|
115
|
+
details: {
|
|
116
|
+
action: "output",
|
|
117
|
+
success: true,
|
|
118
|
+
message,
|
|
119
|
+
output,
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Truncate text from the tail (keep last N lines / MAX_BYTES), matching
|
|
126
|
+
* the behaviour of pi's built-in bash tool. When truncated, appends a
|
|
127
|
+
* notice pointing the agent to the full log files.
|
|
128
|
+
*/
|
|
129
|
+
function truncateTail(
|
|
130
|
+
text: string,
|
|
131
|
+
logFiles: {
|
|
132
|
+
stdoutFile: string;
|
|
133
|
+
stderrFile: string;
|
|
134
|
+
combinedFile: string;
|
|
135
|
+
} | null,
|
|
136
|
+
maxLines: number,
|
|
137
|
+
): string {
|
|
138
|
+
const totalBytes = Buffer.byteLength(text, "utf-8");
|
|
139
|
+
const lines = text.split("\n");
|
|
140
|
+
const totalLines = lines.length;
|
|
141
|
+
|
|
142
|
+
if (totalLines <= maxLines && totalBytes <= MAX_BYTES) {
|
|
143
|
+
return text;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Work backwards, collecting lines that fit
|
|
147
|
+
const kept: string[] = [];
|
|
148
|
+
let keptBytes = 0;
|
|
149
|
+
let hitBytes = false;
|
|
150
|
+
|
|
151
|
+
for (let i = lines.length - 1; i >= 0 && kept.length < maxLines; i--) {
|
|
152
|
+
const line = lines[i] ?? "";
|
|
153
|
+
const lineBytes =
|
|
154
|
+
Buffer.byteLength(line, "utf-8") + (kept.length > 0 ? 1 : 0);
|
|
155
|
+
|
|
156
|
+
if (keptBytes + lineBytes > MAX_BYTES) {
|
|
157
|
+
hitBytes = true;
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
kept.unshift(line);
|
|
162
|
+
keptBytes += lineBytes;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
let result = kept.join("\n");
|
|
166
|
+
|
|
167
|
+
// Append a notice so the agent knows output was truncated
|
|
168
|
+
const shownLines = kept.length;
|
|
169
|
+
const startLine = totalLines - shownLines + 1;
|
|
170
|
+
const sizeNote = hitBytes ? ` (${formatSize(MAX_BYTES)} limit)` : "";
|
|
171
|
+
result += `\n\n[Showing lines ${startLine}-${totalLines} of ${totalLines}${sizeNote}.`;
|
|
172
|
+
|
|
173
|
+
if (logFiles) {
|
|
174
|
+
result += ` Full logs: ${logFiles.stdoutFile} , ${logFiles.stderrFile}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
result += "]";
|
|
178
|
+
|
|
179
|
+
return result;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function formatSize(bytes: number): string {
|
|
183
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
184
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
185
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
186
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ExecuteResult } from "../../constants";
|
|
3
|
+
import type { ProcessManager } from "../../manager";
|
|
4
|
+
import { formatTimestamp, sanitizeLine } from "../../utils";
|
|
5
|
+
import { executeKill } from "./kill";
|
|
6
|
+
|
|
7
|
+
interface RestartParams {
|
|
8
|
+
name?: string;
|
|
9
|
+
command?: string;
|
|
10
|
+
cwd?: string;
|
|
11
|
+
force?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function executeRestart(
|
|
15
|
+
params: RestartParams,
|
|
16
|
+
manager: ProcessManager,
|
|
17
|
+
ctx: ExtensionContext,
|
|
18
|
+
): Promise<ExecuteResult> {
|
|
19
|
+
if (!params.name) {
|
|
20
|
+
return {
|
|
21
|
+
content: [{ type: "text", text: "Missing required parameter: name" }],
|
|
22
|
+
details: {
|
|
23
|
+
action: "restart",
|
|
24
|
+
success: false,
|
|
25
|
+
message: "Missing required parameter: name",
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
if (!params.command) {
|
|
30
|
+
return {
|
|
31
|
+
content: [{ type: "text", text: "Missing required parameter: command" }],
|
|
32
|
+
details: {
|
|
33
|
+
action: "restart",
|
|
34
|
+
success: false,
|
|
35
|
+
message: "Missing required parameter: command",
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Resolve existing process
|
|
41
|
+
const existing = manager.resolve(params.name);
|
|
42
|
+
if (!existing.ok) {
|
|
43
|
+
return {
|
|
44
|
+
content: [
|
|
45
|
+
{
|
|
46
|
+
type: "text",
|
|
47
|
+
text: `No process named "${params.name}" is running. Use process start instead.`,
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
details: {
|
|
51
|
+
action: "restart",
|
|
52
|
+
success: false,
|
|
53
|
+
message: `No process named "${params.name}" is running.`,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Kill existing process (awaited, safe)
|
|
59
|
+
const killResult = await executeKill(
|
|
60
|
+
{ id: existing.info.id, force: params.force },
|
|
61
|
+
manager,
|
|
62
|
+
);
|
|
63
|
+
if (!killResult.details.success) {
|
|
64
|
+
return {
|
|
65
|
+
content: [
|
|
66
|
+
{
|
|
67
|
+
type: "text",
|
|
68
|
+
text: `Failed to kill existing process: ${killResult.details.message}`,
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
details: {
|
|
72
|
+
action: "restart",
|
|
73
|
+
success: false,
|
|
74
|
+
message: `Failed to kill existing process: ${killResult.details.message}`,
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Start new process
|
|
80
|
+
const proc = manager.start(
|
|
81
|
+
params.name,
|
|
82
|
+
params.command,
|
|
83
|
+
params.cwd ?? ctx.cwd,
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
if (proc === null) {
|
|
87
|
+
return {
|
|
88
|
+
content: [
|
|
89
|
+
{
|
|
90
|
+
type: "text",
|
|
91
|
+
text: `A process named "${params.name}" is already running. Use process kill first.`,
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
details: {
|
|
95
|
+
action: "restart",
|
|
96
|
+
success: false,
|
|
97
|
+
message: `A process named "${params.name}" is already running.`,
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const startedAt = formatTimestamp(proc.startTime);
|
|
103
|
+
const message = `Restarted "${sanitizeLine(proc.name)}" (${proc.id}, PID: ${proc.pid})\nStarted at: ${startedAt}\nLogs: ${proc.stdoutFile}`;
|
|
104
|
+
return {
|
|
105
|
+
content: [{ type: "text", text: message }],
|
|
106
|
+
details: {
|
|
107
|
+
action: "restart",
|
|
108
|
+
success: true,
|
|
109
|
+
message,
|
|
110
|
+
process: proc,
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ExecuteResult } from "../../constants";
|
|
3
|
+
import type { ProcessManager } from "../../manager";
|
|
4
|
+
import { formatTimestamp, sanitizeLine } from "../../utils";
|
|
5
|
+
|
|
6
|
+
interface StartParams {
|
|
7
|
+
name?: string;
|
|
8
|
+
command?: string;
|
|
9
|
+
cwd?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function executeStart(
|
|
13
|
+
params: StartParams,
|
|
14
|
+
manager: ProcessManager,
|
|
15
|
+
ctx: ExtensionContext,
|
|
16
|
+
): ExecuteResult {
|
|
17
|
+
if (!params.name) {
|
|
18
|
+
return {
|
|
19
|
+
content: [{ type: "text", text: "Missing required parameter: name" }],
|
|
20
|
+
details: {
|
|
21
|
+
action: "start",
|
|
22
|
+
success: false,
|
|
23
|
+
message: "Missing required parameter: name",
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
if (!params.command) {
|
|
28
|
+
return {
|
|
29
|
+
content: [{ type: "text", text: "Missing required parameter: command" }],
|
|
30
|
+
details: {
|
|
31
|
+
action: "start",
|
|
32
|
+
success: false,
|
|
33
|
+
message: "Missing required parameter: command",
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const proc = manager.start(
|
|
40
|
+
params.name,
|
|
41
|
+
params.command,
|
|
42
|
+
params.cwd ?? ctx.cwd,
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
if (proc === null) {
|
|
46
|
+
return {
|
|
47
|
+
content: [
|
|
48
|
+
{
|
|
49
|
+
type: "text",
|
|
50
|
+
text: `A process named "${params.name}" is already running. Use process kill first, or process restart to replace it.`,
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
details: {
|
|
54
|
+
action: "start",
|
|
55
|
+
success: false,
|
|
56
|
+
message: `A process named "${params.name}" is already running.`,
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const startedAt = formatTimestamp(proc.startTime);
|
|
62
|
+
const message = `Started "${sanitizeLine(proc.name)}" (${proc.id}, PID: ${proc.pid})\nStarted at: ${startedAt}\nLogs: ${proc.stdoutFile}`;
|
|
63
|
+
return {
|
|
64
|
+
content: [{ type: "text", text: message }],
|
|
65
|
+
details: {
|
|
66
|
+
action: "start",
|
|
67
|
+
success: true,
|
|
68
|
+
message,
|
|
69
|
+
process: proc,
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
} catch (error) {
|
|
73
|
+
const message =
|
|
74
|
+
error instanceof Error
|
|
75
|
+
? `Failed to start process: ${error.message}`
|
|
76
|
+
: "Failed to start process";
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
content: [{ type: "text", text: message }],
|
|
80
|
+
details: {
|
|
81
|
+
action: "start",
|
|
82
|
+
success: false,
|
|
83
|
+
message,
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import type { ProcessesDetails } from "../constants";
|
|
4
|
+
import type { ProcessManager } from "../manager";
|
|
5
|
+
import { executeAction } from "./actions";
|
|
6
|
+
|
|
7
|
+
const ProcessesParams = Type.Object({
|
|
8
|
+
action: Type.Union(
|
|
9
|
+
[
|
|
10
|
+
Type.Literal("start"),
|
|
11
|
+
Type.Literal("list"),
|
|
12
|
+
Type.Literal("output"),
|
|
13
|
+
Type.Literal("logs"),
|
|
14
|
+
Type.Literal("kill"),
|
|
15
|
+
Type.Literal("clear"),
|
|
16
|
+
Type.Literal("restart"),
|
|
17
|
+
],
|
|
18
|
+
{
|
|
19
|
+
description:
|
|
20
|
+
"Action: start (run command), list (show all), output (get recent output), logs (get log file paths), kill (terminate or force-kill), clear (remove finished), restart (kill existing and start new)",
|
|
21
|
+
},
|
|
22
|
+
),
|
|
23
|
+
command: Type.Optional(
|
|
24
|
+
Type.String({ description: "Command to run (required for start/restart)" }),
|
|
25
|
+
),
|
|
26
|
+
name: Type.Optional(
|
|
27
|
+
Type.String({
|
|
28
|
+
description:
|
|
29
|
+
"Friendly name for the process (required for start/restart, e.g. 'backend-dev', 'test-runner')",
|
|
30
|
+
}),
|
|
31
|
+
),
|
|
32
|
+
cwd: Type.Optional(
|
|
33
|
+
Type.String({
|
|
34
|
+
description:
|
|
35
|
+
"Working directory for the command (for start/restart action). Defaults to the session working directory. Prefer this over 'cd dir && command' shell wrappers.",
|
|
36
|
+
}),
|
|
37
|
+
),
|
|
38
|
+
id: Type.Optional(
|
|
39
|
+
Type.String({
|
|
40
|
+
description:
|
|
41
|
+
"Exact process ID or exact friendly name to match (required for output/kill/logs).",
|
|
42
|
+
}),
|
|
43
|
+
),
|
|
44
|
+
force: Type.Optional(
|
|
45
|
+
Type.Boolean({
|
|
46
|
+
description:
|
|
47
|
+
"Force-kill the process with SIGKILL for kill action. Use after a normal terminate times out, or when you need an immediate hard stop.",
|
|
48
|
+
}),
|
|
49
|
+
),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export function setupProcessesTools(pi: ExtensionAPI, manager: ProcessManager) {
|
|
53
|
+
pi.registerTool<typeof ProcessesParams, ProcessesDetails>({
|
|
54
|
+
name: "process",
|
|
55
|
+
label: "Process",
|
|
56
|
+
description: `Manage background processes.
|
|
57
|
+
|
|
58
|
+
Actions: start, list, output, logs, kill, clear, restart.
|
|
59
|
+
- start/restart require 'name' and 'command' — restart kills existing process first
|
|
60
|
+
- output/logs/kill require 'id' (exact process ID or exact friendly name)
|
|
61
|
+
- kill supports optional 'force=true' for SIGKILL
|
|
62
|
+
- restart is preferred over start+kill: it safely awaits kill before starting new process
|
|
63
|
+
|
|
64
|
+
Processes continue in the background. Use process output or process logs for a one-off snapshot when you need to inspect status. Do not poll repeatedly just to wait.
|
|
65
|
+
Tool-triggered kills never notify.`,
|
|
66
|
+
promptSnippet:
|
|
67
|
+
"Start and manage long-running processes without blocking the conversation; use process output/logs for one-off status snapshots.",
|
|
68
|
+
promptGuidelines: [
|
|
69
|
+
"Use the process tool instead of bash for dev servers, watch mode, log tails, port-forwards, or commands that should keep running.",
|
|
70
|
+
"After process start, the agent continues its turn — use process output/logs to check status if needed.",
|
|
71
|
+
"Use process output or process logs only for a one-off inspection, explicit user request, or debugging.",
|
|
72
|
+
"Use process restart to replace an existing process — it safely awaits kill before starting the new one.",
|
|
73
|
+
"Do not poll process output/list repeatedly just to wait for a process to finish.",
|
|
74
|
+
],
|
|
75
|
+
|
|
76
|
+
parameters: ProcessesParams,
|
|
77
|
+
|
|
78
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
79
|
+
return executeAction(params, manager, ctx);
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strip ANSI escape codes and other terminal control characters from a string.
|
|
3
|
+
*
|
|
4
|
+
* Removes:
|
|
5
|
+
* - All CSI sequences (\x1b[...X) - SGR, cursor movement, erase, scroll, etc.
|
|
6
|
+
* - OSC sequences (\x1b]...\x07 or \x1b]...\x1b\\)
|
|
7
|
+
* - APC sequences (\x1b_...\x07 or \x1b_...\x1b\\)
|
|
8
|
+
* - Remaining C0 control chars except tab/newline
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const ESC = String.fromCodePoint(0x001b);
|
|
12
|
+
const BEL = String.fromCodePoint(0x0007);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Check if a string contains terminal escape codes.
|
|
16
|
+
*/
|
|
17
|
+
export function hasAnsi(str: string): boolean {
|
|
18
|
+
return str.includes(ESC);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Strip terminal escape sequences from a string while preserving normal text,
|
|
23
|
+
* whitespace, and line breaks.
|
|
24
|
+
*/
|
|
25
|
+
export function stripAnsi(str: string): string {
|
|
26
|
+
let clean = str;
|
|
27
|
+
|
|
28
|
+
if (str.includes(ESC)) {
|
|
29
|
+
// Strip all CSI sequences (ESC[...X where X is any letter)
|
|
30
|
+
clean = clean.replace(new RegExp(`${ESC}\\[[0-9;]*[A-Za-z]`, "gu"), "");
|
|
31
|
+
// Strip OSC sequences: ESC]...<BEL> or ESC]...<ESC>\\
|
|
32
|
+
clean = clean.replace(
|
|
33
|
+
new RegExp(`${ESC}\\][^${BEL}${ESC}]*(?:${BEL}|${ESC}\\\\)`, "gu"),
|
|
34
|
+
"",
|
|
35
|
+
);
|
|
36
|
+
// Strip APC sequences: ESC_...<BEL> or ESC_...<ESC>\\ (used for cursor marker)
|
|
37
|
+
clean = clean.replace(
|
|
38
|
+
new RegExp(`${ESC}_[^${BEL}${ESC}]*(?:${BEL}|${ESC}\\\\)`, "gu"),
|
|
39
|
+
"",
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Strip terminal control chars like carriage return/backspace that can
|
|
44
|
+
// corrupt TUI layout when rendered back into pi.
|
|
45
|
+
return Array.from(clean)
|
|
46
|
+
.filter((char) => {
|
|
47
|
+
const code = char.codePointAt(0) ?? 0;
|
|
48
|
+
const isDisallowedC0 =
|
|
49
|
+
(code >= 0x00 && code <= 0x08) ||
|
|
50
|
+
(code >= 0x0b && code <= 0x1f) ||
|
|
51
|
+
code === 0x7f;
|
|
52
|
+
return !isDisallowedC0;
|
|
53
|
+
})
|
|
54
|
+
.join("");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Sanitize process output for single-line TUI rendering.
|
|
59
|
+
*
|
|
60
|
+
* This is stricter than stripAnsi(): after terminal escapes are removed, any
|
|
61
|
+
* remaining control bytes (carriage returns, backspaces, BEL, embedded
|
|
62
|
+
* newlines, etc.) are dropped so process output cannot move the cursor or
|
|
63
|
+
* alter Pi's surrounding TUI.
|
|
64
|
+
*/
|
|
65
|
+
export function sanitizeLine(str: string): string {
|
|
66
|
+
return stripAnsi(str).replace(/\t/g, " ");
|
|
67
|
+
}
|