taskplane 0.24.19 → 0.24.21

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.
@@ -507,7 +507,23 @@ export interface TaskplaneConfig {
507
507
  * | mergeModel | orchestrator.merge.model | string |
508
508
  * | supervisorModel | orchestrator.supervisor.model | string |
509
509
  * | dashboardPort | (preferences-only; not yet in schema)| number |
510
+ * | initAgentDefaults | (preferences-only; used by init UX) | object |
510
511
  */
512
+ export interface InitAgentDefaultsPreferences {
513
+ /** Worker model default for `taskplane init` prompts (empty = inherit) */
514
+ workerModel?: string;
515
+ /** Reviewer model default for `taskplane init` prompts (empty = inherit) */
516
+ reviewerModel?: string;
517
+ /** Merger model default for `taskplane init` prompts (empty = inherit) */
518
+ mergeModel?: string;
519
+ /** Worker thinking default for `taskplane init` prompts (`""`/`on`/`off`) */
520
+ workerThinking?: string;
521
+ /** Reviewer thinking default for `taskplane init` prompts (`""`/`on`/`off`) */
522
+ reviewerThinking?: string;
523
+ /** Merger thinking default for `taskplane init` prompts (`""`/`on`/`off`) */
524
+ mergeThinking?: string;
525
+ }
526
+
511
527
  export interface UserPreferences {
512
528
  /** Operator identifier (overrides orchestrator.orchestrator.operatorId) */
513
529
  operatorId?: string;
@@ -527,6 +543,8 @@ export interface UserPreferences {
527
543
  supervisorModel?: string;
528
544
  /** Dashboard port (preferences-only; not yet wired into config schema) */
529
545
  dashboardPort?: number;
546
+ /** Saved defaults used to pre-populate `taskplane init` model/thinking prompts */
547
+ initAgentDefaults?: InitAgentDefaultsPreferences;
530
548
  }
531
549
 
532
550
  /** Default (empty) user preferences — all fields undefined means "no override". */
@@ -553,8 +571,8 @@ export const DEFAULT_TASK_RUNNER_SECTION: TaskRunnerSection = {
553
571
  testing: { commands: {} },
554
572
  standards: { docs: [], rules: [] },
555
573
  standardsOverrides: {},
556
- worker: { model: "", tools: "read,write,edit,bash,grep,find,ls", thinking: "off" },
557
- reviewer: { model: "openai/gpt-5.3-codex", tools: "read,bash,grep,find,ls", thinking: "on" },
574
+ worker: { model: "", tools: "read,write,edit,bash,grep,find,ls", thinking: "" },
575
+ reviewer: { model: "", tools: "read,bash,grep,find,ls", thinking: "on" },
558
576
  context: {
559
577
  workerContextWindow: 0,
560
578
  warnPercent: 85,
@@ -609,9 +609,20 @@ export async function spawnMergeAgentV2(
609
609
  timeoutMs: (config.merge.timeout_minutes ?? 10) * 60 * 1000,
610
610
  stateRoot: stateRoot ?? repoRoot,
611
611
  packet: null,
612
- env: { ORCH_BATCH_ID: bid },
612
+ env: {
613
+ ORCH_BATCH_ID: bid,
614
+ // Isolate merge agent from operator's user preferences to prevent
615
+ // verification test contamination (e.g., stale reviewerModel pref
616
+ // overriding schema defaults in deep-equal assertions).
617
+ PI_CODING_AGENT_DIR: join(sidecarRoot, "runtime", bid, "merge-agent-env"),
618
+ },
613
619
  };
614
620
 
621
+ // Ensure isolated agent dir exists with empty preferences
622
+ const isolatedAgentDir = join(sidecarRoot, "runtime", bid, "merge-agent-env", "taskplane");
623
+ mkdirSync(isolatedAgentDir, { recursive: true });
624
+ try { writeFileSync(join(isolatedAgentDir, "preferences.json"), "{}\n", { flag: "wx" }); } catch { /* already exists */ }
625
+
615
626
  const { promise, kill } = spawnAgent(opts);
616
627
 
617
628
  // Store the kill handle for external cleanup (pause/abort).
@@ -49,7 +49,7 @@ export type FieldSource = "default" | "project" | "user";
49
49
  export type FieldLayer = "L1" | "L2" | "L1+L2";
50
50
 
51
51
  /** UI control type for a field */
52
- export type FieldControl = "toggle" | "input";
52
+ export type FieldControl = "toggle" | "input" | "picker";
53
53
 
54
54
  /** Field definition for the settings TUI */
55
55
  export interface FieldDef {
@@ -102,7 +102,7 @@ export const SECTIONS: SectionDef[] = [
102
102
  // The user-facing spawn mode setting is under Worker (controls /task behavior).
103
103
  { configPath: "orchestrator.orchestrator.sessionPrefix", label: "Session Prefix", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "sessionPrefix", description: "Prefix for orchestrator session names" },
104
104
  { configPath: "orchestrator.orchestrator.operatorId", label: "Operator ID", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "operatorId", description: "Operator identifier (empty = auto-detect)" },
105
- { configPath: "orchestrator.orchestrator.integration", label: "Integration", control: "toggle", layer: "L1", fieldType: "enum", values: ["manual", "supervised", "auto"], description: "How completed batches are integrated. manual = user runs /orch-integrate. supervised = supervisor proposes plan, asks confirmation. auto = supervisor executes without asking." },
105
+ { configPath: "orchestrator.orchestrator.integration", label: "Integration", control: "picker", layer: "L1", fieldType: "enum", values: ["manual", "supervised", "auto"], description: "How completed batches are integrated. manual = user runs /orch-integrate. supervised = supervisor proposes plan, asks confirmation. auto = supervisor executes without asking." },
106
106
  ],
107
107
  },
108
108
  {
@@ -129,7 +129,7 @@ export const SECTIONS: SectionDef[] = [
129
129
  fields: [
130
130
  { configPath: "orchestrator.merge.model", label: "Merge Model", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "mergeModel", description: "Merge-agent model (inherit = use session model)" },
131
131
  { configPath: "orchestrator.merge.tools", label: "Merge Tools", control: "input", layer: "L1", fieldType: "string", description: "Merge-agent tool allowlist" },
132
- { configPath: "orchestrator.merge.thinking", label: "Merge Thinking", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "mergeThinking", description: "Merge-agent thinking mode (empty = inherit session)" },
132
+ { configPath: "orchestrator.merge.thinking", label: "Merge Thinking", control: "picker", layer: "L1+L2", fieldType: "string", prefsKey: "mergeThinking", description: "Merge-agent thinking mode" },
133
133
  { configPath: "orchestrator.merge.order", label: "Merge Order", control: "toggle", layer: "L1", fieldType: "enum", values: ["fewest-files-first", "sequential"], description: "Lane merge ordering policy" },
134
134
  { 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)" },
135
135
  ],
@@ -154,7 +154,7 @@ export const SECTIONS: SectionDef[] = [
154
154
  name: "Supervisor",
155
155
  fields: [
156
156
  { configPath: "orchestrator.supervisor.model", label: "Supervisor Model", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "supervisorModel", description: "Supervisor model (inherit = use session model)" },
157
- { configPath: "orchestrator.supervisor.autonomy", label: "Autonomy Level", control: "toggle", layer: "L1", fieldType: "enum", values: ["interactive", "supervised", "autonomous"], description: "Recovery action confirmation behavior" },
157
+ { configPath: "orchestrator.supervisor.autonomy", label: "Autonomy Level", control: "picker", layer: "L1", fieldType: "enum", values: ["interactive", "supervised", "autonomous"], description: "Recovery action confirmation behavior" },
158
158
  ],
159
159
  },
160
160
  {
@@ -162,7 +162,7 @@ export const SECTIONS: SectionDef[] = [
162
162
  fields: [
163
163
  { configPath: "taskRunner.worker.model", label: "Worker Model", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "workerModel", description: "Worker model (inherit = use session model)" },
164
164
  { configPath: "taskRunner.worker.tools", label: "Worker Tools", control: "input", layer: "L1", fieldType: "string", description: "Worker tool allowlist" },
165
- { configPath: "taskRunner.worker.thinking", label: "Worker Thinking", control: "input", layer: "L1", fieldType: "string", description: "Worker thinking mode" },
165
+ { configPath: "taskRunner.worker.thinking", label: "Worker Thinking", control: "picker", layer: "L1", fieldType: "string", description: "Worker thinking mode" },
166
166
  { configPath: "taskRunner.worker.spawnMode", label: "Spawn Mode", control: "toggle", layer: "L1", fieldType: "enum", values: ["subprocess"], optional: true, description: "How /task spawns workers and reviewers. Runtime V2 supports subprocess only." },
167
167
  ],
168
168
  },
@@ -171,7 +171,7 @@ export const SECTIONS: SectionDef[] = [
171
171
  fields: [
172
172
  { configPath: "taskRunner.reviewer.model", label: "Reviewer Model", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "reviewerModel", description: "Reviewer model (inherit = use session model)" },
173
173
  { configPath: "taskRunner.reviewer.tools", label: "Reviewer Tools", control: "input", layer: "L1", fieldType: "string", description: "Reviewer tool allowlist" },
174
- { configPath: "taskRunner.reviewer.thinking", label: "Reviewer Thinking", control: "input", layer: "L1", fieldType: "string", description: "Reviewer thinking mode" },
174
+ { configPath: "taskRunner.reviewer.thinking", label: "Reviewer Thinking", control: "picker", layer: "L1", fieldType: "string", description: "Reviewer thinking mode" },
175
175
  ],
176
176
  },
177
177
  {
@@ -1003,6 +1003,131 @@ async function pickModel(ctx: ExtensionContext, currentModel: string): Promise<s
1003
1003
  }
1004
1004
  }
1005
1005
 
1006
+ type ThinkingModeValue = "" | "on" | "off";
1007
+
1008
+ const THINKING_MODE_OPTIONS: Array<{ value: ThinkingModeValue; label: string }> = [
1009
+ { value: "", label: "inherit (use session thinking)" },
1010
+ { value: "on", label: "on" },
1011
+ { value: "off", label: "off" },
1012
+ ];
1013
+
1014
+ function normalizeThinkingMode(value: unknown): ThinkingModeValue {
1015
+ const cleaned = String(value ?? "").trim().toLowerCase();
1016
+ if (cleaned === "on") return "on";
1017
+ if (cleaned === "off") return "off";
1018
+ return "";
1019
+ }
1020
+
1021
+ async function pickThinkingMode(
1022
+ ctx: ExtensionContext,
1023
+ currentThinking: string,
1024
+ ): Promise<ThinkingModeValue | undefined> {
1025
+ const current = normalizeThinkingMode(currentThinking);
1026
+ const optionToValue = new Map<string, ThinkingModeValue>();
1027
+ const optionLabels: string[] = [];
1028
+
1029
+ for (const option of THINKING_MODE_OPTIONS) {
1030
+ const label = `${option.label}${option.value === current ? " ✓ current" : ""}`;
1031
+ optionLabels.push(label);
1032
+ optionToValue.set(label, option.value);
1033
+ }
1034
+
1035
+ const selected = await selectScrollable(ctx, "Choose thinking mode", optionLabels, 8);
1036
+ if (!selected) return undefined;
1037
+ return optionToValue.get(selected);
1038
+ }
1039
+
1040
+ const MODEL_THINKING_PATH_MAP: Record<string, { thinkingPath: string; label: string }> = {
1041
+ "taskRunner.worker.model": { thinkingPath: "taskRunner.worker.thinking", label: "Worker" },
1042
+ "taskRunner.reviewer.model": { thinkingPath: "taskRunner.reviewer.thinking", label: "Reviewer" },
1043
+ "orchestrator.merge.model": { thinkingPath: "orchestrator.merge.thinking", label: "Merge" },
1044
+ };
1045
+
1046
+ function resolveModelRecord(ctx: ExtensionContext, modelRef: string): any | undefined {
1047
+ const trimmed = modelRef.trim();
1048
+ if (!trimmed) return undefined;
1049
+
1050
+ const available = ctx.modelRegistry.getAvailable();
1051
+ const lower = trimmed.toLowerCase();
1052
+ const slashIdx = trimmed.indexOf("/");
1053
+
1054
+ if (slashIdx > 0) {
1055
+ const provider = trimmed.slice(0, slashIdx).toLowerCase();
1056
+ const id = trimmed.slice(slashIdx + 1).toLowerCase();
1057
+ return available.find((m: any) =>
1058
+ String(m?.provider ?? "").toLowerCase() === provider
1059
+ && String(m?.id ?? "").toLowerCase() === id,
1060
+ );
1061
+ }
1062
+
1063
+ return available.find((m: any) =>
1064
+ String(m?.id ?? "").toLowerCase() === lower
1065
+ || `${String(m?.provider ?? "").toLowerCase()}/${String(m?.id ?? "").toLowerCase()}` === lower,
1066
+ );
1067
+ }
1068
+
1069
+ export function modelSupportsThinking(model: any): boolean {
1070
+ if (!model || typeof model !== "object") return false;
1071
+
1072
+ const boolFlags = [
1073
+ "supportsThinking",
1074
+ "thinking",
1075
+ "supportsReasoning",
1076
+ "supportsReasoningEffort",
1077
+ "supportsReasoningTokens",
1078
+ "reasoning",
1079
+ ];
1080
+ const capabilityKeys = [
1081
+ "reasoningEffort",
1082
+ "reasoningTokens",
1083
+ "thinkingModes",
1084
+ "thinkingMode",
1085
+ "reasoning_effort",
1086
+ "reasoning_tokens",
1087
+ ];
1088
+
1089
+ const candidateObjects = [
1090
+ model,
1091
+ model.capabilities,
1092
+ model.features,
1093
+ model.metadata,
1094
+ ].filter((entry) => entry && typeof entry === "object");
1095
+
1096
+ for (const candidate of candidateObjects) {
1097
+ for (const key of boolFlags) {
1098
+ if (typeof candidate[key] === "boolean" && candidate[key]) return true;
1099
+ }
1100
+ for (const key of capabilityKeys) {
1101
+ if (candidate[key] !== undefined && candidate[key] !== null) return true;
1102
+ }
1103
+ }
1104
+
1105
+ return false;
1106
+ }
1107
+
1108
+ export function buildThinkingSuggestionForModelChange(
1109
+ ctx: ExtensionContext,
1110
+ field: FieldDef,
1111
+ previousModelValue: string,
1112
+ nextModelValue: string,
1113
+ mergedConfig: TaskplaneConfig,
1114
+ ): string | null {
1115
+ const mapping = MODEL_THINKING_PATH_MAP[field.configPath];
1116
+ if (!mapping) return null;
1117
+
1118
+ const previousNormalized = previousModelValue.trim().toLowerCase();
1119
+ const nextNormalized = nextModelValue.trim().toLowerCase();
1120
+ if (!nextNormalized || previousNormalized === nextNormalized) return null;
1121
+
1122
+ const modelRecord = resolveModelRecord(ctx, nextModelValue);
1123
+ if (!modelRecord || !modelSupportsThinking(modelRecord)) return null;
1124
+
1125
+ const currentThinking = normalizeThinkingMode(getNestedValue(mergedConfig, mapping.thinkingPath));
1126
+ if (currentThinking === "on") return null;
1127
+
1128
+ return `${mapping.label} model supports thinking. Consider setting ${mapping.label} Thinking to \"on\".`;
1129
+ }
1130
+
1006
1131
  /**
1007
1132
  * Scrollable select list for model/provider picking.
1008
1133
  * Uses pi's TUI custom widget API.
@@ -1247,20 +1372,34 @@ async function showSectionSettingsLoop(
1247
1372
  const field = section.fields.find((f) => f.configPath === result.fieldId);
1248
1373
  if (!field) continue; // Safety: field not found
1249
1374
 
1250
- // Input fields: the submenu returned a sentinel — use ctx.ui.input() for actual editing
1251
- if (result.rawValue === "__EDIT_REQUESTED__" && field.control === "input") {
1375
+ let previousModelValue = "";
1376
+
1377
+ // Input/picker fields: the submenu returned a sentinel — open the editor picker.
1378
+ if (result.rawValue === "__EDIT_REQUESTED__" && (field.control === "input" || field.control === "picker")) {
1379
+ const state = loadConfigState(configRoot, pointerConfigRoot);
1380
+ const currentDisplay = getFieldDisplayValue(field, state.mergedConfig, state.prefs);
1381
+ const currentClean = String(currentDisplay).replace(/\s+\((?:default|project|user)\)$/, "");
1382
+ const normalizedCurrent = currentClean === "(inherit)" ? "" : currentClean;
1383
+
1252
1384
  // Model fields: use interactive provider → model picker instead of free-text
1253
1385
  if (field.configPath.endsWith(".model")) {
1254
- const state = loadConfigState(configRoot, pointerConfigRoot);
1255
- const currentDisplay = getFieldDisplayValue(field, state.mergedConfig, state.prefs);
1256
- const currentClean = String(currentDisplay).replace(/\s+\((?:default|project|user)\)$/, "");
1257
- const selected = await pickModel(ctx, currentClean === "(inherit)" ? "" : currentClean);
1386
+ previousModelValue = normalizedCurrent;
1387
+ const selected = await pickModel(ctx, normalizedCurrent);
1388
+ if (selected === undefined) continue; // Cancelled
1389
+ result.rawValue = selected;
1390
+ } else if (field.control === "picker" && field.configPath.endsWith(".thinking")) {
1391
+ const selected = await pickThinkingMode(ctx, normalizedCurrent);
1258
1392
  if (selected === undefined) continue; // Cancelled
1259
1393
  result.rawValue = selected;
1394
+ } else if (field.control === "picker" && field.values && field.values.length > 0) {
1395
+ // Enum picker: show scrollable list of allowed values
1396
+ const options = field.values.map((v) =>
1397
+ `${v}${v === normalizedCurrent ? " ✓ current" : ""}`
1398
+ );
1399
+ const selected = await selectScrollable(ctx, field.label, options);
1400
+ if (!selected) continue; // Cancelled
1401
+ result.rawValue = selected.replace(/\s+✓ current$/, "");
1260
1402
  } else {
1261
- const state = loadConfigState(configRoot, pointerConfigRoot);
1262
- const currentDisplay = getFieldDisplayValue(field, state.mergedConfig, state.prefs);
1263
- const currentClean = String(currentDisplay).replace(/\s+\((?:default|project|user)\)$/, "");
1264
1403
  const placeholder = currentClean === "(not set)" || currentClean === "(inherit)" ? "" : currentClean;
1265
1404
 
1266
1405
  const newValue = await ctx.ui.input(
@@ -1327,6 +1466,18 @@ async function showSectionSettingsLoop(
1327
1466
  `ℹ Restart session to apply changes.`,
1328
1467
  "info",
1329
1468
  );
1469
+
1470
+ const refreshedState = loadConfigState(configRoot, pointerConfigRoot);
1471
+ const suggestion = buildThinkingSuggestionForModelChange(
1472
+ ctx,
1473
+ field,
1474
+ previousModelValue,
1475
+ String(result.rawValue ?? ""),
1476
+ refreshedState.mergedConfig,
1477
+ );
1478
+ if (suggestion) {
1479
+ ctx.ui.notify(`💡 ${suggestion}`, "info");
1480
+ }
1330
1481
  } catch (err: any) {
1331
1482
  ctx.ui.notify(`❌ Failed to save: ${err.message}`, "error");
1332
1483
  }
@@ -1375,7 +1526,7 @@ async function showSectionSettingsOnce(
1375
1526
  // The inline submenu approach freezes on Windows/tmux (issue #57).
1376
1527
  // We set a single sentinel value so pressing Enter/Space triggers onChange,
1377
1528
  // which exits the TUI. The caller then uses ctx.ui.input() for actual editing.
1378
- if (field.control === "input") {
1529
+ if (field.control === "input" || field.control === "picker") {
1379
1530
  item.values = [`__EDIT_REQUESTED__`];
1380
1531
  }
1381
1532
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskplane",
3
- "version": "0.24.19",
3
+ "version": "0.24.21",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -47,7 +47,7 @@ standards_overrides: {}
47
47
  worker:
48
48
  model: "" # empty = inherit from parent pi session
49
49
  tools: "read,write,edit,bash,grep,find,ls"
50
- thinking: "off"
50
+ thinking: "" # empty = inherit from parent pi session
51
51
  # spawn_mode: "subprocess" # currently supported runtime mode
52
52
 
53
53
  reviewer: