@gogomi/pi-windows-shell 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/index.ts ADDED
@@ -0,0 +1,1050 @@
1
+ /**
2
+ * pi-windows-shell — Windows PowerShell and process-management extension for Pi.
3
+ *
4
+ * Registers 9 explicit tools for PowerShell execution and Windows process management.
5
+ * Does NOT override bash, read, write, or edit.
6
+ */
7
+
8
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
+ import { Type } from "@sinclair/typebox";
10
+ import { readFileSync } from "node:fs";
11
+ import { dirname, resolve } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import {
14
+ findPowerShell,
15
+ executePowerShell,
16
+ getPowerShellVersion,
17
+ clearDiscoveryCache,
18
+ startDetachedProcess,
19
+ } from "./shell.js";
20
+ import {
21
+ loadRegistry,
22
+ addProcess,
23
+ updateProcess,
24
+ getProcess,
25
+ getAllProcesses,
26
+ cleanupRegistry,
27
+ } from "./process-registry.js";
28
+ import { tailFile } from "./output.js";
29
+ import {
30
+ getBaseDir,
31
+ getLogsDir,
32
+ getDefaultOutputFile,
33
+ ensureDir,
34
+ generateProcessId,
35
+ getRegistryPath,
36
+ } from "./paths.js";
37
+ import type { ManagedProcess } from "./types.js";
38
+
39
+ // Load the Windows shell policy at extension init time
40
+ const __dirname = dirname(fileURLToPath(import.meta.url));
41
+ const policyPath = resolve(__dirname, "prompts", "windows-shell-policy.md");
42
+ let windowsShellPolicy = "";
43
+ try {
44
+ windowsShellPolicy = readFileSync(policyPath, "utf-8");
45
+ } catch {
46
+ // Policy file not found — extension still works, just without the policy injection
47
+ }
48
+
49
+ export default function (pi: ExtensionAPI) {
50
+ // ── Status bar ──────────────────────────────────────────────
51
+ pi.on("session_start", async (_event, ctx) => {
52
+ try {
53
+ const discovery = await findPowerShell();
54
+ const version = await getPowerShellVersion();
55
+ const label = version
56
+ ? `WinShell: ${discovery.kind} ${version}`
57
+ : `WinShell: ${discovery.kind}`;
58
+ ctx.ui.setStatus("win-shell", label);
59
+ } catch {
60
+ ctx.ui.setStatus("win-shell", "WinShell: not found");
61
+ }
62
+ });
63
+
64
+ // ── System prompt injection ─────────────────────────────────
65
+ if (windowsShellPolicy) {
66
+ pi.on("before_agent_start", async (event) => {
67
+ return {
68
+ systemPrompt: event.systemPrompt + "\n\n" + windowsShellPolicy,
69
+ };
70
+ });
71
+ }
72
+
73
+ // ── Slash commands ──────────────────────────────────────────
74
+
75
+ pi.registerCommand("win-processes", {
76
+ description: "Show tracked Windows background processes",
77
+ handler: async (_args, ctx) => {
78
+ const processes = await getAllProcesses();
79
+ if (processes.length === 0) {
80
+ ctx.ui.notify("No tracked processes.", "info");
81
+ return;
82
+ }
83
+ const lines = processes.map(
84
+ (p) => `[${p.status}] ${p.id} PID:${p.pid} ${p.name} ${p.command.slice(0, 60)}`
85
+ );
86
+ ctx.ui.notify(lines.join("\n"), "info");
87
+ },
88
+ });
89
+
90
+ pi.registerCommand("win-cleanup", {
91
+ description: "Clean up stale process registry entries",
92
+ handler: async (_args, ctx) => {
93
+ const result = await cleanupRegistry({
94
+ removeExited: true,
95
+ deleteLogs: false,
96
+ olderThanDays: 7,
97
+ });
98
+ ctx.ui.notify(
99
+ `Removed ${result.removedEntries} entries, kept ${result.keptRunning} running.`,
100
+ "info"
101
+ );
102
+ },
103
+ });
104
+
105
+ pi.registerCommand("win-shell-info", {
106
+ description: "Show discovered PowerShell and data paths",
107
+ handler: async (_args, ctx) => {
108
+ try {
109
+ const discovery = await findPowerShell();
110
+ const version = await getPowerShellVersion();
111
+ const info = [
112
+ `Shell: ${discovery.exe} (${discovery.kind})`,
113
+ `Version: ${version ?? "unknown"}`,
114
+ `Registry: ${getRegistryPath()}`,
115
+ `Logs: ${getLogsDir()}`,
116
+ `Base: ${getBaseDir()}`,
117
+ ];
118
+ ctx.ui.notify(info.join("\n"), "info");
119
+ } catch (error) {
120
+ ctx.ui.notify(
121
+ `Failed: ${error instanceof Error ? error.message : String(error)}`,
122
+ "error"
123
+ );
124
+ }
125
+ },
126
+ });
127
+
128
+ // ══════════════════════════════════════════════════════════════
129
+ // Tools
130
+ // ══════════════════════════════════════════════════════════════
131
+
132
+ // ── powershell ──────────────────────────────────────────────
133
+
134
+ pi.registerTool({
135
+ name: "powershell",
136
+ label: "PowerShell",
137
+ description:
138
+ "Run a foreground PowerShell command on Windows. Use for Windows-native commands: Windows paths (C:\\\\, D:\\\\)" +
139
+ ", $env variables, .exe/.cmd/.bat/.ps1 execution, and Windows system inspection. " +
140
+ "Returns stdout, stderr, and exit code. Default timeout: 120s. Output truncated at 50KB/2000 lines.",
141
+ promptSnippet: "Run a PowerShell command on Windows (foreground, up to 120s, output truncated at 50KB/2000 lines)",
142
+ promptGuidelines: [
143
+ "Use powershell for Windows-native commands: Windows paths, $env variables, .exe/.cmd/.bat/.ps1 execution, process diagnostics, and Windows registry/system inspection.",
144
+ "Do NOT use powershell for long-running dev servers — use win_start_process instead.",
145
+ "Use bash (not powershell) for git diff, git status, git log, git grep, and Unix-like repository workflows (grep, sed, awk, find, xargs).",
146
+ ],
147
+ parameters: Type.Object({
148
+ command: Type.String({ description: "PowerShell command to execute" }),
149
+ cwd: Type.Optional(
150
+ Type.String({ description: "Working directory (defaults to project root)" })
151
+ ),
152
+ timeoutMs: Type.Optional(
153
+ Type.Number({ description: "Timeout in milliseconds (default: 120000)" })
154
+ ),
155
+ maxOutputBytes: Type.Optional(
156
+ Type.Number({ description: "Maximum output bytes (default: 50000)" })
157
+ ),
158
+ maxLines: Type.Optional(
159
+ Type.Number({ description: "Maximum output lines (default: 2000)" })
160
+ ),
161
+ }),
162
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
163
+ try {
164
+ const result = await executePowerShell({
165
+ command: params.command,
166
+ cwd: params.cwd ?? ctx.cwd,
167
+ timeoutMs: params.timeoutMs,
168
+ maxOutputBytes: params.maxOutputBytes,
169
+ maxLines: params.maxLines,
170
+ });
171
+
172
+ const parts: string[] = [];
173
+ parts.push(`Shell: ${result.shell}`);
174
+ parts.push(`CWD: ${result.cwd}`);
175
+ if (result.timedOut) parts.push("[Command timed out]");
176
+ if (result.truncated) parts.push("[Output truncated]");
177
+ parts.push(`Exit code: ${result.exitCode}`);
178
+
179
+ if (result.stdout) {
180
+ parts.push(`\nSTDOUT:\n${result.stdout}`);
181
+ }
182
+ if (result.stderr) {
183
+ parts.push(`\nSTDERR:\n${result.stderr}`);
184
+ }
185
+
186
+ return {
187
+ content: [{ type: "text", text: parts.join("\n") }],
188
+ details: { ...result },
189
+ };
190
+ } catch (error) {
191
+ const msg = error instanceof Error ? error.message : String(error);
192
+ return {
193
+ content: [
194
+ {
195
+ type: "text",
196
+ text: `powershell failed:\nCommand: ${params.command}\nError: ${msg}`,
197
+ },
198
+ ],
199
+ details: { error: msg },
200
+ };
201
+ }
202
+ },
203
+ });
204
+
205
+ // ── win_start_process ───────────────────────────────────────
206
+
207
+ pi.registerTool({
208
+ name: "win_start_process",
209
+ label: "Start Process",
210
+ description:
211
+ "Start a long-running/background Windows process and capture output to a persistent log file. " +
212
+ "Use for dev servers, watchers, long-running scripts — anything that should not block Pi. " +
213
+ "Returns a stable ID, PID, and output file path.",
214
+ promptSnippet: "Start a background Windows process (detached, output logged to file)",
215
+ promptGuidelines: [
216
+ "Use win_start_process for long-running Windows commands or background servers instead of Bash background syntax like `npm run dev &`.",
217
+ "After win_start_process returns OUTPUT_FILE, use win_read_output to inspect logs.",
218
+ "Use win_process_status to check whether the process is still alive.",
219
+ "Use win_stop_process to stop a PID or ID returned by win_start_process.",
220
+ ],
221
+ parameters: Type.Object({
222
+ name: Type.Optional(Type.String({ description: "Human-readable name for the process" })),
223
+ command: Type.String({ description: "PowerShell command to run in background" }),
224
+ cwd: Type.Optional(
225
+ Type.String({ description: "Working directory (defaults to project root)" })
226
+ ),
227
+ outputFile: Type.Optional(
228
+ Type.String({ description: "Custom output file path (default: auto-generated)" })
229
+ ),
230
+ append: Type.Optional(
231
+ Type.Boolean({ description: "Append to output file instead of overwriting (default: true)" })
232
+ ),
233
+ }),
234
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
235
+ try {
236
+ const processName = params.name || params.command.slice(0, 50).replace(/[^a-zA-Z0-9]/g, "_");
237
+ const id = generateProcessId(processName);
238
+ const outputFile =
239
+ params.outputFile || getDefaultOutputFile(processName);
240
+
241
+ await ensureDir(getLogsDir());
242
+
243
+ const { pid, shell } = await startDetachedProcess({
244
+ command: params.command,
245
+ cwd: params.cwd ?? ctx.cwd,
246
+ outputFile,
247
+ append: params.append,
248
+ });
249
+
250
+ const entry: ManagedProcess = {
251
+ id,
252
+ name: processName,
253
+ pid,
254
+ command: params.command,
255
+ cwd: params.cwd ?? ctx.cwd,
256
+ shell,
257
+ outputFile,
258
+ startedAt: new Date().toISOString(),
259
+ status: "running",
260
+ };
261
+
262
+ await addProcess(entry);
263
+
264
+ const text = [
265
+ "Started process.",
266
+ `ID: ${id}`,
267
+ `NAME: ${processName}`,
268
+ `PID: ${pid}`,
269
+ `CWD: ${params.cwd ?? ctx.cwd}`,
270
+ `OUTPUT_FILE: ${outputFile}`,
271
+ `COMMAND: ${params.command}`,
272
+ ].join("\n");
273
+
274
+ return {
275
+ content: [{ type: "text", text }],
276
+ details: { id, pid, outputFile, shell },
277
+ };
278
+ } catch (error) {
279
+ const msg = error instanceof Error ? error.message : String(error);
280
+ return {
281
+ content: [
282
+ {
283
+ type: "text",
284
+ text: `win_start_process failed:\nCommand: ${params.command}\nError: ${msg}`,
285
+ },
286
+ ],
287
+ details: { error: msg },
288
+ };
289
+ }
290
+ },
291
+ });
292
+
293
+ // ── win_process_status ──────────────────────────────────────
294
+
295
+ pi.registerTool({
296
+ name: "win_process_status",
297
+ label: "Process Status",
298
+ description:
299
+ "Check whether a managed process (by registry ID) or arbitrary PID is alive. " +
300
+ "Updates the registry status if checking by ID.",
301
+ promptSnippet: "Check if a background process is still running (by ID or PID)",
302
+ promptGuidelines: [
303
+ "Use win_process_status to check whether a process started by win_start_process is still running.",
304
+ "Provide either id (registry ID from win_start_process) or pid (numeric process ID).",
305
+ ],
306
+ parameters: Type.Object({
307
+ id: Type.Optional(
308
+ Type.String({ description: "Registry ID returned by win_start_process" })
309
+ ),
310
+ pid: Type.Optional(Type.Number({ description: "Raw Windows process ID" })),
311
+ }),
312
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
313
+ try {
314
+ if (!params.id && !params.pid) {
315
+ return {
316
+ content: [
317
+ {
318
+ type: "text",
319
+ text: "win_process_status requires either id or pid parameter.",
320
+ },
321
+ ],
322
+ details: {},
323
+ };
324
+ }
325
+
326
+ let entry: ManagedProcess | null = null;
327
+ let pid: number;
328
+
329
+ if (params.id) {
330
+ entry = await getProcess(params.id);
331
+ if (!entry) {
332
+ return {
333
+ content: [
334
+ { type: "text", text: `No process found with ID: ${params.id}` },
335
+ ],
336
+ details: {},
337
+ };
338
+ }
339
+ pid = entry.pid;
340
+ } else {
341
+ pid = params.pid!;
342
+ }
343
+
344
+ // Check if PID is alive using PowerShell
345
+ const result = await executePowerShell({
346
+ command: `Get-Process -Id ${pid} -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Id`,
347
+ timeoutMs: 10000,
348
+ cwd: ctx.cwd,
349
+ });
350
+
351
+ const alive = result.exitCode === 0 && result.stdout.trim().length > 0;
352
+ const status = alive ? ("running" as const) : ("exited" as const);
353
+
354
+ // Update registry if checking by ID
355
+ if (entry) {
356
+ await updateProcess(entry.id, {
357
+ status,
358
+ lastCheckedAt: new Date().toISOString(),
359
+ });
360
+ }
361
+
362
+ const text = [
363
+ `ID: ${entry?.id ?? "n/a"}`,
364
+ `PID: ${pid}`,
365
+ `STATUS: ${status}`,
366
+ ...(entry
367
+ ? [
368
+ `NAME: ${entry.name}`,
369
+ `COMMAND: ${entry.command}`,
370
+ `OUTPUT_FILE: ${entry.outputFile}`,
371
+ `STARTED_AT: ${entry.startedAt}`,
372
+ ]
373
+ : []),
374
+ ].join("\n");
375
+
376
+ return {
377
+ content: [{ type: "text", text }],
378
+ details: { id: entry?.id, pid, status, alive },
379
+ };
380
+ } catch (error) {
381
+ const msg = error instanceof Error ? error.message : String(error);
382
+ return {
383
+ content: [
384
+ {
385
+ type: "text",
386
+ text: `win_process_status failed:\nError: ${msg}`,
387
+ },
388
+ ],
389
+ details: { error: msg },
390
+ };
391
+ }
392
+ },
393
+ });
394
+
395
+ // ── win_read_output ─────────────────────────────────────────
396
+
397
+ pi.registerTool({
398
+ name: "win_read_output",
399
+ label: "Read Output",
400
+ description:
401
+ "Read output logs from a process started by win_start_process. " +
402
+ "Reads tail lines by default to avoid dumping huge logs. " +
403
+ "Provide either a registry ID or a direct output file path.",
404
+ promptSnippet: "Read tail of a background process log (by ID or file path)",
405
+ promptGuidelines: [
406
+ "Use win_read_output to inspect the log file returned by win_start_process.",
407
+ "Prefer tailLines 100 unless the user asks for more.",
408
+ ],
409
+ parameters: Type.Object({
410
+ id: Type.Optional(
411
+ Type.String({ description: "Registry ID returned by win_start_process" })
412
+ ),
413
+ outputFile: Type.Optional(
414
+ Type.String({ description: "Direct path to the output log file" })
415
+ ),
416
+ tailLines: Type.Optional(
417
+ Type.Number({ description: "Number of tail lines to read (default: 100)" })
418
+ ),
419
+ maxBytes: Type.Optional(
420
+ Type.Number({ description: "Maximum bytes to read (default: 50000)" })
421
+ ),
422
+ }),
423
+ async execute(_toolCallId, params, _signal, _onUpdate) {
424
+ try {
425
+ if (!params.id && !params.outputFile) {
426
+ return {
427
+ content: [
428
+ {
429
+ type: "text",
430
+ text: "win_read_output requires either id or outputFile parameter.",
431
+ },
432
+ ],
433
+ details: {},
434
+ };
435
+ }
436
+
437
+ let outputFile: string;
438
+
439
+ if (params.id) {
440
+ const entry = await getProcess(params.id);
441
+ if (!entry) {
442
+ return {
443
+ content: [
444
+ { type: "text", text: `No process found with ID: ${params.id}` },
445
+ ],
446
+ details: {},
447
+ };
448
+ }
449
+ outputFile = entry.outputFile;
450
+ } else {
451
+ outputFile = params.outputFile!;
452
+ }
453
+
454
+ const tailResult = await tailFile(outputFile, {
455
+ lines: params.tailLines ?? 100,
456
+ maxBytes: params.maxBytes ?? 50000,
457
+ });
458
+
459
+ if (!tailResult.fileExists) {
460
+ return {
461
+ content: [
462
+ {
463
+ type: "text",
464
+ text: `Output file does not exist yet.\nThe process may not have written output.\nFile: ${outputFile}`,
465
+ },
466
+ ],
467
+ details: { outputFile },
468
+ };
469
+ }
470
+
471
+ if (tailResult.lines.length === 0) {
472
+ return {
473
+ content: [
474
+ {
475
+ type: "text",
476
+ text: `Output file exists but is empty (0 bytes).\nThe process may not have written output yet.\nFile: ${outputFile}`,
477
+ },
478
+ ],
479
+ details: { outputFile },
480
+ };
481
+ }
482
+
483
+ const text = [
484
+ `OUTPUT_FILE: ${outputFile}`,
485
+ `LINES_SHOWN: ${tailResult.linesRead}`,
486
+ `TRUNCATED: ${tailResult.truncated}`,
487
+ "",
488
+ tailResult.lines.join("\n"),
489
+ ].join("\n");
490
+
491
+ return {
492
+ content: [{ type: "text", text }],
493
+ details: { outputFile, ...tailResult },
494
+ };
495
+ } catch (error) {
496
+ const msg = error instanceof Error ? error.message : String(error);
497
+ return {
498
+ content: [
499
+ {
500
+ type: "text",
501
+ text: `win_read_output failed:\nError: ${msg}`,
502
+ },
503
+ ],
504
+ details: { error: msg },
505
+ };
506
+ }
507
+ },
508
+ });
509
+
510
+ // ── win_stop_process ────────────────────────────────────────
511
+
512
+ pi.registerTool({
513
+ name: "win_stop_process",
514
+ label: "Stop Process",
515
+ description:
516
+ "Stop a process by registry ID or PID. " +
517
+ "Uses taskkill /T /F by default to kill the full process tree. " +
518
+ "Updates the registry if stopped by ID.",
519
+ promptSnippet: "Stop a background process (by ID or PID, kills process tree by default)",
520
+ promptGuidelines: [
521
+ "Use win_stop_process to stop a PID or ID returned by win_start_process.",
522
+ "Use tree=true (default) for dev servers that may spawn child processes.",
523
+ ],
524
+ parameters: Type.Object({
525
+ id: Type.Optional(
526
+ Type.String({ description: "Registry ID returned by win_start_process" })
527
+ ),
528
+ pid: Type.Optional(Type.Number({ description: "Raw Windows process ID" })),
529
+ force: Type.Optional(Type.Boolean({ description: "Force kill (default: true)" })),
530
+ tree: Type.Optional(Type.Boolean({ description: "Kill process tree (default: true)" })),
531
+ }),
532
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
533
+ try {
534
+ if (!params.id && !params.pid) {
535
+ return {
536
+ content: [
537
+ {
538
+ type: "text",
539
+ text: "win_stop_process requires either id or pid parameter.",
540
+ },
541
+ ],
542
+ details: {},
543
+ };
544
+ }
545
+
546
+ let entry: ManagedProcess | null = null;
547
+ let pid: number;
548
+
549
+ if (params.id) {
550
+ entry = await getProcess(params.id);
551
+ if (!entry) {
552
+ return {
553
+ content: [
554
+ { type: "text", text: `No process found with ID: ${params.id}` },
555
+ ],
556
+ details: {},
557
+ };
558
+ }
559
+ pid = entry.pid;
560
+ } else {
561
+ pid = params.pid!;
562
+ }
563
+
564
+ const force = params.force !== false;
565
+ const tree = params.tree !== false;
566
+
567
+ // Kill the process
568
+ if (tree) {
569
+ const killResult = await executePowerShell({
570
+ command: `taskkill /PID ${pid} /T${force ? " /F" : ""}`,
571
+ timeoutMs: 15000,
572
+ cwd: ctx.cwd,
573
+ });
574
+
575
+ if (killResult.exitCode !== 0 && killResult.exitCode !== 128) {
576
+ // 128 means already exited — that's fine
577
+ const alreadyGone =
578
+ killResult.stderr.includes("not found") ||
579
+ killResult.stderr.includes("no running");
580
+ if (!alreadyGone) {
581
+ const info = [
582
+ `Attempted to kill PID ${pid} (tree=${tree}, force=${force})`,
583
+ `Exit code: ${killResult.exitCode}`,
584
+ `STDERR: ${killResult.stderr}`,
585
+ ].join("\n");
586
+ return {
587
+ content: [{ type: "text", text: info }],
588
+ details: { pid, killed: false, exitCode: killResult.exitCode },
589
+ };
590
+ }
591
+ }
592
+ } else {
593
+ await executePowerShell({
594
+ command: `Stop-Process -Id ${pid}${force ? " -Force" : ""} -ErrorAction SilentlyContinue`,
595
+ timeoutMs: 15000,
596
+ cwd: ctx.cwd,
597
+ });
598
+ }
599
+
600
+ // Update registry
601
+ if (entry) {
602
+ await updateProcess(entry.id, {
603
+ status: "exited",
604
+ lastCheckedAt: new Date().toISOString(),
605
+ });
606
+ }
607
+
608
+ const text = [
609
+ "Stopped process.",
610
+ `ID: ${entry?.id ?? "n/a"}`,
611
+ `PID: ${pid}`,
612
+ `TREE: ${tree}`,
613
+ `FORCE: ${force}`,
614
+ ].join("\n");
615
+
616
+ return {
617
+ content: [{ type: "text", text }],
618
+ details: { id: entry?.id, pid, killed: true, tree, force },
619
+ };
620
+ } catch (error) {
621
+ const msg = error instanceof Error ? error.message : String(error);
622
+ return {
623
+ content: [
624
+ {
625
+ type: "text",
626
+ text: `win_stop_process failed:\nError: ${msg}`,
627
+ },
628
+ ],
629
+ details: { error: msg },
630
+ };
631
+ }
632
+ },
633
+ });
634
+
635
+ // ── win_list_processes ──────────────────────────────────────
636
+
637
+ pi.registerTool({
638
+ name: "win_list_processes",
639
+ label: "List Processes",
640
+ description:
641
+ "List processes tracked by the extension registry. " +
642
+ "Optionally refreshes status by checking each PID.",
643
+ promptSnippet: "List tracked background processes with status",
644
+ promptGuidelines: [
645
+ "Use win_list_processes to see background processes previously started by win_start_process.",
646
+ ],
647
+ parameters: Type.Object({
648
+ includeExited: Type.Optional(
649
+ Type.Boolean({ description: "Include exited processes (default: false)" })
650
+ ),
651
+ refresh: Type.Optional(
652
+ Type.Boolean({ description: "Check each PID live before reporting (default: true)" })
653
+ ),
654
+ }),
655
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
656
+ try {
657
+ const includeExited = params.includeExited ?? false;
658
+ const refresh = params.refresh ?? true;
659
+
660
+ let processes = await getAllProcesses();
661
+
662
+ // Refresh statuses if requested
663
+ if (refresh) {
664
+ for (const proc of processes) {
665
+ try {
666
+ const result = await executePowerShell({
667
+ command: `Get-Process -Id ${proc.pid} -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Id`,
668
+ timeoutMs: 5000,
669
+ cwd: ctx.cwd,
670
+ });
671
+ const alive =
672
+ result.exitCode === 0 && result.stdout.trim().length > 0;
673
+ const newStatus = alive ? ("running" as const) : ("exited" as const);
674
+ if (newStatus !== proc.status) {
675
+ await updateProcess(proc.id, {
676
+ status: newStatus,
677
+ lastCheckedAt: new Date().toISOString(),
678
+ });
679
+ }
680
+ } catch {
681
+ // Skip processes we can't check
682
+ }
683
+ }
684
+ // Reload after updates
685
+ processes = await getAllProcesses();
686
+ }
687
+
688
+ // Filter
689
+ const filtered = includeExited
690
+ ? processes
691
+ : processes.filter((p) => p.status !== "exited");
692
+
693
+ if (filtered.length === 0) {
694
+ return {
695
+ content: [
696
+ {
697
+ type: "text",
698
+ text: "No tracked processes." +
699
+ (includeExited ? " (including exited)" : ""),
700
+ },
701
+ ],
702
+ details: { count: 0 },
703
+ };
704
+ }
705
+
706
+ const header = "ID | NAME | PID | STATUS | STARTED_AT | CWD";
707
+ const separator = "-".repeat(header.length);
708
+ const rows = filtered.map(
709
+ (p) =>
710
+ `${p.id} | ${p.name.slice(0, 20)} | ${p.pid} | ${p.status} | ${p.startedAt.slice(0, 19)} | ${p.cwd.slice(0, 40)}`
711
+ );
712
+ const text = [header, separator, ...rows, "", `Total: ${filtered.length} process(es)`].join("\n");
713
+
714
+ return {
715
+ content: [{ type: "text", text }],
716
+ details: { count: filtered.length, processes: filtered },
717
+ };
718
+ } catch (error) {
719
+ const msg = error instanceof Error ? error.message : String(error);
720
+ return {
721
+ content: [
722
+ {
723
+ type: "text",
724
+ text: `win_list_processes failed:\nError: ${msg}`,
725
+ },
726
+ ],
727
+ details: { error: msg },
728
+ };
729
+ }
730
+ },
731
+ });
732
+
733
+ // ── win_kill_port ───────────────────────────────────────────
734
+
735
+ pi.registerTool({
736
+ name: "win_kill_port",
737
+ label: "Kill Port",
738
+ description:
739
+ "Find and kill Windows processes listening on a TCP port. " +
740
+ "Uses Get-NetTCPConnection, falls back to netstat.",
741
+ promptSnippet: "Kill Windows processes listening on a TCP port",
742
+ promptGuidelines: [
743
+ "Use win_kill_port when a Windows dev server port is stuck or already in use.",
744
+ ],
745
+ parameters: Type.Object({
746
+ port: Type.Number({ description: "TCP port number to free" }),
747
+ force: Type.Optional(Type.Boolean({ description: "Force kill (default: true)" })),
748
+ }),
749
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
750
+ try {
751
+ const port = params.port;
752
+ const force = params.force !== false;
753
+
754
+ // Try Get-NetTCPConnection first (PowerShell 4+)
755
+ let pidsResult = await executePowerShell({
756
+ command: `Get-NetTCPConnection -LocalPort ${port} -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique`,
757
+ timeoutMs: 15000,
758
+ cwd: ctx.cwd,
759
+ });
760
+
761
+ // Fallback to netstat if Get-NetTCPConnection fails
762
+ if (pidsResult.exitCode !== 0 || !pidsResult.stdout.trim()) {
763
+ const netstatResult = await executePowerShell({
764
+ command: `netstat -ano | findstr :${port}`,
765
+ timeoutMs: 15000,
766
+ cwd: ctx.cwd,
767
+ });
768
+
769
+ if (netstatResult.exitCode !== 0 || !netstatResult.stdout.trim()) {
770
+ return {
771
+ content: [
772
+ {
773
+ type: "text",
774
+ text: `No process found listening on port ${port}.`,
775
+ },
776
+ ],
777
+ details: { port, pidsFound: [] },
778
+ };
779
+ }
780
+
781
+ // Parse netstat output to extract PIDs
782
+ const lines = netstatResult.stdout
783
+ .split(/\r?\n/)
784
+ .filter((l) => l.trim());
785
+ const pidSet = new Set<number>();
786
+ for (const line of lines) {
787
+ const match = line.match(/:(\d+)\s+.*\s+(\d+)\s*$/);
788
+ if (match && parseInt(match[1]) === port) {
789
+ pidSet.add(parseInt(match[2]));
790
+ }
791
+ }
792
+ const pids = Array.from(pidSet);
793
+
794
+ if (pids.length === 0) {
795
+ return {
796
+ content: [
797
+ {
798
+ type: "text",
799
+ text: `No process found listening on port ${port}.`,
800
+ },
801
+ ],
802
+ details: { port, pidsFound: [] },
803
+ };
804
+ }
805
+
806
+ // Kill found PIDs
807
+ const killed: number[] = [];
808
+ for (const pid of pids) {
809
+ const killResult = await executePowerShell({
810
+ command: `taskkill /PID ${pid} /T${force ? " /F" : ""}`,
811
+ timeoutMs: 10000,
812
+ cwd: ctx.cwd,
813
+ });
814
+ if (killResult.exitCode === 0 || killResult.exitCode === 128) {
815
+ killed.push(pid);
816
+ }
817
+ }
818
+
819
+ const text = [
820
+ `PORT: ${port}`,
821
+ `PIDS_FOUND: ${pids.join(", ")}`,
822
+ `KILLED: ${killed.length > 0 ? killed.join(", ") : "none"}`,
823
+ ].join("\n");
824
+
825
+ return {
826
+ content: [{ type: "text", text }],
827
+ details: { port, pidsFound: pids, killed },
828
+ };
829
+ }
830
+
831
+ // Parse Get-NetTCPConnection output
832
+ const pids = pidsResult.stdout
833
+ .split(/\r?\n/)
834
+ .map((l) => parseInt(l.trim()))
835
+ .filter((n) => !isNaN(n) && n > 0);
836
+
837
+ if (pids.length === 0) {
838
+ return {
839
+ content: [
840
+ {
841
+ type: "text",
842
+ text: `No process found listening on port ${port}.`,
843
+ },
844
+ ],
845
+ details: { port, pidsFound: [] },
846
+ };
847
+ }
848
+
849
+ // Kill found PIDs
850
+ const killed: number[] = [];
851
+ for (const pid of pids) {
852
+ const killResult = await executePowerShell({
853
+ command: `taskkill /PID ${pid} /T${force ? " /F" : ""}`,
854
+ timeoutMs: 10000,
855
+ cwd: ctx.cwd,
856
+ });
857
+ if (killResult.exitCode === 0 || killResult.exitCode === 128) {
858
+ killed.push(pid);
859
+ }
860
+ }
861
+
862
+ const text = [
863
+ `PORT: ${port}`,
864
+ `PIDS_FOUND: ${pids.join(", ")}`,
865
+ `KILLED: ${killed.length > 0 ? killed.join(", ") : "none"}`,
866
+ ].join("\n");
867
+
868
+ return {
869
+ content: [{ type: "text", text }],
870
+ details: { port, pidsFound: pids, killed },
871
+ };
872
+ } catch (error) {
873
+ const msg = error instanceof Error ? error.message : String(error);
874
+ return {
875
+ content: [
876
+ {
877
+ type: "text",
878
+ text: `win_kill_port failed:\nPort: ${params.port}\nError: ${msg}`,
879
+ },
880
+ ],
881
+ details: { error: msg },
882
+ };
883
+ }
884
+ },
885
+ });
886
+
887
+ // ── win_which ───────────────────────────────────────────────
888
+
889
+ pi.registerTool({
890
+ name: "win_which",
891
+ label: "Which",
892
+ description:
893
+ "Discover commands on Windows using PowerShell Get-Command. " +
894
+ "Returns the source path, version, and command type. " +
895
+ "Falls back to cmd `where` if PowerShell is unavailable.",
896
+ promptSnippet: "Find the location of a Windows command (like which on Unix)",
897
+ promptGuidelines: [
898
+ "Use win_which before assuming a Windows command path.",
899
+ "Use win_which when a command fails with 'not found' or is unavailable.",
900
+ ],
901
+ parameters: Type.Object({
902
+ command: Type.String({ description: "Command name to locate" }),
903
+ }),
904
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
905
+ try {
906
+ const command = params.command;
907
+
908
+ // Try PowerShell Get-Command
909
+ const psResult = await executePowerShell({
910
+ command: `Get-Command ${command} -ErrorAction SilentlyContinue | Format-List Source,Version,CommandType,Name`,
911
+ timeoutMs: 15000,
912
+ cwd: ctx.cwd,
913
+ });
914
+
915
+ if (psResult.exitCode === 0 && psResult.stdout.trim()) {
916
+ // Parse Format-List output
917
+ const lines = psResult.stdout.split(/\r?\n/).filter((l) => l.trim());
918
+ const parsed: Record<string, string> = {};
919
+ for (const line of lines) {
920
+ const match = line.match(/^(\w+)\s*:\s*(.+)$/);
921
+ if (match) {
922
+ parsed[match[1].toLowerCase()] = match[2].trim();
923
+ }
924
+ }
925
+
926
+ const text = [
927
+ `COMMAND: ${command}`,
928
+ `FOUND: true`,
929
+ `SOURCE: ${parsed.source ?? "unknown"}`,
930
+ `VERSION: ${parsed.version ?? "unknown"}`,
931
+ `COMMAND_TYPE: ${parsed.commandtype ?? "unknown"}`,
932
+ ].join("\n");
933
+
934
+ return {
935
+ content: [{ type: "text", text }],
936
+ details: {
937
+ command,
938
+ found: true,
939
+ source: parsed.source,
940
+ version: parsed.version,
941
+ commandType: parsed.commandtype,
942
+ },
943
+ };
944
+ }
945
+
946
+ // Fallback to cmd where
947
+ const cmdResult = await executePowerShell({
948
+ command: `cmd /c "where ${command} 2>nul"`,
949
+ timeoutMs: 10000,
950
+ cwd: ctx.cwd,
951
+ });
952
+
953
+ if (cmdResult.exitCode === 0 && cmdResult.stdout.trim()) {
954
+ const paths = cmdResult.stdout
955
+ .split(/\r?\n/)
956
+ .filter((l) => l.trim());
957
+ const text = [
958
+ `COMMAND: ${command}`,
959
+ `FOUND: true`,
960
+ `SOURCE: ${paths[0]}`,
961
+ `ALL_LOCATIONS:\n${paths.join("\n")}`,
962
+ ].join("\n");
963
+
964
+ return {
965
+ content: [{ type: "text", text }],
966
+ details: { command, found: true, source: paths[0], allPaths: paths },
967
+ };
968
+ }
969
+
970
+ return {
971
+ content: [
972
+ {
973
+ type: "text",
974
+ text: `COMMAND: ${command}\nFOUND: false`,
975
+ },
976
+ ],
977
+ details: { command, found: false },
978
+ };
979
+ } catch (error) {
980
+ const msg = error instanceof Error ? error.message : String(error);
981
+ return {
982
+ content: [
983
+ {
984
+ type: "text",
985
+ text: `win_which failed:\nCommand: ${params.command}\nError: ${msg}`,
986
+ },
987
+ ],
988
+ details: { error: msg },
989
+ };
990
+ }
991
+ },
992
+ });
993
+
994
+ // ── win_cleanup_processes ───────────────────────────────────
995
+
996
+ pi.registerTool({
997
+ name: "win_cleanup_processes",
998
+ label: "Cleanup Processes",
999
+ description:
1000
+ "Clean stale registry entries and optionally delete old log files. " +
1001
+ "Removes exited process records and logs older than the specified days.",
1002
+ promptSnippet: "Remove stale process records and old log files",
1003
+ promptGuidelines: [
1004
+ "Use win_cleanup_processes to remove stale process records and optionally old log files.",
1005
+ ],
1006
+ parameters: Type.Object({
1007
+ removeExited: Type.Optional(
1008
+ Type.Boolean({ description: "Remove exited process entries (default: true)" })
1009
+ ),
1010
+ deleteLogs: Type.Optional(
1011
+ Type.Boolean({ description: "Delete old log files (default: false)" })
1012
+ ),
1013
+ olderThanDays: Type.Optional(
1014
+ Type.Number({ description: "Age threshold in days for log deletion (default: 7)" })
1015
+ ),
1016
+ }),
1017
+ async execute(_toolCallId, params, _signal, _onUpdate) {
1018
+ try {
1019
+ const result = await cleanupRegistry({
1020
+ removeExited: params.removeExited ?? true,
1021
+ deleteLogs: params.deleteLogs ?? false,
1022
+ olderThanDays: params.olderThanDays ?? 7,
1023
+ });
1024
+
1025
+ const text = [
1026
+ "CLEANUP SUMMARY",
1027
+ `Removed registry entries: ${result.removedEntries}`,
1028
+ `Deleted log files: ${result.deletedLogs}`,
1029
+ `Kept running processes: ${result.keptRunning}`,
1030
+ ].join("\n");
1031
+
1032
+ return {
1033
+ content: [{ type: "text", text }],
1034
+ details: result,
1035
+ };
1036
+ } catch (error) {
1037
+ const msg = error instanceof Error ? error.message : String(error);
1038
+ return {
1039
+ content: [
1040
+ {
1041
+ type: "text",
1042
+ text: `win_cleanup_processes failed:\nError: ${msg}`,
1043
+ },
1044
+ ],
1045
+ details: { error: msg },
1046
+ };
1047
+ }
1048
+ },
1049
+ });
1050
+ }