@haemmid/pi-processes 0.9.1 → 0.9.3
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 +25 -0
- package/README.md +14 -8
- package/package.json +1 -1
- package/src/manager.ts +70 -0
- package/src/tools/actions/ensure.ts +87 -0
- package/src/tools/actions/index.ts +3 -0
- package/src/tools/actions/restart.ts +6 -26
- package/src/tools/actions/utils.ts +7 -2
- package/src/tools/index.ts +5 -3
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,31 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
This project follows semantic versioning for public releases.
|
|
6
6
|
|
|
7
|
+
## [0.9.3] - 2026-07-02
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- `restart` waits for old process to fully exit before starting replacement;
|
|
12
|
+
does not start a new process if the old one does not exit within timeout.
|
|
13
|
+
- `restart` error message now shows the actual signal used (SIGTERM vs SIGKILL)
|
|
14
|
+
depending on `force` parameter.
|
|
15
|
+
- Ambiguous name errors now include exact process IDs in the suggestion.
|
|
16
|
+
- Added manager-level regression tests for `restart`: no premature spawn,
|
|
17
|
+
timeout handling, force=SIGKILL, post-restart resolve, ambiguous name rejection.
|
|
18
|
+
|
|
19
|
+
## [0.9.2] - 2026-07-02
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- `ensure` action for idempotent dev-server workflows.
|
|
24
|
+
- `ensure` reuses existing process when name+command+cwd match.
|
|
25
|
+
- `ensure` returns conflict error when name matches but configuration differs.
|
|
26
|
+
- `ensure` returns next commands (output/logs/restart/kill by name).
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- Version bumped to 0.9.2.
|
|
31
|
+
|
|
7
32
|
## [0.9.1] - 2026-07-02
|
|
8
33
|
|
|
9
34
|
### Added
|
package/README.md
CHANGED
|
@@ -94,7 +94,7 @@ For Astro/Vite dev servers, ask the agent:
|
|
|
94
94
|
|
|
95
95
|
```text
|
|
96
96
|
Use the process tool for the Astro dev server.
|
|
97
|
-
|
|
97
|
+
Ensure `npm run dev -- --host 0.0.0.0` is running as `my-site:astro`.
|
|
98
98
|
Use `process output` when you need logs.
|
|
99
99
|
Do not restart after ordinary .astro, .ts, or .css edits.
|
|
100
100
|
Restart only after package/config/env changes or if the server exits.
|
|
@@ -104,10 +104,10 @@ Typical tool flow:
|
|
|
104
104
|
|
|
105
105
|
```text
|
|
106
106
|
process list
|
|
107
|
-
process
|
|
108
|
-
process output
|
|
107
|
+
process ensure "npm run dev -- --host 0.0.0.0" name="my-site:astro"
|
|
108
|
+
process output name="my-site:astro"
|
|
109
109
|
process restart "npm run dev -- --host 0.0.0.0" name="my-site:astro"
|
|
110
|
-
process kill
|
|
110
|
+
process kill name="my-site:astro"
|
|
111
111
|
```
|
|
112
112
|
|
|
113
113
|
## Demo
|
|
@@ -156,7 +156,8 @@ The tool is named `process`.
|
|
|
156
156
|
|
|
157
157
|
| Action | Description |
|
|
158
158
|
|--------|-------------|
|
|
159
|
-
| `start` | Start a managed process. |
|
|
159
|
+
| `start` | Start a managed process (refuses if a live process with the same name exists). |
|
|
160
|
+
| `ensure` | Idempotent start: reuse existing process if name+command+cwd match, start new otherwise. |
|
|
160
161
|
| `restart` | Kill existing process and start a new one (safe: await kill → start). |
|
|
161
162
|
| `list` | List managed processes. |
|
|
162
163
|
| `output` | Return a one-off tailed stdout/stderr snapshot. |
|
|
@@ -170,6 +171,8 @@ The tool is named `process`.
|
|
|
170
171
|
process start "pnpm dev" name="backend-dev"
|
|
171
172
|
process start "pnpm test --watch" name="tests"
|
|
172
173
|
process start "pnpm dev" name="backend-dev" cwd="/path/to/project"
|
|
174
|
+
process ensure "pnpm dev" name="backend-dev"
|
|
175
|
+
process ensure "pnpm dev" name="backend-dev" cwd="/path/to/project"
|
|
173
176
|
process restart "pnpm dev" name="backend-dev"
|
|
174
177
|
process restart "pnpm dev" name="backend-dev" force=true
|
|
175
178
|
process list
|
|
@@ -184,13 +187,16 @@ process clear
|
|
|
184
187
|
|
|
185
188
|
### Field rules
|
|
186
189
|
|
|
187
|
-
- `start`/`restart` require `command` and `name`.
|
|
190
|
+
- `start`/`ensure`/`restart` require `command` and `name`.
|
|
188
191
|
- `output`, `logs`, and `kill` accept either `id` (exact process ID) or `name` (process name).
|
|
189
192
|
- `kill` accepts `force=true` to send `SIGKILL` instead of `SIGTERM`.
|
|
190
|
-
- `start` refuses if a process with the same name is already running.
|
|
193
|
+
- `start` refuses if a live process with the same name is already running.
|
|
194
|
+
- `ensure` reuses existing process when name+command+cwd match; returns conflict error otherwise.
|
|
191
195
|
- `restart` safely kills the existing process (awaited) before starting a new one.
|
|
192
196
|
- `restart` accepts `force=true` to send `SIGKILL` instead of `SIGTERM`.
|
|
193
|
-
- `
|
|
197
|
+
- `restart` waits for the old process to exit; if it does not exit within the
|
|
198
|
+
timeout, the replacement is not started. Use `force=true` or inspect logs.
|
|
199
|
+
- `start`/`ensure`/`restart` accept `cwd` to override the working directory (defaults to session cwd).
|
|
194
200
|
- `output`, `logs`, and `kill` return an error if both `id` and `name` are provided.
|
|
195
201
|
|
|
196
202
|
## Killing processes
|
package/package.json
CHANGED
package/src/manager.ts
CHANGED
|
@@ -235,6 +235,76 @@ export class ProcessManager {
|
|
|
235
235
|
return this.toProcessInfo(managed);
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Restart a process by name: kill existing (awaited), then start new.
|
|
240
|
+
*
|
|
241
|
+
* - Waits for existing process to exit (polls every 500ms, up to timeoutMs).
|
|
242
|
+
* - Returns null if existing process does not exit after timeout.
|
|
243
|
+
* - Returns null if a different live process with the same name appears.
|
|
244
|
+
* - Returns new ProcessInfo on success.
|
|
245
|
+
*/
|
|
246
|
+
async restart(
|
|
247
|
+
name: string,
|
|
248
|
+
command: string,
|
|
249
|
+
cwd: string,
|
|
250
|
+
opts?: { force?: boolean; timeoutMs?: number },
|
|
251
|
+
): Promise<ProcessInfo | null> {
|
|
252
|
+
const existing = this.resolve(name);
|
|
253
|
+
if (!existing.ok) {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Kill existing process
|
|
258
|
+
const killResult = await this.kill(existing.info.id, {
|
|
259
|
+
signal: opts?.force ? "SIGKILL" : "SIGTERM",
|
|
260
|
+
timeoutMs: opts?.timeoutMs ?? 5000,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
if (!killResult.ok) {
|
|
264
|
+
// Process did not exit — return null so caller can decide
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Verify no other live process with this name appeared
|
|
269
|
+
const liveMatch = Array.from(this.processes.values()).find(
|
|
270
|
+
(proc) => proc.name === name && LIVE_STATUSES.has(proc.status),
|
|
271
|
+
);
|
|
272
|
+
if (liveMatch) {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Start new process
|
|
277
|
+
return this.start(name, command, cwd);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Ensure a process is running with the given name, command, and cwd.
|
|
282
|
+
*
|
|
283
|
+
* - If a live process with the same name + cwd exists → return it.
|
|
284
|
+
* - If a live process with the same name exists but different cwd/command →
|
|
285
|
+
* return null (conflict).
|
|
286
|
+
* - If no live process with this name exists → start a new one.
|
|
287
|
+
* - Finished processes with the same name are ignored.
|
|
288
|
+
*/
|
|
289
|
+
ensure(name: string, command: string, cwd: string): ProcessInfo | null {
|
|
290
|
+
// Find live process with this name
|
|
291
|
+
const liveMatch = Array.from(this.processes.values()).find(
|
|
292
|
+
(proc) => proc.name === name && LIVE_STATUSES.has(proc.status),
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
if (liveMatch) {
|
|
296
|
+
// Same name, same cwd → reuse
|
|
297
|
+
if (liveMatch.cwd === cwd && liveMatch.command === command) {
|
|
298
|
+
return this.toProcessInfo(liveMatch);
|
|
299
|
+
}
|
|
300
|
+
// Same name, different cwd/command → conflict
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// No live match → start new
|
|
305
|
+
return this.start(name, command, cwd);
|
|
306
|
+
}
|
|
307
|
+
|
|
238
308
|
list(): ProcessInfo[] {
|
|
239
309
|
return Array.from(this.processes.values())
|
|
240
310
|
.map((p) => this.toProcessInfo(p))
|
|
@@ -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
|
+
import { buildNextCommands } from "./utils";
|
|
6
|
+
|
|
7
|
+
interface EnsureParams {
|
|
8
|
+
name?: string;
|
|
9
|
+
command?: string;
|
|
10
|
+
cwd?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function executeEnsure(
|
|
14
|
+
params: EnsureParams,
|
|
15
|
+
manager: ProcessManager,
|
|
16
|
+
ctx: ExtensionContext,
|
|
17
|
+
): ExecuteResult {
|
|
18
|
+
if (!params.name) {
|
|
19
|
+
return {
|
|
20
|
+
content: [{ type: "text", text: "Missing required parameter: name" }],
|
|
21
|
+
details: {
|
|
22
|
+
action: "ensure",
|
|
23
|
+
success: false,
|
|
24
|
+
message: "Missing required parameter: name",
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
if (!params.command) {
|
|
29
|
+
return {
|
|
30
|
+
content: [{ type: "text", text: "Missing required parameter: command" }],
|
|
31
|
+
details: {
|
|
32
|
+
action: "ensure",
|
|
33
|
+
success: false,
|
|
34
|
+
message: "Missing required parameter: command",
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const proc = manager.ensure(
|
|
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 with a different command or cwd. Use process kill first, or process restart to replace it.`,
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
details: {
|
|
54
|
+
action: "ensure",
|
|
55
|
+
success: false,
|
|
56
|
+
message: `A process named "${params.name}" is already running with a different configuration.`,
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const startedAt = formatTimestamp(proc.startTime);
|
|
62
|
+
|
|
63
|
+
// Check if this was an existing process or newly started
|
|
64
|
+
const isNew = proc.pid > 0;
|
|
65
|
+
const message = isNew
|
|
66
|
+
? [
|
|
67
|
+
`Started "${sanitizeLine(proc.name)}" (${proc.id}, PID: ${proc.pid})`,
|
|
68
|
+
`Started at: ${startedAt}`,
|
|
69
|
+
`Logs: ${proc.stdoutFile}`,
|
|
70
|
+
buildNextCommands(proc.name),
|
|
71
|
+
].join("\n")
|
|
72
|
+
: [
|
|
73
|
+
`Already running "${sanitizeLine(proc.name)}" (${proc.id}, PID: ${proc.pid})`,
|
|
74
|
+
`Reusing existing managed process.`,
|
|
75
|
+
buildNextCommands(proc.name),
|
|
76
|
+
].join("\n");
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
content: [{ type: "text", text: message }],
|
|
80
|
+
details: {
|
|
81
|
+
action: "ensure",
|
|
82
|
+
success: true,
|
|
83
|
+
message,
|
|
84
|
+
process: proc,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -2,6 +2,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import type { ExecuteResult } from "../../constants";
|
|
3
3
|
import type { ProcessManager } from "../../manager";
|
|
4
4
|
import { executeClear } from "./clear";
|
|
5
|
+
import { executeEnsure } from "./ensure";
|
|
5
6
|
import { executeKill } from "./kill";
|
|
6
7
|
import { executeList } from "./list";
|
|
7
8
|
import { executeLogs } from "./logs";
|
|
@@ -26,6 +27,8 @@ export async function executeAction(
|
|
|
26
27
|
switch (params.action) {
|
|
27
28
|
case "start":
|
|
28
29
|
return executeStart(params, manager, ctx);
|
|
30
|
+
case "ensure":
|
|
31
|
+
return executeEnsure(params, manager, ctx);
|
|
29
32
|
case "restart":
|
|
30
33
|
return executeRestart(params, manager, ctx);
|
|
31
34
|
case "list":
|
|
@@ -2,7 +2,6 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import type { ExecuteResult } from "../../constants";
|
|
3
3
|
import type { ProcessManager } from "../../manager";
|
|
4
4
|
import { formatTimestamp, sanitizeLine } from "../../utils";
|
|
5
|
-
import { executeKill } from "./kill";
|
|
6
5
|
import { buildNextCommands } from "./utils";
|
|
7
6
|
|
|
8
7
|
interface RestartParams {
|
|
@@ -56,46 +55,27 @@ export async function executeRestart(
|
|
|
56
55
|
};
|
|
57
56
|
}
|
|
58
57
|
|
|
59
|
-
//
|
|
60
|
-
const
|
|
61
|
-
{ id: existing.info.id, force: params.force },
|
|
62
|
-
manager,
|
|
63
|
-
);
|
|
64
|
-
if (!killResult.details.success) {
|
|
65
|
-
return {
|
|
66
|
-
content: [
|
|
67
|
-
{
|
|
68
|
-
type: "text",
|
|
69
|
-
text: `Failed to kill existing process: ${killResult.details.message}`,
|
|
70
|
-
},
|
|
71
|
-
],
|
|
72
|
-
details: {
|
|
73
|
-
action: "restart",
|
|
74
|
-
success: false,
|
|
75
|
-
message: `Failed to kill existing process: ${killResult.details.message}`,
|
|
76
|
-
},
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Start new process
|
|
81
|
-
const proc = manager.start(
|
|
58
|
+
// Restart via manager (atomic: kill → await exit → start)
|
|
59
|
+
const proc = await manager.restart(
|
|
82
60
|
params.name,
|
|
83
61
|
params.command,
|
|
84
62
|
params.cwd ?? ctx.cwd,
|
|
63
|
+
{ force: params.force },
|
|
85
64
|
);
|
|
86
65
|
|
|
87
66
|
if (proc === null) {
|
|
67
|
+
const signal = params.force ? "SIGKILL" : "SIGTERM";
|
|
88
68
|
return {
|
|
89
69
|
content: [
|
|
90
70
|
{
|
|
91
71
|
type: "text",
|
|
92
|
-
text: `
|
|
72
|
+
text: `Could not restart "${sanitizeLine(params.name)}": previous process did not exit after ${signal}. Use restart force=true or inspect logs.`,
|
|
93
73
|
},
|
|
94
74
|
],
|
|
95
75
|
details: {
|
|
96
76
|
action: "restart",
|
|
97
77
|
success: false,
|
|
98
|
-
message: `
|
|
78
|
+
message: `Previous process did not exit after ${signal}.`,
|
|
99
79
|
},
|
|
100
80
|
};
|
|
101
81
|
}
|
|
@@ -62,17 +62,22 @@ export function resolveSelector(
|
|
|
62
62
|
const choices = (resolved.matches ?? [])
|
|
63
63
|
.map((match) => `${match.id} ("${sanitizeLine(match.name)}")`)
|
|
64
64
|
.join(", ");
|
|
65
|
+
const ids = (resolved.matches ?? []).map((m) => m.id);
|
|
66
|
+
const suggestion =
|
|
67
|
+
ids.length === 2
|
|
68
|
+
? `Use id="${ids[0]}" or id="${ids[1]}", or inspect process list.`
|
|
69
|
+
: `Use an exact process ID instead. Matches: ${choices}`;
|
|
65
70
|
return {
|
|
66
71
|
content: [
|
|
67
72
|
{
|
|
68
73
|
type: "text",
|
|
69
|
-
text: `
|
|
74
|
+
text: `Multiple processes named "${query}" found. ${suggestion}`,
|
|
70
75
|
},
|
|
71
76
|
],
|
|
72
77
|
details: {
|
|
73
78
|
action: "output",
|
|
74
79
|
success: false,
|
|
75
|
-
message: `
|
|
80
|
+
message: `Multiple processes named "${query}" found. Matches: ${choices}`,
|
|
76
81
|
},
|
|
77
82
|
};
|
|
78
83
|
}
|
package/src/tools/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ const ProcessesParams = Type.Object({
|
|
|
8
8
|
action: Type.Union(
|
|
9
9
|
[
|
|
10
10
|
Type.Literal("start"),
|
|
11
|
+
Type.Literal("ensure"),
|
|
11
12
|
Type.Literal("list"),
|
|
12
13
|
Type.Literal("output"),
|
|
13
14
|
Type.Literal("logs"),
|
|
@@ -17,7 +18,7 @@ const ProcessesParams = Type.Object({
|
|
|
17
18
|
],
|
|
18
19
|
{
|
|
19
20
|
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
|
+
"Action: start (run command), ensure (idempotent start), 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
|
},
|
|
22
23
|
),
|
|
23
24
|
command: Type.Optional(
|
|
@@ -55,8 +56,9 @@ export function setupProcessesTools(pi: ExtensionAPI, manager: ProcessManager) {
|
|
|
55
56
|
label: "Process",
|
|
56
57
|
description: `Manage background processes.
|
|
57
58
|
|
|
58
|
-
Actions: start, list, output, logs, kill, clear, restart.
|
|
59
|
-
- start/restart require 'name' and 'command'
|
|
59
|
+
Actions: start, ensure, list, output, logs, kill, clear, restart.
|
|
60
|
+
- start/restart/ensure require 'name' and 'command'
|
|
61
|
+
- ensure is an idempotent start: reuses existing process if name+command+cwd match
|
|
60
62
|
- output/logs/kill accept either 'id' (exact process ID) or 'name' (process name)
|
|
61
63
|
- kill supports optional 'force=true' for SIGKILL
|
|
62
64
|
- restart is preferred over start+kill: it safely awaits kill before starting new process
|