taskplane 0.20.5 → 0.21.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.
@@ -331,6 +331,8 @@ function createSessionState() {
331
331
  currentTool: null,
332
332
  error: null,
333
333
  agentEnded: false,
334
+ /** Authoritative context usage from pi get_session_stats (null if unavailable) */
335
+ contextUsage: null,
334
336
  };
335
337
  }
336
338
 
@@ -414,6 +416,10 @@ function applyEvent(state, event) {
414
416
  if (event.success === false && event.error) {
415
417
  state.error = event.error;
416
418
  }
419
+ // get_session_stats response — extract authoritative contextUsage
420
+ if (event.success === true && event.data?.contextUsage) {
421
+ state.contextUsage = event.data.contextUsage;
422
+ }
417
423
  break;
418
424
  }
419
425
 
@@ -455,6 +461,8 @@ function buildExitSummary(state, exitCode, exitSignal, errorOverride, startTime)
455
461
  durationSec,
456
462
  lastToolCall: state.lastToolCall,
457
463
  error: finalError,
464
+ // Authoritative context usage from pi ≥ 0.63.0 (null if unavailable)
465
+ contextUsage: state.contextUsage || null,
458
466
  };
459
467
 
460
468
  return redactSummary(rawSummary);
@@ -614,6 +622,22 @@ function closeStdin() {
614
622
  }
615
623
  }
616
624
 
625
+ /**
626
+ * Query pi for authoritative session stats including contextUsage.
627
+ * Available in pi ≥ 0.63.0 (RPC get_session_stats exposes contextUsage).
628
+ * Safe to call on older versions — the command is ignored or returns
629
+ * without the field, and state.contextUsage stays null.
630
+ */
631
+ function querySessionStats() {
632
+ try {
633
+ if (proc.stdin && !proc.stdin.destroyed) {
634
+ proc.stdin.write(JSON.stringify({ type: "get_session_stats" }) + "\n");
635
+ }
636
+ } catch {
637
+ // stdin may be closed — ignore
638
+ }
639
+ }
640
+
617
641
  // ── Route RPC events ─────────────────────────────────────────────────
618
642
 
619
643
  function handleEvent(event) {
@@ -628,6 +652,13 @@ function handleEvent(event) {
628
652
  // Side effects that depend on the event type (IO, stdin lifecycle, display)
629
653
  switch (event.type) {
630
654
  case "message_end":
655
+ displayProgress(state);
656
+ // Query pi for authoritative context usage (pi ≥ 0.63.0).
657
+ // Falls back gracefully: older pi versions ignore the command
658
+ // or return a response without contextUsage — state.contextUsage stays null.
659
+ querySessionStats();
660
+ break;
661
+
631
662
  case "tool_execution_start":
632
663
  displayProgress(state);
633
664
  break;
package/bin/taskplane.mjs CHANGED
@@ -1193,6 +1193,15 @@ async function cmdInit(args) {
1193
1193
  { skipIfExists: !force, label: ".pi/taskplane-workspace.yaml" },
1194
1194
  );
1195
1195
 
1196
+ // ── Gitignore enforcement in config repo (Scenario D) ───
1197
+ // Ensure .gitignore exists even when reusing existing config
1198
+ const gitignoreResult = ensureGitignoreEntries(configRepoRoot, { dryRun: false, prefix: ".taskplane/" });
1199
+ if (gitignoreResult.created) {
1200
+ console.log(` ${c.green}create${c.reset} ${configRepo}/.gitignore`);
1201
+ } else if (gitignoreResult.added.length > 0) {
1202
+ console.log(` ${c.green}update${c.reset} ${configRepo}/.gitignore (${gitignoreResult.added.length} entries added)`);
1203
+ }
1204
+
1196
1205
  console.log(`\n${OK} ${c.bold}Workspace pointer created.${c.reset}\n`);
1197
1206
  console.log(` Config: ${c.cyan}${configRepo}/.taskplane/${c.reset}`);
1198
1207
  console.log(` Pointer: ${c.cyan}.pi/taskplane-pointer.json${c.reset}`);
@@ -2510,18 +2519,20 @@ function cmdDoctor() {
2510
2519
  console.log();
2511
2520
  const hasUnifiedJson = fs.existsSync(path.join(configLocation.root, configLocation.prefix, "taskplane-config.json"));
2512
2521
  const configFiles = [
2513
- { path: "taskplane-config.json", required: false },
2514
- { path: "task-runner.yaml", required: !hasUnifiedJson },
2515
- { path: "task-orchestrator.yaml", required: !hasUnifiedJson },
2516
- { path: "agents/task-worker.md", required: true },
2517
- { path: "agents/task-reviewer.md", required: true },
2518
- { path: "agents/task-merger.md", required: true },
2519
- { path: "agents/supervisor.md", required: false },
2520
- { path: "taskplane.json", required: false },
2522
+ { path: "taskplane-config.json", required: false, hide: false },
2523
+ // YAML configs are legacy fallback — hide when taskplane-config.json exists
2524
+ { path: "task-runner.yaml", required: !hasUnifiedJson, hide: hasUnifiedJson },
2525
+ { path: "task-orchestrator.yaml", required: !hasUnifiedJson, hide: hasUnifiedJson },
2526
+ { path: "agents/task-worker.md", required: true, hide: false },
2527
+ { path: "agents/task-reviewer.md", required: true, hide: false },
2528
+ { path: "agents/task-merger.md", required: true, hide: false },
2529
+ // supervisor.md is created by /orch; taskplane.json is created at runtime
2530
+ { path: "agents/supervisor.md", required: false, hide: true },
2531
+ { path: "taskplane.json", required: false, hide: true },
2521
2532
  ];
2522
2533
 
2523
2534
  let missingRequiredConfigs = 0;
2524
- for (const { path: relPath, required } of configFiles) {
2535
+ for (const { path: relPath, required, hide } of configFiles) {
2525
2536
  const fullPath = path.join(configLocation.root, configLocation.prefix, relPath);
2526
2537
  const displayPath = `${configLocation.label}/${relPath}`;
2527
2538
  const exists = fs.existsSync(fullPath);
@@ -2531,7 +2542,8 @@ function cmdDoctor() {
2531
2542
  console.log(` ${FAIL} ${displayPath} missing`);
2532
2543
  missingRequiredConfigs++;
2533
2544
  issues++;
2534
- } else {
2545
+ } else if (!hide) {
2546
+ // Show optional files only when they're relevant (not superseded)
2535
2547
  console.log(` ${WARN} ${displayPath} missing ${c.dim}(optional)${c.reset}`);
2536
2548
  }
2537
2549
  }
@@ -1367,6 +1367,8 @@ interface SidecarTelemetryDelta {
1367
1367
  lastRetryError: string;
1368
1368
  /** Whether any sidecar events were parsed in this tick (used for callback gating) */
1369
1369
  hadEvents: boolean;
1370
+ /** Authoritative context usage from pi get_session_stats (pi ≥ 0.63.0, null if unavailable) */
1371
+ contextUsage: { percentUsed: number; totalTokens: number; maxTokens: number } | null;
1370
1372
  }
1371
1373
 
1372
1374
  /**
@@ -1385,7 +1387,7 @@ function tailSidecarJsonl(filePath: string, tailState: SidecarTailState): Sideca
1385
1387
  inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
1386
1388
  cost: 0, latestTotalTokens: 0, toolCalls: 0, lastTool: "",
1387
1389
  retryActive: tailState.retryActive, retriesStarted: 0, lastRetryError: "",
1388
- hadEvents: false,
1390
+ hadEvents: false, contextUsage: null,
1389
1391
  };
1390
1392
 
1391
1393
  // Gracefully handle missing file (wrapper hasn't written yet)
@@ -1496,6 +1498,21 @@ function tailSidecarJsonl(filePath: string, tailState: SidecarTailState): Sideca
1496
1498
  tailState.retryActive = false;
1497
1499
  break;
1498
1500
  }
1501
+
1502
+ case "response": {
1503
+ // get_session_stats response from pi ≥ 0.63.0 — authoritative context usage
1504
+ if (event.success === true && event.data?.contextUsage) {
1505
+ const cu = event.data.contextUsage;
1506
+ if (typeof cu.percentUsed === "number") {
1507
+ delta.contextUsage = {
1508
+ percentUsed: cu.percentUsed,
1509
+ totalTokens: cu.totalTokens || 0,
1510
+ maxTokens: cu.maxTokens || 0,
1511
+ };
1512
+ }
1513
+ }
1514
+ break;
1515
+ }
1499
1516
  }
1500
1517
  }
1501
1518
 
@@ -2405,8 +2422,10 @@ export default function (pi: ExtensionAPI) {
2405
2422
  state.reviewerLastTool = delta.lastTool;
2406
2423
  }
2407
2424
 
2408
- // Context %
2409
- if (delta.latestTotalTokens > 0 && contextWindow > 0) {
2425
+ // Context % — prefer authoritative contextUsage (pi ≥ 0.63.0)
2426
+ if (delta.contextUsage) {
2427
+ state.reviewerContextPct = delta.contextUsage.percentUsed;
2428
+ } else if (delta.latestTotalTokens > 0 && contextWindow > 0) {
2410
2429
  state.reviewerContextPct = (delta.latestTotalTokens / contextWindow) * 100;
2411
2430
  }
2412
2431
 
@@ -2566,7 +2585,10 @@ export default function (pi: ExtensionAPI) {
2566
2585
  state.reviewerCostUsd += delta.cost;
2567
2586
  state.reviewerToolCount += delta.toolCalls;
2568
2587
  if (delta.lastTool) state.reviewerLastTool = delta.lastTool;
2569
- if (delta.latestTotalTokens > 0 && contextWindow > 0) {
2588
+ // Context % prefer authoritative contextUsage (pi ≥ 0.63.0)
2589
+ if (delta.contextUsage) {
2590
+ state.reviewerContextPct = delta.contextUsage.percentUsed;
2591
+ } else if (delta.latestTotalTokens > 0 && contextWindow > 0) {
2570
2592
  state.reviewerContextPct = (delta.latestTotalTokens / contextWindow) * 100;
2571
2593
  }
2572
2594
  writeLaneState(state);
@@ -3140,18 +3162,24 @@ export default function (pi: ExtensionAPI) {
3140
3162
  state.workerLastRetryError = delta.lastRetryError;
3141
3163
  }
3142
3164
 
3143
- // Context % (same as subprocess onContextPct)
3144
- // totalTokens is cumulative from the most recent message_end
3145
- if (delta.latestTotalTokens > 0 && contextWindow > 0) {
3146
- const pct = (delta.latestTotalTokens / contextWindow) * 100;
3147
- state.workerContextPct = pct;
3148
- if (pct >= warnPct) {
3149
- writeWrapUpSignal(`Wrap up (context ${Math.round(pct)}%)`);
3150
- }
3151
- if (pct >= killPct && state.workerStatus === "running") {
3152
- console.error(`[task-runner] tmux worker: context limit (${Math.round(pct)}%) — killing session '${sessionName}'`);
3153
- killReason = "context";
3154
- spawned.kill();
3165
+ // Context % prefer authoritative contextUsage from pi ≥ 0.63.0,
3166
+ // fall back to manual calculation from totalTokens + cacheRead.
3167
+ {
3168
+ const pct = delta.contextUsage
3169
+ ? delta.contextUsage.percentUsed
3170
+ : (delta.latestTotalTokens > 0 && contextWindow > 0)
3171
+ ? (delta.latestTotalTokens / contextWindow) * 100
3172
+ : 0;
3173
+ if (pct > 0) {
3174
+ state.workerContextPct = pct;
3175
+ if (pct >= warnPct) {
3176
+ writeWrapUpSignal(`Wrap up (context ${Math.round(pct)}%)`);
3177
+ }
3178
+ if (pct >= killPct && state.workerStatus === "running") {
3179
+ console.error(`[task-runner] tmux worker: context limit (${Math.round(pct)}%) — killing session '${sessionName}'`);
3180
+ killReason = "context";
3181
+ spawned.kill();
3182
+ }
3155
3183
  }
3156
3184
  }
3157
3185
 
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Fork entry point for the engine child process.
3
+ *
4
+ * Node v25 blocks .ts files inside node_modules regardless of flags.
5
+ * This .mjs file loads cleanly (no TypeScript processing needed), then
6
+ * uses jiti to import engine-worker.ts — bypassing Node's restriction.
7
+ *
8
+ * jiti is the same TypeScript runtime loader that Pi uses to load
9
+ * extensions. It transforms .ts files itself, independent of Node's
10
+ * --experimental-strip-types support.
11
+ */
12
+ import { createJiti } from "jiti";
13
+ import { fileURLToPath } from "node:url";
14
+ import { dirname, join } from "node:path";
15
+
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
+ const jiti = createJiti(import.meta.url);
18
+ await jiti.import(join(__dirname, "engine-worker.ts"));
@@ -5,8 +5,7 @@ import { execSync, execFileSync } from "child_process";
5
5
  import { writeFileSync, unlinkSync, mkdirSync, existsSync, readdirSync } from "fs";
6
6
  import { join, dirname } from "path";
7
7
  import { fileURLToPath } from "url";
8
- // child_process.fork() disabled Node v25 blocks .ts in node_modules.
9
- // Re-enable when engine-worker ships as pre-compiled .js bundle.
8
+ import { fork, type ChildProcess } from "child_process";
10
9
 
11
10
  // Direct imports — avoid barrel (index.ts) to prevent loading the entire module graph.
12
11
  // Each import targets the specific module where the symbol is defined.
@@ -898,7 +897,7 @@ function resolveEngineWorkerPath(): string {
898
897
  } catch {
899
898
  thisDir = __dirname;
900
899
  }
901
- return join(thisDir, "engine-worker.ts");
900
+ return join(thisDir, "engine-worker-entry.mjs");
902
901
  }
903
902
 
904
903
  /**
@@ -936,14 +935,24 @@ export function startBatchInWorker(
936
935
  updateWidget: () => void,
937
936
  onMonitorUpdate?: (state: import("./types.ts").MonitorState) => void,
938
937
  onTerminal?: () => void,
939
- ): null {
940
- // ── Main-thread execution (TP-071 fork disabled) ─────────────
941
- // Node v25 blocks .ts files inside node_modules regardless of
942
- // --experimental-strip-types or --experimental-transform-types.
943
- // Until we ship a pre-compiled engine bundle, the engine runs on
944
- // the main thread via startBatchAsync(). The supervisor stays
945
- // responsive because engine work is async I/O (tmux, git, fs).
946
- {
938
+ ): ChildProcess | null {
939
+ const workerPath = resolveEngineWorkerPath();
940
+
941
+ let child: ChildProcess;
942
+ try {
943
+ // Fork a child process to run the engine in a separate isolate.
944
+ // The entry point is a .mjs file that uses jiti to load .ts files,
945
+ // bypassing Node v25's restriction on .ts in node_modules.
946
+ child = fork(workerPath, [], {
947
+ env: { ...process.env, TASKPLANE_ENGINE_FORK: "1" },
948
+ serialization: "advanced",
949
+ });
950
+ } catch (spawnErr: unknown) {
951
+ const errMsg = spawnErr instanceof Error ? spawnErr.message : String(spawnErr);
952
+ ctx.ui.notify(
953
+ `⚠️ Engine process spawn failed: ${errMsg}\n Falling back to main-thread execution.`,
954
+ "warning",
955
+ );
947
956
  // Construct fallback engine function from workerData and run on main thread
948
957
  const wsConfig = wkData.workspaceConfig
949
958
  ? deserializeWorkspaceConfig(wkData.workspaceConfig)
@@ -976,6 +985,91 @@ export function startBatchInWorker(
976
985
  startBatchAsync(fallbackFn, batchState, ctx, updateWidget, onTerminal);
977
986
  return null;
978
987
  }
988
+
989
+ // Send workerData as first IPC message
990
+ child.send({ type: "init", data: wkData });
991
+
992
+ // Terminal settlement guard (R001 §3): ensures onTerminal fires at most once.
993
+ let settled = false;
994
+ const settle = () => {
995
+ if (settled) return;
996
+ settled = true;
997
+ onTerminal?.();
998
+ };
999
+
1000
+ child.on("message", (msg: WorkerToMainMessage) => {
1001
+ switch (msg.type) {
1002
+ case "notify":
1003
+ ctx.ui.notify(msg.msg, msg.level);
1004
+ updateWidget();
1005
+ break;
1006
+
1007
+ case "monitor-update":
1008
+ onMonitorUpdate?.(msg.state);
1009
+ break;
1010
+
1011
+ case "engine-event":
1012
+ break;
1013
+
1014
+ case "state-sync":
1015
+ applySerializedState(batchState, msg.state);
1016
+ updateWidget();
1017
+ break;
1018
+
1019
+ case "complete":
1020
+ applySerializedState(batchState, msg.state);
1021
+ updateWidget();
1022
+ settle();
1023
+ break;
1024
+
1025
+ case "error":
1026
+ if (batchState.phase !== "completed" && batchState.phase !== "failed") {
1027
+ batchState.phase = "failed";
1028
+ batchState.endedAt = Date.now();
1029
+ batchState.errors.push(`Unhandled engine error: ${msg.message}`);
1030
+ }
1031
+ ctx.ui.notify(
1032
+ `❌ Engine crashed with unhandled error: ${msg.message}\n` +
1033
+ ` Batch ${batchState.batchId} marked as failed.`,
1034
+ "error",
1035
+ );
1036
+ updateWidget();
1037
+ break;
1038
+ }
1039
+ });
1040
+
1041
+ child.on("error", (err: Error) => {
1042
+ if (batchState.phase !== "completed" && batchState.phase !== "failed") {
1043
+ batchState.phase = "failed";
1044
+ batchState.endedAt = Date.now();
1045
+ batchState.errors.push(`Engine process error: ${err.message}`);
1046
+ }
1047
+ ctx.ui.notify(
1048
+ `❌ Engine process error: ${err.message}\n` +
1049
+ ` Batch ${batchState.batchId} marked as failed.`,
1050
+ "error",
1051
+ );
1052
+ updateWidget();
1053
+ settle();
1054
+ });
1055
+
1056
+ child.on("exit", (code: number | null) => {
1057
+ if (code !== 0 && !settled) {
1058
+ if (batchState.phase !== "completed" && batchState.phase !== "failed") {
1059
+ batchState.phase = "failed";
1060
+ batchState.endedAt = Date.now();
1061
+ batchState.errors.push(`Engine process exited with code ${code}`);
1062
+ }
1063
+ ctx.ui.notify(
1064
+ `❌ Engine process exited unexpectedly (code ${code}).`,
1065
+ "error",
1066
+ );
1067
+ updateWidget();
1068
+ }
1069
+ settle();
1070
+ });
1071
+
1072
+ return child;
979
1073
  }
980
1074
 
981
1075
  // ── TP-043 R002-2: Integration Executor Builder ─────────────────────
@@ -1292,9 +1386,9 @@ export default function (pi: ExtensionAPI) {
1292
1386
  let orchWidgetCtx: ExtensionContext | undefined;
1293
1387
  let latestMonitorState: MonitorState | null = null;
1294
1388
 
1295
- // ── TP-071: Active engine handle (currently unused — fork disabled) ──
1296
- // Will be restored when engine-worker ships as pre-compiled .js bundle.
1297
- let activeWorker: null = null;
1389
+ // ── TP-071: Active engine child process ──────────────────────────
1390
+ // Tracked so pause/abort can send control messages to the engine.
1391
+ let activeWorker: ChildProcess | null = null;
1298
1392
 
1299
1393
  // ── Supervisor State (TP-041) ────────────────────────────────────
1300
1394
  let supervisorState = freshSupervisorState();
@@ -1911,8 +2005,8 @@ export default function (pi: ExtensionAPI) {
1911
2005
  return ORCH_MESSAGES.pauseAlreadyPaused(orchBatchState.batchId);
1912
2006
  }
1913
2007
  orchBatchState.pauseSignal.paused = true;
1914
- // TP-071: Forward pause to engine process (disabled fork not active)
1915
- // activeWorker?.send({ type: "pause" });
2008
+ // TP-071: Forward pause to engine process (its pauseSignal is separate)
2009
+ activeWorker?.send({ type: "pause" });
1916
2010
  updateOrchWidget();
1917
2011
  return ORCH_MESSAGES.pauseActivated(orchBatchState.batchId);
1918
2012
  }
@@ -2102,10 +2196,16 @@ export default function (pi: ExtensionAPI) {
2102
2196
  orchBatchState.pauseSignal.paused = true;
2103
2197
  messages.push(" ✓ Pause signal set on in-memory batch state");
2104
2198
  }
2105
- // TP-071: Forward pause to engine (disabled fork not active)
2106
- if (false as boolean) {
2107
- // Will be restored when engine-worker ships as pre-compiled .js bundle
2108
- messages.push(" ✓ Pause signal forwarded to engine process");
2199
+ // TP-071: Forward pause to engine and kill on hard abort
2200
+ if (activeWorker) {
2201
+ activeWorker.send({ type: "pause" });
2202
+ if (hard) {
2203
+ activeWorker.kill();
2204
+ activeWorker = null;
2205
+ messages.push(" ✓ Engine process killed (hard abort)");
2206
+ } else {
2207
+ messages.push(" ✓ Pause signal forwarded to engine process");
2208
+ }
2109
2209
  }
2110
2210
 
2111
2211
  // Step 3: Check what we're aborting
@@ -3182,8 +3282,15 @@ export default function (pi: ExtensionAPI) {
3182
3282
  // Ensure supervisor lockfile/heartbeat are cleaned up on normal session exit.
3183
3283
  // This avoids leaving a live-looking lock when the process exits cleanly.
3184
3284
  pi.on("session_end", async () => {
3185
- // TP-071: Kill engine process on session exit (disabled — fork not active)
3186
- // Will be restored when engine-worker ships as pre-compiled .js bundle
3285
+ // TP-071: Kill engine process on session exit
3286
+ if (activeWorker) {
3287
+ try {
3288
+ activeWorker.kill();
3289
+ activeWorker = null;
3290
+ } catch {
3291
+ // Best effort — process may already be dead
3292
+ }
3293
+ }
3187
3294
  try {
3188
3295
  await deactivateSupervisor(pi, supervisorState);
3189
3296
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskplane",
3
- "version": "0.20.5",
3
+ "version": "0.21.0",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -43,6 +43,7 @@
43
43
  "@sinclair/typebox": "*"
44
44
  },
45
45
  "dependencies": {
46
+ "jiti": "^2.6.1",
46
47
  "yaml": "^2.4.0"
47
48
  },
48
49
  "license": "MIT",