taskplane 0.28.2 → 0.28.4

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.
@@ -27,6 +27,7 @@ import { writeFileSync, readFileSync, existsSync, mkdirSync, renameSync, unlinkS
27
27
  import { join, dirname } from "path";
28
28
  import { spawn as nodeSpawn } from "child_process";
29
29
  import { resolvePiCliPath, resolveTaskplaneAgentTemplate } from "./path-resolver.ts";
30
+ import { loadPiSettingsPackages, filterExcludedExtensions } from "./settings-loader.ts";
30
31
  import { randomBytes } from "crypto";
31
32
  import { buildExpansionRequestId, type SegmentExpansionRequest } from "./types.ts";
32
33
 
@@ -445,6 +446,27 @@ export default function (pi: ExtensionAPI) {
445
446
  ];
446
447
  if (reviewerModel) args.push("--model", reviewerModel);
447
448
  if (reviewerThinking) args.push("--thinking", reviewerThinking);
449
+
450
+ // TP-180: Forward user-installed extensions to reviewer agent
451
+ // Use TASKPLANE_STATE_ROOT (canonical project root) for settings resolution,
452
+ // falling back to cwd (which may be a worktree without .pi/settings.json).
453
+ const settingsRoot = process.env.TASKPLANE_STATE_ROOT || cwd;
454
+ const reviewerPackages = loadPiSettingsPackages(settingsRoot);
455
+ // Apply reviewer-specific exclusions from config (JSON array via env)
456
+ let reviewerExclusions: string[] = [];
457
+ try {
458
+ const rawExclude = process.env.TASKPLANE_REVIEWER_EXCLUDE_EXTENSIONS;
459
+ if (rawExclude) {
460
+ const parsed = JSON.parse(rawExclude);
461
+ if (Array.isArray(parsed)) {
462
+ reviewerExclusions = parsed.filter((v: unknown): v is string => typeof v === "string");
463
+ }
464
+ }
465
+ } catch { /* ignore malformed */ }
466
+ const filteredReviewerPackages = filterExcludedExtensions(reviewerPackages, reviewerExclusions);
467
+ for (const pkg of filteredReviewerPackages) {
468
+ args.push("-e", pkg);
469
+ }
448
470
  const proc = nodeSpawn(process.execPath, args, {
449
471
  shell: false,
450
472
  cwd,
@@ -1125,6 +1125,7 @@ export function toOrchestratorConfig(config: TaskplaneConfig): import("./types.t
1125
1125
  verify: [...o.merge.verify],
1126
1126
  order: o.merge.order,
1127
1127
  timeout_minutes: o.merge.timeoutMinutes ?? 90,
1128
+ exclude_extensions: [...(o.merge.excludeExtensions ?? [])],
1128
1129
  },
1129
1130
  failure: {
1130
1131
  on_task_failure: o.failure.onTaskFailure,
@@ -1183,7 +1184,9 @@ export function toTaskRunnerConfig(config: TaskplaneConfig): import("./types.ts"
1183
1184
  model: config.taskRunner.reviewer.model,
1184
1185
  thinking: config.taskRunner.reviewer.thinking,
1185
1186
  tools: config.taskRunner.reviewer.tools,
1187
+ excludeExtensions: [...(config.taskRunner.reviewer.excludeExtensions ?? [])],
1186
1188
  },
1189
+ workerExcludeExtensions: [...(config.taskRunner.worker.excludeExtensions ?? [])],
1187
1190
  };
1188
1191
  }
1189
1192
 
@@ -111,6 +111,8 @@ export interface WorkerConfig {
111
111
  thinking: string;
112
112
  /** Optional spawn mode override for task-runner (Runtime V2 subprocess-only). */
113
113
  spawnMode?: "subprocess";
114
+ /** Package specifiers to exclude from extension forwarding for worker agents (exact match). @since TP-180 */
115
+ excludeExtensions?: string[];
114
116
  }
115
117
 
116
118
  /** Reviewer agent configuration */
@@ -121,6 +123,8 @@ export interface ReviewerConfig {
121
123
  tools: string;
122
124
  /** Thinking mode for reviewer */
123
125
  thinking: string;
126
+ /** Package specifiers to exclude from extension forwarding for reviewer agents (exact match). @since TP-180 */
127
+ excludeExtensions?: string[];
124
128
  }
125
129
 
126
130
  /** Context/resource limits for task execution */
@@ -310,6 +314,8 @@ export interface MergeConfig {
310
314
  order: "fewest-files-first" | "sequential";
311
315
  /** Merge-agent timeout in minutes */
312
316
  timeoutMinutes?: number;
317
+ /** Package specifiers to exclude from extension forwarding for merge agents (exact match). @since TP-180 */
318
+ excludeExtensions?: string[];
313
319
  }
314
320
 
315
321
  /** Failure policy settings */
@@ -588,8 +594,8 @@ export const DEFAULT_TASK_RUNNER_SECTION: TaskRunnerSection = {
588
594
  testing: { commands: {} },
589
595
  standards: { docs: [], rules: [] },
590
596
  standardsOverrides: {},
591
- worker: { model: "", tools: "read,write,edit,bash,grep,find,ls", thinking: "" },
592
- reviewer: { model: "", tools: "read,bash,grep,find,ls", thinking: "on" },
597
+ worker: { model: "", tools: "read,write,edit,bash,grep,find,ls", thinking: "", excludeExtensions: [] },
598
+ reviewer: { model: "", tools: "read,bash,grep,find,ls", thinking: "on", excludeExtensions: [] },
593
599
  context: {
594
600
  workerContextWindow: 0,
595
601
  warnPercent: 85,
@@ -645,6 +651,7 @@ export const DEFAULT_ORCHESTRATOR_SECTION: OrchestratorSection = {
645
651
  verify: [],
646
652
  order: "fewest-files-first",
647
653
  timeoutMinutes: 90,
654
+ excludeExtensions: [],
648
655
  },
649
656
  failure: {
650
657
  onTaskFailure: "skip-dependents",
@@ -6,7 +6,7 @@ import { existsSync, readdirSync, readFileSync, renameSync, unlinkSync } from "f
6
6
  import { join, resolve } from "path";
7
7
 
8
8
  import { formatDiscoveryResults, runDiscovery } from "./discovery.ts";
9
- import { buildReviewerEnv, computeTransitiveDependents, execLog, executeLaneV2, executeWave, killV2LaneAgents, resolveCanonicalTaskPaths } from "./execution.ts";
9
+ import { buildReviewerEnv, buildWorkerExcludeEnv, computeTransitiveDependents, execLog, executeLaneV2, executeWave, killV2LaneAgents, resolveCanonicalTaskPaths } from "./execution.ts";
10
10
  import type { RuntimeBackend } from "./execution.ts";
11
11
  import type { MonitorUpdateCallback } from "./execution.ts";
12
12
  // classifyExit no longer called directly — Tier 0 uses exitDiagnostic.classification
@@ -1381,7 +1381,7 @@ async function attemptWorkerCrashRetry(
1381
1381
  retryPauseSignal,
1382
1382
  wsRoot,
1383
1383
  isWsMode,
1384
- { ORCH_BATCH_ID: batchState.batchId, ...buildReviewerEnv(runnerConfig?.reviewer) }, // TP-089: ensure mailbox works for retries
1384
+ { ORCH_BATCH_ID: batchState.batchId, ...buildReviewerEnv(runnerConfig?.reviewer), ...buildWorkerExcludeEnv(runnerConfig?.workerExcludeExtensions) }, // TP-089: ensure mailbox works for retries
1385
1385
  );
1386
1386
 
1387
1387
  const retryOutcome = retryResult.tasks[0];
@@ -1640,7 +1640,7 @@ async function attemptModelFallbackRetry(
1640
1640
  // Pass TASKPLANE_MODEL_FALLBACK=1 as extra env var to signal
1641
1641
  // the task-runner to use the session model instead of configured model.
1642
1642
  // TP-089: Also include ORCH_BATCH_ID so mailbox steering works for retries.
1643
- const modelFallbackEnv = { TASKPLANE_MODEL_FALLBACK: "1", ORCH_BATCH_ID: batchState.batchId, ...buildReviewerEnv(runnerConfig?.reviewer) };
1643
+ const modelFallbackEnv = { TASKPLANE_MODEL_FALLBACK: "1", ORCH_BATCH_ID: batchState.batchId, ...buildReviewerEnv(runnerConfig?.reviewer), ...buildWorkerExcludeEnv(runnerConfig?.workerExcludeExtensions) };
1644
1644
  const retryResult = await executeLaneV2(
1645
1645
  retryLane,
1646
1646
  orchConfig,
@@ -1887,7 +1887,9 @@ async function attemptStaleWorktreeRecovery(
1887
1887
  model: runnerConfig?.reviewer?.model || "",
1888
1888
  thinking: runnerConfig?.reviewer?.thinking || "",
1889
1889
  tools: runnerConfig?.reviewer?.tools || "",
1890
+ excludeExtensions: runnerConfig?.reviewer?.excludeExtensions ?? [],
1890
1891
  },
1892
+ runnerConfig?.workerExcludeExtensions ?? [],
1891
1893
  );
1892
1894
 
1893
1895
  return retryResult;
@@ -2490,7 +2492,9 @@ export async function executeOrchBatch(
2490
2492
  model: runnerConfig?.reviewer?.model || "",
2491
2493
  thinking: runnerConfig?.reviewer?.thinking || "",
2492
2494
  tools: runnerConfig?.reviewer?.tools || "",
2495
+ excludeExtensions: runnerConfig?.reviewer?.excludeExtensions ?? [],
2493
2496
  },
2497
+ runnerConfig?.workerExcludeExtensions ?? [],
2494
2498
  );
2495
2499
 
2496
2500
  // ── TP-039: Tier 0 — Stale worktree recovery ────────────
@@ -1754,7 +1754,8 @@ export async function executeWave(
1754
1754
  runtimeBackend?: RuntimeBackend,
1755
1755
  onSupervisorAlert?: SupervisorAlertCallback,
1756
1756
  supervisorAutonomy: "interactive" | "supervised" | "autonomous" = "autonomous",
1757
- reviewerConfig?: { model?: string; thinking?: string; tools?: string },
1757
+ reviewerConfig?: { model?: string; thinking?: string; tools?: string; excludeExtensions?: string[] },
1758
+ workerExcludeExtensions?: string[],
1758
1759
  ): Promise<WaveExecutionResult> {
1759
1760
  const startedAt = Date.now();
1760
1761
  const policy = config.failure.on_task_failure;
@@ -1862,6 +1863,7 @@ export async function executeWave(
1862
1863
  ORCH_BATCH_ID: batchId,
1863
1864
  TASKPLANE_SUPERVISOR_AUTONOMY: supervisorAutonomy,
1864
1865
  ...buildReviewerEnv(reviewerConfig),
1866
+ ...buildWorkerExcludeEnv(workerExcludeExtensions),
1865
1867
  }, onSupervisorAlert),
1866
1868
  );
1867
1869
 
@@ -2503,13 +2505,44 @@ import { executeTaskV2, type LaneRunnerConfig, type LaneRunnerTaskResult } from
2503
2505
  *
2504
2506
  * @since TP-160
2505
2507
  */
2508
+ /**
2509
+ * Parse a JSON string array from an env var value, returning empty array on failure.
2510
+ * @since TP-180
2511
+ */
2512
+ function parseJsonArrayEnv(value?: string): string[] {
2513
+ if (!value) return [];
2514
+ try {
2515
+ const parsed = JSON.parse(value);
2516
+ if (Array.isArray(parsed)) return parsed.filter((v: unknown): v is string => typeof v === "string");
2517
+ } catch { /* ignore malformed */ }
2518
+ return [];
2519
+ }
2520
+
2506
2521
  export function buildReviewerEnv(
2507
- reviewerConfig?: { model?: string; thinking?: string; tools?: string } | null,
2522
+ reviewerConfig?: { model?: string; thinking?: string; tools?: string; excludeExtensions?: string[] } | null,
2508
2523
  ): Record<string, string> {
2509
2524
  const env: Record<string, string> = {};
2510
2525
  if (reviewerConfig?.model) env.TASKPLANE_REVIEWER_MODEL = reviewerConfig.model;
2511
2526
  if (reviewerConfig?.thinking) env.TASKPLANE_REVIEWER_THINKING = reviewerConfig.thinking;
2512
2527
  if (reviewerConfig?.tools) env.TASKPLANE_REVIEWER_TOOLS = reviewerConfig.tools;
2528
+ // TP-180: Forward reviewer extension exclusions as JSON array
2529
+ if (reviewerConfig?.excludeExtensions && reviewerConfig.excludeExtensions.length > 0) {
2530
+ env.TASKPLANE_REVIEWER_EXCLUDE_EXTENSIONS = JSON.stringify(reviewerConfig.excludeExtensions);
2531
+ }
2532
+ return env;
2533
+ }
2534
+
2535
+ /**
2536
+ * Build worker extension exclusion env vars from config.
2537
+ * @since TP-180
2538
+ */
2539
+ export function buildWorkerExcludeEnv(
2540
+ workerExcludeExtensions?: string[] | null,
2541
+ ): Record<string, string> {
2542
+ const env: Record<string, string> = {};
2543
+ if (workerExcludeExtensions && workerExcludeExtensions.length > 0) {
2544
+ env.TASKPLANE_WORKER_EXCLUDE_EXTENSIONS = JSON.stringify(workerExcludeExtensions);
2545
+ }
2513
2546
  return env;
2514
2547
  }
2515
2548
 
@@ -2606,6 +2639,9 @@ export async function executeLaneV2(
2606
2639
  reviewerModel: extraEnvVars?.TASKPLANE_REVIEWER_MODEL || "",
2607
2640
  reviewerThinking: extraEnvVars?.TASKPLANE_REVIEWER_THINKING || "",
2608
2641
  reviewerTools: extraEnvVars?.TASKPLANE_REVIEWER_TOOLS || "",
2642
+ // TP-180: Extension exclusion lists from config
2643
+ workerExcludeExtensions: parseJsonArrayEnv(extraEnvVars?.TASKPLANE_WORKER_EXCLUDE_EXTENSIONS),
2644
+ reviewerExcludeExtensions: parseJsonArrayEnv(extraEnvVars?.TASKPLANE_REVIEWER_EXCLUDE_EXTENSIONS),
2609
2645
  supervisorAutonomy,
2610
2646
  projectName: config.project?.name || "project",
2611
2647
  maxIterations: 20,
@@ -33,6 +33,7 @@ import {
33
33
  } from "./task-executor-core.ts";
34
34
 
35
35
  import { spawnAgent, type AgentHostOptions, type AgentHostResult } from "./agent-host.ts";
36
+ import { loadPiSettingsPackages, filterExcludedExtensions } from "./settings-loader.ts";
36
37
 
37
38
  import {
38
39
  appendAgentEvent,
@@ -222,6 +223,10 @@ export interface LaneRunnerConfig {
222
223
  supervisorAutonomy?: "interactive" | "supervised" | "autonomous";
223
224
  /** Project name (for review request context) */
224
225
  projectName?: string;
226
+ /** Package specifiers to exclude from worker extension forwarding (exact match). @since TP-180 */
227
+ workerExcludeExtensions?: string[];
228
+ /** Package specifiers to exclude from reviewer extension forwarding (exact match). @since TP-180 */
229
+ reviewerExcludeExtensions?: string[];
225
230
  /** Max worker iterations before giving up */
226
231
  maxIterations: number;
227
232
  /** No-progress stall limit */
@@ -555,6 +560,10 @@ export async function executeTaskV2(
555
560
  const outboxDir = join(config.stateRoot, ".pi", "mailbox", config.batchId, workerAgentId, "outbox");
556
561
  const bridgeExtensionPath = join(LANE_RUNNER_DIR, "agent-bridge-extension.ts");
557
562
 
563
+ // TP-180: Forward user-installed extensions to worker agent
564
+ const allPackages = loadPiSettingsPackages(config.stateRoot);
565
+ const workerPackages = filterExcludedExtensions(allPackages, config.workerExcludeExtensions ?? []);
566
+
558
567
  const hostOpts: AgentHostOptions = {
559
568
  agentId: workerAgentId,
560
569
  role: "worker",
@@ -577,7 +586,7 @@ export async function executeTaskV2(
577
586
  timeoutMs: config.maxWorkerMinutes * 60_000,
578
587
  stateRoot: config.stateRoot,
579
588
  packet: unit.packet,
580
- extensions: [bridgeExtensionPath],
589
+ extensions: [bridgeExtensionPath, ...workerPackages],
581
590
  env: {
582
591
  TASKPLANE_OUTBOX_DIR: outboxDir,
583
592
  TASKPLANE_AGENT_ID: workerAgentId,
@@ -597,6 +606,11 @@ export async function executeTaskV2(
597
606
  ...(config.reviewerModel ? { TASKPLANE_REVIEWER_MODEL: config.reviewerModel } : {}),
598
607
  ...(config.reviewerThinking ? { TASKPLANE_REVIEWER_THINKING: config.reviewerThinking } : {}),
599
608
  ...(config.reviewerTools ? { TASKPLANE_REVIEWER_TOOLS: config.reviewerTools } : {}),
609
+ // TP-180: Pass state root and reviewer exclusions for extension forwarding
610
+ TASKPLANE_STATE_ROOT: config.stateRoot,
611
+ ...(config.reviewerExcludeExtensions && config.reviewerExcludeExtensions.length > 0
612
+ ? { TASKPLANE_REVIEWER_EXCLUDE_EXTENSIONS: JSON.stringify(config.reviewerExcludeExtensions) }
613
+ : {}),
600
614
  },
601
615
  // TP-172: Exit interception callback — escalate to supervisor when worker
602
616
  // exits without making visible progress (no checkboxes, no blocker logged).
@@ -21,6 +21,7 @@ import { loadOrchestratorConfig } from "./config.ts";
21
21
  import { captureBaseline, diffFingerprints, runVerificationCommands, parseTestOutput, deduplicateFingerprints } from "./verification.ts";
22
22
  import { spawnAgent } from "./agent-host.ts";
23
23
  import type { AgentHostOptions, AgentHostResult, AgentTelemetryCallback } from "./agent-host.ts";
24
+ import { loadPiSettingsPackages, filterExcludedExtensions } from "./settings-loader.ts";
24
25
  import type { RuntimeBackend } from "./execution.ts";
25
26
  import type { VerificationBaseline, FingerprintDiff, TestFingerprint } from "./verification.ts";
26
27
 
@@ -716,6 +717,12 @@ export async function spawnMergeAgentV2(
716
717
  mkdirSync(join(mailboxDir, "inbox"), { recursive: true });
717
718
  }
718
719
 
720
+ // TP-180: Forward user-installed extensions to merge agent
721
+ const mergeStateRoot = stateRoot ?? repoRoot;
722
+ const allMergePackages = loadPiSettingsPackages(mergeStateRoot);
723
+ const mergeExclusions = config.merge.exclude_extensions ?? [];
724
+ const mergePackages = filterExcludedExtensions(allMergePackages, mergeExclusions);
725
+
719
726
  const opts: AgentHostOptions = {
720
727
  agentId: sessionName,
721
728
  role: "merger",
@@ -733,8 +740,9 @@ export async function spawnMergeAgentV2(
733
740
  eventsPath,
734
741
  exitSummaryPath,
735
742
  timeoutMs: (config.merge.timeout_minutes ?? 10) * 60 * 1000,
736
- stateRoot: stateRoot ?? repoRoot,
743
+ stateRoot: mergeStateRoot,
737
744
  packet: null,
745
+ ...(mergePackages.length > 0 ? { extensions: mergePackages } : {}),
738
746
  env: {
739
747
  ORCH_BATCH_ID: bid,
740
748
  },
@@ -748,7 +756,6 @@ export async function spawnMergeAgentV2(
748
756
  }
749
757
  const mergeNumber = mergeNumberMatch ? parseInt(mergeNumberMatch[1], 10) : 1;
750
758
  const mergeStartedAt = Date.now();
751
- const mergeStateRoot = stateRoot ?? repoRoot;
752
759
 
753
760
  // Helper: build a RuntimeAgentTelemetrySnapshot from a partial AgentHostResult.
754
761
  const buildAgentSnap = (tel: Partial<AgentHostResult>, status: RuntimeAgentTelemetrySnapshot["status"]): RuntimeAgentTelemetrySnapshot => ({
@@ -8,7 +8,7 @@ import { join } from "path";
8
8
  import { assembleDiagnosticInput, emitDiagnosticReports } from "./diagnostic-reports.ts";
9
9
  import { runDiscovery } from "./discovery.ts";
10
10
  import { executeOrchBatch, resolveDisplayWaveNumber } from "./engine.ts";
11
- import { buildReviewerEnv, computeTransitiveDependents, execLog, executeLaneV2, executeWave, resolveCanonicalTaskPaths } from "./execution.ts";
11
+ import { buildReviewerEnv, buildWorkerExcludeEnv, computeTransitiveDependents, execLog, executeLaneV2, executeWave, resolveCanonicalTaskPaths } from "./execution.ts";
12
12
  import type { MonitorUpdateCallback, RuntimeBackend } from "./execution.ts";
13
13
  import { selectRuntimeBackend } from "./engine.ts";
14
14
  import { readRegistrySnapshot, isTerminalStatus, isProcessAlive } from "./process-registry.ts";
@@ -1506,7 +1506,7 @@ export async function resumeOrchBatch(
1506
1506
  const laneResult = await executeLaneV2(
1507
1507
  lane, orchConfig, laneRepoRoot, batchState.pauseSignal,
1508
1508
  workspaceRoot, !!workspaceConfig,
1509
- { ORCH_BATCH_ID: batchState.batchId, ...buildReviewerEnv(runnerConfig.reviewer) },
1509
+ { ORCH_BATCH_ID: batchState.batchId, ...buildReviewerEnv(runnerConfig.reviewer), ...buildWorkerExcludeEnv(runnerConfig.workerExcludeExtensions) },
1510
1510
  emitAlert,
1511
1511
  );
1512
1512
  const taskResult = laneResult.tasks.find(t => t.taskId === task.taskId);
@@ -1588,7 +1588,7 @@ export async function resumeOrchBatch(
1588
1588
  const laneResult = await executeLaneV2(
1589
1589
  lane, orchConfig, reExecRepoRoot, batchState.pauseSignal,
1590
1590
  workspaceRoot, !!workspaceConfig,
1591
- { ORCH_BATCH_ID: batchState.batchId, ...buildReviewerEnv(runnerConfig.reviewer) },
1591
+ { ORCH_BATCH_ID: batchState.batchId, ...buildReviewerEnv(runnerConfig.reviewer), ...buildWorkerExcludeEnv(runnerConfig.workerExcludeExtensions) },
1592
1592
  emitAlert,
1593
1593
  );
1594
1594
  const taskResult = laneResult.tasks.find(t => t.taskId === task.taskId);
@@ -2048,6 +2048,7 @@ export async function resumeOrchBatch(
2048
2048
  emitAlert,
2049
2049
  supervisorAutonomy,
2050
2050
  runnerConfig.reviewer,
2051
+ runnerConfig.workerExcludeExtensions ?? [],
2051
2052
  );
2052
2053
 
2053
2054
  batchState.waveResults.push(waveResult);
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Settings Loader — Read and merge Pi extension packages from settings files
3
+ *
4
+ * Reads `.pi/settings.json` from both project-level and global locations,
5
+ * extracts the `packages` arrays, merges them (project entries first,
6
+ * deduplicated), and filters out taskplane itself.
7
+ *
8
+ * Used by spawn points (worker, reviewer, merge agent) to forward
9
+ * user-installed extensions as explicit `-e` flags alongside `--no-extensions`.
10
+ *
11
+ * @module taskplane/settings-loader
12
+ * @since TP-180
13
+ */
14
+
15
+ import { readFileSync } from "fs";
16
+ import { join } from "path";
17
+ import { homedir } from "os";
18
+
19
+ // ── Constants ────────────────────────────────────────────────────────
20
+
21
+ /** Subpath under a project root for the project-level Pi settings file. */
22
+ const PROJECT_SETTINGS_SUBPATH = join(".pi", "settings.json");
23
+
24
+ /** Subpath under the global agent dir for the global Pi settings file. */
25
+ const GLOBAL_SETTINGS_SUBPATH = join(".pi", "agent", "settings.json");
26
+
27
+ // ── Internal Helpers ─────────────────────────────────────────────────
28
+
29
+ /**
30
+ * Safely read and parse a JSON file, returning null on any failure.
31
+ */
32
+ function readJsonSafe(filePath: string): Record<string, unknown> | null {
33
+ try {
34
+ const raw = readFileSync(filePath, "utf-8");
35
+ const parsed = JSON.parse(raw);
36
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
37
+ return parsed as Record<string, unknown>;
38
+ }
39
+ return null;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Extract the `packages` array from a parsed settings object.
47
+ * Returns an empty array if the key is missing or not an array of strings.
48
+ */
49
+ function extractPackages(settings: Record<string, unknown> | null): string[] {
50
+ if (!settings) return [];
51
+ const packages = settings.packages;
52
+ if (!Array.isArray(packages)) return [];
53
+ // Filter to strings only, skip non-string entries gracefully
54
+ return packages.filter((p): p is string => typeof p === "string" && p.length > 0);
55
+ }
56
+
57
+ /**
58
+ * Resolve the global Pi agent settings path.
59
+ *
60
+ * Resolution order:
61
+ * 1. `PI_CODING_AGENT_DIR` env → `<value>/settings.json`
62
+ * 2. `os.homedir()/.pi/agent/settings.json`
63
+ */
64
+ function resolveGlobalSettingsPath(): string {
65
+ const agentDir = process.env.PI_CODING_AGENT_DIR;
66
+ if (agentDir) {
67
+ return join(agentDir, "settings.json");
68
+ }
69
+ return join(homedir(), GLOBAL_SETTINGS_SUBPATH);
70
+ }
71
+
72
+ // ── Public API ───────────────────────────────────────────────────────
73
+
74
+ /**
75
+ * Load Pi extension packages from project and global settings files.
76
+ *
77
+ * Reads `.pi/settings.json` from the project root (stateRoot) and from
78
+ * the global agent directory, merges the package lists (project first,
79
+ * deduplicated), and filters out any package containing "taskplane"
80
+ * (which is already loaded as the bridge extension).
81
+ *
82
+ * @param stateRoot - Project root directory (used to locate `.pi/settings.json`)
83
+ * @returns Array of package specifiers (e.g., `["npm:pi-sage"]`) or empty array
84
+ */
85
+ export function loadPiSettingsPackages(stateRoot: string): string[] {
86
+ // Read project-level packages
87
+ const projectSettingsPath = join(stateRoot, PROJECT_SETTINGS_SUBPATH);
88
+ const projectSettings = readJsonSafe(projectSettingsPath);
89
+ const projectPackages = extractPackages(projectSettings);
90
+
91
+ // Read global packages
92
+ const globalSettingsPath = resolveGlobalSettingsPath();
93
+ const globalSettings = readJsonSafe(globalSettingsPath);
94
+ const globalPackages = extractPackages(globalSettings);
95
+
96
+ // Merge: project entries first, then global, deduplicated
97
+ const seen = new Set<string>();
98
+ const merged: string[] = [];
99
+
100
+ for (const pkg of projectPackages) {
101
+ if (!seen.has(pkg)) {
102
+ seen.add(pkg);
103
+ merged.push(pkg);
104
+ }
105
+ }
106
+ for (const pkg of globalPackages) {
107
+ if (!seen.has(pkg)) {
108
+ seen.add(pkg);
109
+ merged.push(pkg);
110
+ }
111
+ }
112
+
113
+ // Filter out taskplane itself (already loaded as bridge extension).
114
+ // Match known specifier patterns: "npm:taskplane", "taskplane", or scoped
115
+ // variants like "npm:@scope/taskplane". Avoid substring matching to prevent
116
+ // false positives on unrelated packages containing "taskplane" in their name.
117
+ return merged.filter((pkg) => {
118
+ // Strip npm:/git: prefix to get the bare package name
119
+ const bare = pkg.replace(/^(?:npm:|git:(?:github\.com\/[^/]+\/)?)/, "").toLowerCase();
120
+ // Exact match on bare name, or scoped exact match (@scope/taskplane)
121
+ return bare !== "taskplane" && !bare.endsWith("/taskplane");
122
+ });
123
+ }
124
+
125
+ /**
126
+ * Filter out excluded extensions from a package list.
127
+ *
128
+ * @param packages - Full list of package specifiers
129
+ * @param exclusions - Package specifiers to exclude (exact match)
130
+ * @returns Filtered list with excluded packages removed
131
+ */
132
+ export function filterExcludedExtensions(packages: string[], exclusions: string[]): string[] {
133
+ if (!exclusions || exclusions.length === 0) return packages;
134
+ const excludeSet = new Set(exclusions);
135
+ return packages.filter((pkg) => !excludeSet.has(pkg));
136
+ }
@@ -2,7 +2,7 @@
2
2
  * Settings TUI — interactive configuration viewer and editor.
3
3
  *
4
4
  * Provides a `/taskplane-settings` command that renders a two-level navigation:
5
- * 1. Section selector (13 sections)
5
+ * 1. Section selector (14 sections)
6
6
  * 2. Per-section SettingsList with field display, source badges,
7
7
  * and inline editing for enum/boolean/string/number fields
8
8
  *
@@ -38,6 +38,7 @@ import {
38
38
  resolveConfigRoot,
39
39
  resolveGlobalPreferencesPath,
40
40
  } from "./config-loader.ts";
41
+ import { loadPiSettingsPackages } from "./settings-loader.ts";
41
42
 
42
43
 
43
44
  // ── Types ────────────────────────────────────────────────────────────
@@ -87,7 +88,7 @@ export interface SectionDef {
87
88
  // ── Section & Field Definitions ──────────────────────────────────────
88
89
 
89
90
  /**
90
- * Canonical navigation map — 13 sections.
91
+ * Canonical navigation map — 14 sections.
91
92
  * Order matches the Step 1 design in STATUS.md.
92
93
  */
93
94
  export const SECTIONS: SectionDef[] = [
@@ -136,6 +137,11 @@ export const SECTIONS: SectionDef[] = [
136
137
  { configPath: "orchestrator.merge.timeoutMinutes", label: "Merge Timeout (minutes)", control: "input", layer: "L1", fieldType: "number", description: "Max time for merge agent to complete. Increase for large batches (default: 10)" },
137
138
  ],
138
139
  },
140
+ {
141
+ name: "Agent Extensions",
142
+ readOnly: true, // Dynamically handled — no fixed fields
143
+ fields: [],
144
+ },
139
145
  {
140
146
  name: "Context Limits",
141
147
  fields: [
@@ -1205,9 +1211,11 @@ async function showSectionSelectorLoop(
1205
1211
  const sectionItems: SelectItem[] = SECTIONS.map((section, i) => ({
1206
1212
  value: String(i),
1207
1213
  label: section.name,
1208
- description: section.readOnly
1209
- ? "Read-only collection/record fields"
1210
- : `${section.fields.length} setting${section.fields.length === 1 ? "" : "s"}`,
1214
+ description: section.name === "Agent Extensions"
1215
+ ? "Toggle extensions per agent type"
1216
+ : section.readOnly
1217
+ ? "Read-only collection/record fields"
1218
+ : `${section.fields.length} setting${section.fields.length === 1 ? "" : "s"}`,
1211
1219
  }));
1212
1220
 
1213
1221
  const selectedSection = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
@@ -1252,7 +1260,9 @@ async function showSectionSelectorLoop(
1252
1260
  const sectionIndex = parseInt(selectedSection, 10);
1253
1261
  const section = SECTIONS[sectionIndex];
1254
1262
 
1255
- if (section.readOnly) {
1263
+ if (section.name === "Agent Extensions") {
1264
+ await showExtensionsSection(ctx, configRoot, pointerConfigRoot, onConfigChanged);
1265
+ } else if (section.readOnly) {
1256
1266
  await showAdvancedSection(ctx, state.mergedConfig);
1257
1267
  } else {
1258
1268
  await showSectionSettingsLoop(ctx, section, configRoot, pointerConfigRoot, onConfigChanged);
@@ -1312,6 +1322,142 @@ async function showAdvancedSection(
1312
1322
  });
1313
1323
  }
1314
1324
 
1325
+ /**
1326
+ * TP-180: Agent Extensions section — toggle extensions per agent type.
1327
+ *
1328
+ * Discovers all installed Pi extension packages from project + global settings,
1329
+ * shows per-agent-type toggles (Worker, Reviewer, Merger), and saves
1330
+ * exclusion changes to project taskplane-config.json.
1331
+ */
1332
+ async function showExtensionsSection(
1333
+ ctx: ExtensionContext,
1334
+ configRoot: string,
1335
+ pointerConfigRoot?: string,
1336
+ onConfigChanged?: () => void,
1337
+ ): Promise<void> {
1338
+ while (true) {
1339
+ const resolvedRoot = resolveConfigRoot(configRoot, pointerConfigRoot);
1340
+ const mergedConfig = loadProjectConfig(configRoot, pointerConfigRoot);
1341
+
1342
+ // Discover installed packages (excluding taskplane itself)
1343
+ // Use configRoot (project/state root) for consistency with runtime forwarding,
1344
+ // not resolvedRoot (pointer-resolved config path).
1345
+ const packages = loadPiSettingsPackages(configRoot);
1346
+
1347
+ if (packages.length === 0) {
1348
+ await ctx.ui.custom((_tui, theme, _kb, done) => {
1349
+ const container = new Container();
1350
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1351
+ container.addChild(new Text(theme.fg("accent", theme.bold("Agent Extensions")), 1, 0));
1352
+ container.addChild(new Text("", 0, 0));
1353
+ container.addChild(new Text(theme.fg("dim", "No third-party extensions found."), 1, 0));
1354
+ container.addChild(new Text(theme.fg("dim", "Install extensions via pi settings to see them here."), 1, 0));
1355
+ container.addChild(new Text("", 0, 0));
1356
+ container.addChild(new Text(theme.fg("dim", "esc back"), 1, 0));
1357
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1358
+ return {
1359
+ render: (w: number) => container.render(w),
1360
+ invalidate: () => container.invalidate(),
1361
+ handleInput: (data: string) => { if (data === "\x1b" || data === "\x1b\x1b") done(undefined); },
1362
+ };
1363
+ });
1364
+ return;
1365
+ }
1366
+
1367
+ // Read current exclusion lists
1368
+ const workerExclude = new Set(mergedConfig.taskRunner.worker.excludeExtensions ?? []);
1369
+ const reviewerExclude = new Set(mergedConfig.taskRunner.reviewer.excludeExtensions ?? []);
1370
+ const mergeExclude = new Set(mergedConfig.orchestrator.merge.excludeExtensions ?? []);
1371
+
1372
+ const agentTypes = [
1373
+ { name: "Worker", exclude: workerExclude, configPath: "taskRunner.worker.excludeExtensions" },
1374
+ { name: "Reviewer", exclude: reviewerExclude, configPath: "taskRunner.reviewer.excludeExtensions" },
1375
+ { name: "Merger", exclude: mergeExclude, configPath: "orchestrator.merge.excludeExtensions" },
1376
+ ];
1377
+
1378
+ // Build toggle items: one per package per agent type
1379
+ const settingsItems: SettingItem[] = [];
1380
+ for (const pkg of packages) {
1381
+ for (const agentType of agentTypes) {
1382
+ const isExcluded = agentType.exclude.has(pkg);
1383
+ const enabled = !isExcluded;
1384
+ settingsItems.push({
1385
+ id: `${agentType.configPath}::${pkg}`,
1386
+ label: `${pkg}`,
1387
+ currentValue: enabled ? "✅ enabled" : "❌ disabled",
1388
+ description: agentType.name,
1389
+ values: [enabled ? "❌ disabled" : "✅ enabled"],
1390
+ });
1391
+ }
1392
+ }
1393
+
1394
+ const result = await ctx.ui.custom<{ id: string; value: string } | null>((tui, theme, _kb, done) => {
1395
+ const container = new Container();
1396
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1397
+ container.addChild(new Text(theme.fg("accent", theme.bold("Agent Extensions")), 1, 0));
1398
+ container.addChild(new Text(theme.fg("dim", "Toggle extensions on/off per agent type"), 1, 0));
1399
+ container.addChild(new Text("", 0, 0));
1400
+
1401
+ const settingsList = new SettingsList(
1402
+ settingsItems,
1403
+ Math.min(settingsItems.length + 2, 20),
1404
+ getSettingsListTheme(),
1405
+ (id, newValue) => done({ id, value: newValue }),
1406
+ () => done(null),
1407
+ );
1408
+ container.addChild(settingsList);
1409
+
1410
+ container.addChild(new Text("", 0, 0));
1411
+ container.addChild(new Text(theme.fg("dim", "↑↓ navigate • space toggle • esc back"), 1, 0));
1412
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1413
+
1414
+ return {
1415
+ render: (w: number) => container.render(w),
1416
+ invalidate: () => container.invalidate(),
1417
+ handleInput: (data: string) => { settingsList.handleInput?.(data); tui.requestRender(); },
1418
+ };
1419
+ });
1420
+
1421
+ if (!result) return; // User pressed Esc
1422
+
1423
+ // Parse the toggle result
1424
+ const [configPath, pkg] = result.id.split("::", 2);
1425
+ if (!configPath || !pkg) continue;
1426
+
1427
+ const enabling = result.value.includes("enabled");
1428
+
1429
+ // Read current exclusion array from merged effective config (handles YAML+JSON)
1430
+ const freshConfig = loadProjectConfig(configRoot, pointerConfigRoot);
1431
+ const currentExcludeList: string[] = (getNestedValue(freshConfig, configPath) as string[] | undefined) ?? [];
1432
+
1433
+ let newExcludeList: string[];
1434
+ if (enabling) {
1435
+ // Remove from exclusions → enable
1436
+ newExcludeList = currentExcludeList.filter((e: string) => e !== pkg);
1437
+ } else {
1438
+ // Add to exclusions → disable
1439
+ newExcludeList = currentExcludeList.includes(pkg)
1440
+ ? currentExcludeList
1441
+ : [...currentExcludeList, pkg];
1442
+ }
1443
+
1444
+ try {
1445
+ writeProjectConfigField(configRoot, configPath, newExcludeList, pointerConfigRoot);
1446
+ if (onConfigChanged) {
1447
+ try { onConfigChanged(); } catch { /* non-fatal */ }
1448
+ }
1449
+ ctx.ui.notify(
1450
+ `${enabling ? "✅ Enabled" : "❌ Disabled"} ${pkg} for ${configPath.includes("worker") ? "Worker" : configPath.includes("reviewer") ? "Reviewer" : "Merger"}`,
1451
+ "info",
1452
+ );
1453
+ } catch (err: any) {
1454
+ ctx.ui.notify(`❌ Failed to save: ${err.message}`, "error");
1455
+ }
1456
+
1457
+ // Loop continues → re-render with fresh state
1458
+ }
1459
+ }
1460
+
1315
1461
  /**
1316
1462
  * Format a source badge for display.
1317
1463
  */
@@ -43,6 +43,8 @@ export interface OrchestratorConfig {
43
43
  order: "fewest-files-first" | "sequential";
44
44
  /** Merge agent timeout in minutes. Default: 10. Increase for large batches. */
45
45
  timeout_minutes: number;
46
+ /** Package specifiers to exclude from extension forwarding (exact match). @since TP-180 */
47
+ exclude_extensions?: string[];
46
48
  };
47
49
  failure: {
48
50
  on_task_failure: "skip-dependents" | "stop-wave" | "stop-all";
@@ -323,7 +325,11 @@ export interface TaskRunnerConfig {
323
325
  thinking: string;
324
326
  /** Comma-separated tool allowlist */
325
327
  tools: string;
328
+ /** Package specifiers to exclude from extension forwarding (exact match). @since TP-180 */
329
+ excludeExtensions?: string[];
326
330
  };
331
+ /** Worker agent extension exclusion list. @since TP-180 */
332
+ workerExcludeExtensions?: string[];
327
333
  }
328
334
 
329
335
  /** Result of a preflight check */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskplane",
3
- "version": "0.28.2",
3
+ "version": "0.28.4",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",