pi-interactive-shell 0.5.0 → 0.5.2

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 CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  All notable changes to the `pi-interactive-shell` extension will be documented in this file.
4
4
 
5
+ ## [0.5.2] - 2026-01-23
6
+
7
+ ### Fixed
8
+ - **npx installation missing files** - The install script had a hardcoded file list that was missing 4 critical files (`key-encoding.ts`, `types.ts`, `tool-schema.ts`, `reattach-overlay.ts`). Now reads from `package.json`'s `files` array as the single source of truth, ensuring all files are always copied.
9
+ - **Broken symlink handling** - Fixed skill symlink creation failing when a broken symlink already existed at the target path. `existsSync()` returns `false` for broken symlinks, causing the old code to skip removal. Now unconditionally attempts removal, correctly handling broken symlinks.
10
+
11
+ ## [0.5.1] - 2026-01-22
12
+
13
+ ### Fixed
14
+ - **Prevent overlay stacking** - Starting a new `interactive_shell` session or using `/attach` while an overlay is already open now returns an error instead of causing undefined behavior with stacked/stuck overlays.
15
+
5
16
  ## [0.5.0] - 2026-01-22
6
17
 
7
18
  ### Changed
package/index.ts CHANGED
@@ -8,6 +8,9 @@ import { translateInput } from "./key-encoding.js";
8
8
  import { TOOL_NAME, TOOL_LABEL, TOOL_DESCRIPTION, toolParameters, type ToolParams } from "./tool-schema.js";
9
9
  import { formatDuration, formatDurationMs } from "./types.js";
10
10
 
11
+ // Track whether an overlay is currently open to prevent stacking
12
+ let overlayOpen = false;
13
+
11
14
  export default function interactiveShellExtension(pi: ExtensionAPI) {
12
15
  pi.on("session_shutdown", () => {
13
16
  sessionManager.killAll();
@@ -370,12 +373,24 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
370
373
  const config = loadConfig(effectiveCwd);
371
374
  const isHandsFree = mode === "hands-free";
372
375
 
376
+ // Prevent starting a new overlay while one is already open
377
+ if (overlayOpen) {
378
+ return {
379
+ content: [{ type: "text", text: "An interactive shell overlay is already open. Wait for it to close or kill the active session before starting a new one." }],
380
+ isError: true,
381
+ details: { error: "overlay_already_open" },
382
+ };
383
+ }
384
+
373
385
  // Generate sessionId early so it's available immediately
374
386
  const generatedSessionId = isHandsFree ? generateSessionId(name) : undefined;
375
387
 
376
388
  // For hands-free mode: non-blocking - return immediately with sessionId
377
389
  // Agent can then query status/output via sessionId and kill when done
378
390
  if (isHandsFree && generatedSessionId) {
391
+ // Mark overlay as open
392
+ overlayOpen = true;
393
+
379
394
  // Start overlay but don't await - it runs in background
380
395
  const overlayPromise = ctx.ui.custom<InteractiveShellResult>(
381
396
  (tui, theme, _kb, done) =>
@@ -421,12 +436,14 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
421
436
 
422
437
  // Handle overlay completion in background (cleanup when user closes)
423
438
  overlayPromise.then((result) => {
439
+ overlayOpen = false;
424
440
  // Session already handles cleanup via finishWith* methods
425
441
  // This just ensures the promise doesn't cause unhandled rejection
426
442
  if (result.userTookOver) {
427
443
  // User took over - session continues interactively
428
444
  }
429
445
  }).catch(() => {
446
+ overlayOpen = false;
430
447
  // Ignore errors - session cleanup handles this
431
448
  });
432
449
 
@@ -448,6 +465,7 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
448
465
  }
449
466
 
450
467
  // Interactive mode: blocking - wait for overlay to close
468
+ overlayOpen = true;
451
469
  onUpdate?.({
452
470
  content: [{ type: "text", text: `Opening: ${command}` }],
453
471
  details: {
@@ -457,7 +475,9 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
457
475
  },
458
476
  });
459
477
 
460
- const result = await ctx.ui.custom<InteractiveShellResult>(
478
+ let result: InteractiveShellResult;
479
+ try {
480
+ result = await ctx.ui.custom<InteractiveShellResult>(
461
481
  (tui, theme, _kb, done) =>
462
482
  new InteractiveShellOverlay(
463
483
  tui,
@@ -532,6 +552,9 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
532
552
  },
533
553
  },
534
554
  );
555
+ } finally {
556
+ overlayOpen = false;
557
+ }
535
558
 
536
559
  let summary: string;
537
560
  if (result.backgrounded) {
@@ -569,6 +592,12 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
569
592
  pi.registerCommand("attach", {
570
593
  description: "Reattach to a background shell session",
571
594
  handler: async (args, ctx) => {
595
+ // Prevent reattaching while another overlay is open
596
+ if (overlayOpen) {
597
+ ctx.ui.notify("An overlay is already open. Close it first.", "error");
598
+ return;
599
+ }
600
+
572
601
  const sessions = sessionManager.list();
573
602
 
574
603
  if (sessions.length === 0) {
@@ -601,25 +630,30 @@ export default function interactiveShellExtension(pi: ExtensionAPI) {
601
630
  }
602
631
 
603
632
  const config = loadConfig(ctx.cwd);
604
- await ctx.ui.custom<InteractiveShellResult>(
605
- (tui, theme, _kb, done) =>
606
- new ReattachOverlay(
607
- tui,
608
- theme,
609
- { id: session.id, command: session.command, reason: session.reason, session: session.session },
610
- config,
611
- done,
612
- ),
613
- {
614
- overlay: true,
615
- overlayOptions: {
616
- width: `${config.overlayWidthPercent}%`,
617
- maxHeight: `${config.overlayHeightPercent}%`,
618
- anchor: "center",
619
- margin: 1,
633
+ overlayOpen = true;
634
+ try {
635
+ await ctx.ui.custom<InteractiveShellResult>(
636
+ (tui, theme, _kb, done) =>
637
+ new ReattachOverlay(
638
+ tui,
639
+ theme,
640
+ { id: session.id, command: session.command, reason: session.reason, session: session.session },
641
+ config,
642
+ done,
643
+ ),
644
+ {
645
+ overlay: true,
646
+ overlayOptions: {
647
+ width: `${config.overlayWidthPercent}%`,
648
+ maxHeight: `${config.overlayHeightPercent}%`,
649
+ anchor: "center",
650
+ margin: 1,
651
+ },
620
652
  },
621
- },
622
- );
653
+ );
654
+ } finally {
655
+ overlayOpen = false;
656
+ }
623
657
  },
624
658
  });
625
659
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-interactive-shell",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Run AI coding agents as foreground subagents in pi TUI overlays with hands-free monitoring",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { existsSync, mkdirSync, cpSync, symlinkSync, unlinkSync, readFileSync } from "node:fs";
3
+ import { existsSync, mkdirSync, cpSync, symlinkSync, unlinkSync, readFileSync, statSync } from "node:fs";
4
4
  import { join, dirname } from "node:path";
5
5
  import { homedir } from "node:os";
6
6
  import { execSync } from "node:child_process";
@@ -24,36 +24,34 @@ function main() {
24
24
  log(`Creating ${EXTENSION_DIR}`);
25
25
  mkdirSync(EXTENSION_DIR, { recursive: true });
26
26
 
27
- // Files to copy
28
- const files = [
29
- "package.json",
30
- "index.ts",
31
- "config.ts",
32
- "overlay-component.ts",
33
- "pty-session.ts",
34
- "session-manager.ts",
35
- "README.md",
36
- "SKILL.md",
37
- "CHANGELOG.md",
38
- ];
27
+ // Read files list from package.json (single source of truth)
28
+ // Include package.json itself (npm auto-includes it but it's not in the files array)
29
+ const files = ["package.json", ...(pkg.files || [])];
39
30
 
40
- // Copy files
41
- for (const file of files) {
42
- const src = join(packageRoot, file);
43
- const dest = join(EXTENSION_DIR, file);
44
- if (existsSync(src)) {
45
- cpSync(src, dest);
46
- log(`Copied ${file}`);
31
+ // Copy files and directories
32
+ for (const rawEntry of files) {
33
+ // Normalize: remove trailing slashes for consistent handling
34
+ const entry = rawEntry.replace(/\/+$/, "");
35
+ const src = join(packageRoot, entry);
36
+ const dest = join(EXTENSION_DIR, entry);
37
+
38
+ if (!existsSync(src)) {
39
+ continue;
47
40
  }
48
- }
49
41
 
50
- // Copy scripts directory
51
- const scriptsDir = join(packageRoot, "scripts");
52
- const destScriptsDir = join(EXTENSION_DIR, "scripts");
53
- if (existsSync(scriptsDir)) {
54
- mkdirSync(destScriptsDir, { recursive: true });
55
- cpSync(scriptsDir, destScriptsDir, { recursive: true });
56
- log("Copied scripts/");
42
+ try {
43
+ const stat = statSync(src);
44
+ if (stat.isDirectory()) {
45
+ mkdirSync(dest, { recursive: true });
46
+ cpSync(src, dest, { recursive: true });
47
+ log(`Copied ${entry}/`);
48
+ } else {
49
+ cpSync(src, dest);
50
+ log(`Copied ${entry}`);
51
+ }
52
+ } catch (error) {
53
+ log(`Warning: Could not copy ${entry}: ${error.message}`);
54
+ }
57
55
  }
58
56
 
59
57
  // Run npm install in extension directory
@@ -72,8 +70,12 @@ function main() {
72
70
  const skillTarget = join(EXTENSION_DIR, "SKILL.md");
73
71
 
74
72
  try {
75
- if (existsSync(skillLink)) {
73
+ // Remove existing entry if present (handles regular files, symlinks, and broken symlinks)
74
+ // Note: existsSync returns false for broken symlinks, so we unconditionally try unlink
75
+ try {
76
76
  unlinkSync(skillLink);
77
+ } catch (e) {
78
+ if (e.code !== "ENOENT") throw e;
77
79
  }
78
80
  symlinkSync(skillTarget, skillLink);
79
81
  log("Skill symlink created");