taskplane 0.1.7 → 0.1.9

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.
@@ -480,6 +480,12 @@ export async function pollUntilTaskComplete(
480
480
 
481
481
  let lastPaneTail = "";
482
482
 
483
+ // Abort signal file path — checked each poll cycle.
484
+ // Any process can create this file to trigger abort (belt-and-suspenders
485
+ // alongside the in-memory pauseSignal, since /orch-abort may not be able
486
+ // to run concurrently with the /orch command handler).
487
+ const abortSignalFile = join(repoRoot, ".pi", "orch-abort-signal");
488
+
483
489
  // Main polling loop
484
490
  while (true) {
485
491
  // Check pause signal
@@ -494,6 +500,20 @@ export async function pollUntilTaskComplete(
494
500
  };
495
501
  }
496
502
 
503
+ // Check file-based abort signal
504
+ if (existsSync(abortSignalFile)) {
505
+ execLog(laneId, task.taskId, "abort signal file detected — killing session and aborting");
506
+ tmuxKillSession(sessionName);
507
+ // Also kill child sessions (worker, reviewer)
508
+ tmuxKillSession(`${sessionName}-worker`);
509
+ tmuxKillSession(`${sessionName}-reviewer`);
510
+ return {
511
+ status: "failed",
512
+ exitReason: "Aborted by signal file (.pi/orch-abort-signal)",
513
+ doneFileFound: false,
514
+ };
515
+ }
516
+
497
517
  // Capture live pane output for diagnostics (best effort).
498
518
  const paneTail = captureTmuxPaneTail(sessionName);
499
519
  if (paneTail) {
@@ -1,6 +1,8 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
2
2
 
3
3
  import { execSync } from "child_process";
4
+ import { writeFileSync, unlinkSync, mkdirSync } from "fs";
5
+ import { join } from "path";
4
6
 
5
7
  import {
6
8
  DEFAULT_ORCHESTRATOR_CONFIG,
@@ -10,7 +12,6 @@ import {
10
12
  createOrchWidget,
11
13
  deleteBatchState,
12
14
  detectOrphanSessions,
13
- executeAbort,
14
15
  executeLane,
15
16
  executeOrchBatch,
16
17
  formatDependencyGraph,
@@ -347,113 +348,144 @@ export default function (pi: ExtensionAPI) {
347
348
  pi.registerCommand("orch-abort", {
348
349
  description: "Abort batch: /orch-abort [--hard]",
349
350
  handler: async (args, ctx) => {
350
- const hard = args?.trim() === "--hard";
351
- const mode: AbortMode = hard ? "hard" : "graceful";
352
- const prefix = orchConfig.orchestrator.tmux_prefix;
353
- const gracePeriodMs = orchConfig.orchestrator.abort_grace_period * 1000;
354
-
355
- // Check for active in-memory batch
356
- const hasActiveBatch = orchBatchState.phase !== "idle" &&
357
- orchBatchState.phase !== "completed" &&
358
- orchBatchState.phase !== "failed" &&
359
- orchBatchState.phase !== "stopped";
360
-
361
- // Also check for persisted state (abort can work on orphaned batches too)
362
- let persistedState: PersistedBatchState | null = null;
363
351
  try {
364
- persistedState = loadBatchState(ctx.cwd);
365
- } catch {
366
- // Ignore we may still have in-memory state or orphan sessions
367
- }
352
+ const hard = args?.trim() === "--hard";
353
+ const mode: AbortMode = hard ? "hard" : "graceful";
354
+ const prefix = orchConfig.orchestrator.tmux_prefix;
355
+ const gracePeriodMs = orchConfig.orchestrator.abort_grace_period * 1000;
356
+
357
+ ctx.ui.notify(`🛑 Abort requested (${mode} mode, prefix: ${prefix})...`, "info");
358
+
359
+ // ── Step 1: Write abort signal file immediately ──────────
360
+ // This is the primary abort mechanism. The orchestrator's polling
361
+ // loop checks for this file on every cycle, so even if this command
362
+ // handler runs concurrently with /orch (or is queued behind it),
363
+ // the signal file will be detected.
364
+ const abortSignalFile = join(ctx.cwd, ".pi", "orch-abort-signal");
365
+ try {
366
+ mkdirSync(join(ctx.cwd, ".pi"), { recursive: true });
367
+ writeFileSync(abortSignalFile, `abort requested at ${new Date().toISOString()} (mode: ${mode})`, "utf-8");
368
+ ctx.ui.notify(" ✓ Abort signal file written (.pi/orch-abort-signal)", "info");
369
+ } catch (err) {
370
+ ctx.ui.notify(` ⚠ Failed to write abort signal file: ${err instanceof Error ? err.message : String(err)}`, "warning");
371
+ }
368
372
 
369
- // If no in-memory batch AND no persisted state, check for orphan sessions
370
- if (!hasActiveBatch && !persistedState) {
371
- // Last chance: check for orphan sessions
372
- const sessionNames = parseOrchSessionNames(
373
- (() => {
374
- try {
375
- return execSync('tmux list-sessions -F "#{session_name}"', {
376
- encoding: "utf-8",
377
- timeout: 5000,
378
- });
379
- } catch {
380
- return "";
381
- }
382
- })(),
383
- prefix,
384
- );
385
- if (sessionNames.length === 0) {
386
- ctx.ui.notify(ORCH_MESSAGES.abortNoBatch(), "warning");
387
- return;
373
+ // ── Step 2: Set pause signal immediately ─────────────────
374
+ // Belt-and-suspenders: if the /orch polling loop can see this
375
+ // shared object, it will stop on the next iteration.
376
+ if (orchBatchState.pauseSignal) {
377
+ orchBatchState.pauseSignal.paused = true;
378
+ ctx.ui.notify(" ✓ Pause signal set on in-memory batch state", "info");
388
379
  }
389
- // If orphan sessions exist, proceed with abort (will kill them)
390
- }
391
380
 
392
- const batchId = orchBatchState.batchId || persistedState?.batchId || "unknown";
381
+ // ── Step 3: Check what we're aborting ────────────────────
382
+ const hasActiveBatch = orchBatchState.phase !== "idle" &&
383
+ orchBatchState.phase !== "completed" &&
384
+ orchBatchState.phase !== "failed" &&
385
+ orchBatchState.phase !== "stopped";
386
+
387
+ let persistedState: PersistedBatchState | null = null;
388
+ try {
389
+ persistedState = loadBatchState(ctx.cwd);
390
+ } catch {
391
+ // Ignore — we may still have in-memory state or orphan sessions
392
+ }
393
393
 
394
- // Notify user of abort start
395
- if (mode === "graceful") {
396
- const sessionCount = orchBatchState.currentLanes.length || persistedState?.tasks.length || 0;
397
- ctx.ui.notify(ORCH_MESSAGES.abortGracefulStarting(batchId, sessionCount), "info");
398
394
  ctx.ui.notify(
399
- ORCH_MESSAGES.abortGracefulWaiting(batchId, orchConfig.orchestrator.abort_grace_period),
395
+ ` Batch state: in-memory=${hasActiveBatch ? orchBatchState.phase : "none"}, ` +
396
+ `persisted=${persistedState ? persistedState.batchId : "none"}`,
400
397
  "info",
401
398
  );
402
- } else {
403
- const sessionCount = orchBatchState.currentLanes.length || persistedState?.tasks.length || 0;
404
- ctx.ui.notify(ORCH_MESSAGES.abortHardStarting(batchId, sessionCount), "info");
405
- }
406
399
 
407
- // Execute abort
408
- const result = await executeAbort(
409
- mode,
410
- prefix,
411
- ctx.cwd,
412
- orchBatchState,
413
- persistedState,
414
- gracePeriodMs,
415
- );
400
+ // ── Step 4: Scan for tmux sessions ──────────────────────
401
+ let allSessionNames: string[] = [];
402
+ try {
403
+ const tmuxOutput = execSync('tmux list-sessions -F "#{session_name}"', {
404
+ encoding: "utf-8",
405
+ timeout: 5000,
406
+ }).trim();
407
+ const all = tmuxOutput ? tmuxOutput.split("\n").map(s => s.trim()).filter(Boolean) : [];
408
+ allSessionNames = all.filter(name => name.startsWith(`${prefix}-`));
409
+ ctx.ui.notify(` Found ${allSessionNames.length} session(s) matching prefix "${prefix}-": ${allSessionNames.join(", ") || "(none)"}`, "info");
410
+ } catch {
411
+ ctx.ui.notify(" ⚠ Could not list tmux sessions (tmux not available?)", "warning");
412
+ }
416
413
 
417
- // Update in-memory batch state
418
- orchBatchState.phase = "stopped";
419
- orchBatchState.endedAt = result.durationMs + Date.now() - result.durationMs; // Use actual time
420
- updateOrchWidget();
414
+ // If no batch AND no sessions, nothing to abort
415
+ if (!hasActiveBatch && !persistedState && allSessionNames.length === 0) {
416
+ ctx.ui.notify(ORCH_MESSAGES.abortNoBatch(), "warning");
417
+ // Clean up signal file
418
+ try { unlinkSync(abortSignalFile); } catch {}
419
+ return;
420
+ }
421
+
422
+ const batchId = orchBatchState.batchId || persistedState?.batchId || "unknown";
421
423
 
422
- // Notify results
423
- const durationSec = Math.round(result.durationMs / 1000);
424
- if (mode === "graceful") {
425
- const forceKilled = result.sessionsKilled - result.gracefulExits;
426
- if (forceKilled > 0) {
427
- ctx.ui.notify(
428
- ORCH_MESSAGES.abortGracefulForceKill(forceKilled),
429
- "warning",
430
- );
424
+ // ── Step 5: Kill sessions directly (fast path) ──────────
425
+ // For hard mode or when sessions are found, kill them immediately
426
+ // rather than waiting through the full executeAbort flow.
427
+ if (allSessionNames.length > 0) {
428
+ ctx.ui.notify(` Killing ${allSessionNames.length} tmux session(s)...`, "info");
429
+ let killed = 0;
430
+ for (const name of allSessionNames) {
431
+ try {
432
+ // Kill child sessions first (worker, reviewer)
433
+ execSync(`tmux kill-session -t "${name}-worker" 2>/dev/null`, { timeout: 3000 }).toString();
434
+ } catch {}
435
+ try {
436
+ execSync(`tmux kill-session -t "${name}-reviewer" 2>/dev/null`, { timeout: 3000 }).toString();
437
+ } catch {}
438
+ try {
439
+ execSync(`tmux kill-session -t "${name}" 2>/dev/null`, { timeout: 3000 }).toString();
440
+ killed++;
441
+ ctx.ui.notify(` ✓ Killed: ${name}`, "info");
442
+ } catch {
443
+ // Session may have already exited
444
+ ctx.ui.notify(` · ${name} (already exited)`, "info");
445
+ killed++;
446
+ }
447
+ }
448
+ ctx.ui.notify(` ✓ ${killed}/${allSessionNames.length} session(s) terminated`, "info");
449
+ } else {
450
+ ctx.ui.notify(" No tmux sessions to kill", "info");
431
451
  }
452
+
453
+ // ── Step 6: Clean up batch state ────────────────────────
454
+ try {
455
+ orchBatchState.phase = "stopped";
456
+ orchBatchState.endedAt = Date.now();
457
+ updateOrchWidget();
458
+ ctx.ui.notify(" ✓ In-memory batch state set to 'stopped'", "info");
459
+ } catch (err) {
460
+ ctx.ui.notify(` ⚠ Failed to update in-memory state: ${err instanceof Error ? err.message : String(err)}`, "warning");
461
+ }
462
+
463
+ try {
464
+ deleteBatchState(ctx.cwd);
465
+ ctx.ui.notify(" ✓ Batch state file deleted (.pi/batch-state.json)", "info");
466
+ } catch (err) {
467
+ ctx.ui.notify(` ⚠ Failed to delete batch state file: ${err instanceof Error ? err.message : String(err)}`, "warning");
468
+ }
469
+
470
+ // ── Step 7: Clean up abort signal file ───────────────────
471
+ try { unlinkSync(abortSignalFile); } catch {}
472
+
473
+ // ── Done ─────────────────────────────────────────────────
432
474
  ctx.ui.notify(
433
- ORCH_MESSAGES.abortGracefulComplete(batchId, result.gracefulExits, forceKilled, durationSec),
434
- "info",
435
- );
436
- } else {
437
- ctx.ui.notify(
438
- ORCH_MESSAGES.abortHardComplete(batchId, result.sessionsKilled, durationSec),
475
+ `✅ Abort complete for batch ${batchId}. Sessions killed, state cleaned up.\n` +
476
+ ` Worktrees and branches are preserved for inspection.`,
439
477
  "info",
440
478
  );
441
- }
442
-
443
- // Report errors if any
444
- if (result.errors.length > 0) {
445
- const errorDetails = result.errors.map(e => ` • [${e.code}] ${e.message}`).join("\n");
479
+ } catch (err) {
480
+ // Top-level catch: ensure the user ALWAYS sees something
446
481
  ctx.ui.notify(
447
- `${ORCH_MESSAGES.abortPartialFailure(result.errors.length)}\n${errorDetails}`,
448
- "warning",
482
+ `❌ Abort failed with error: ${err instanceof Error ? err.message : String(err)}\n` +
483
+ ` Stack: ${err instanceof Error ? err.stack : "N/A"}\n\n` +
484
+ ` Manual cleanup: tmux kill-server (kills ALL tmux sessions)\n` +
485
+ ` Or: tmux kill-session -t <session-name> for each session`,
486
+ "error",
449
487
  );
450
488
  }
451
-
452
- // Final message
453
- ctx.ui.notify(
454
- ORCH_MESSAGES.abortComplete(mode, result.sessionsKilled),
455
- "info",
456
- );
457
489
  },
458
490
  });
459
491
 
@@ -572,6 +604,90 @@ export default function (pi: ExtensionAPI) {
572
604
  "/orch-sessions List TMUX sessions",
573
605
  "info",
574
606
  );
607
+
608
+ // Check for taskplane updates (non-blocking)
609
+ checkForUpdate(ctx);
575
610
  });
576
611
  }
577
612
 
613
+ // ── Update Check ─────────────────────────────────────────────────────
614
+
615
+ /**
616
+ * Check npm registry for a newer version of taskplane.
617
+ *
618
+ * Runs asynchronously and never throws — update check failures are
619
+ * silently ignored so they don't interfere with normal operation.
620
+ */
621
+ async function checkForUpdate(ctx: ExtensionContext): Promise<void> {
622
+ try {
623
+ // Get installed version from our own package.json
624
+ const { readFileSync: readFS } = await import("fs");
625
+ const { dirname, join: joinPath } = await import("path");
626
+ const { fileURLToPath } = await import("url");
627
+
628
+ // Resolve package.json relative to this extension file.
629
+ // In npm install layout: node_modules/taskplane/extensions/taskplane/extension.ts
630
+ // package.json is at: node_modules/taskplane/package.json
631
+ let pkgJsonPath: string;
632
+ try {
633
+ const thisDir = dirname(fileURLToPath(import.meta.url));
634
+ pkgJsonPath = joinPath(thisDir, "..", "..", "package.json");
635
+ } catch {
636
+ // Fallback for environments where import.meta.url is unavailable
637
+ pkgJsonPath = joinPath(__dirname, "..", "..", "package.json");
638
+ }
639
+
640
+ let installedVersion: string;
641
+ try {
642
+ const pkg = JSON.parse(readFS(pkgJsonPath, "utf-8"));
643
+ installedVersion = pkg.version;
644
+ } catch {
645
+ return; // Can't determine installed version — skip check
646
+ }
647
+
648
+ // Fetch latest version from npm registry (5s timeout)
649
+ const controller = new AbortController();
650
+ const timeout = setTimeout(() => controller.abort(), 5000);
651
+
652
+ const response = await fetch("https://registry.npmjs.org/taskplane/latest", {
653
+ signal: controller.signal,
654
+ headers: { "Accept": "application/json" },
655
+ });
656
+ clearTimeout(timeout);
657
+
658
+ if (!response.ok) return;
659
+
660
+ const data = await response.json() as { version?: string };
661
+ const latestVersion = data.version;
662
+ if (!latestVersion) return;
663
+
664
+ // Compare versions (simple semver comparison)
665
+ if (latestVersion !== installedVersion && isNewerVersion(latestVersion, installedVersion)) {
666
+ ctx.ui.notify(
667
+ `\n` +
668
+ ` Update Available\n` +
669
+ ` New version ${latestVersion} is available (installed: ${installedVersion}).\n` +
670
+ ` Run: npm install -g taskplane\n`,
671
+ "info",
672
+ );
673
+ }
674
+ } catch {
675
+ // Silently ignore — network errors, offline, etc.
676
+ }
677
+ }
678
+
679
+ /**
680
+ * Compare two semver version strings. Returns true if `a` is newer than `b`.
681
+ */
682
+ function isNewerVersion(a: string, b: string): boolean {
683
+ const pa = a.split(".").map(Number);
684
+ const pb = b.split(".").map(Number);
685
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
686
+ const na = pa[i] || 0;
687
+ const nb = pb[i] || 0;
688
+ if (na > nb) return true;
689
+ if (na < nb) return false;
690
+ }
691
+ return false;
692
+ }
693
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskplane",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",