taskplane 0.24.15 → 0.24.17

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.
@@ -2,7 +2,7 @@ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-age
2
2
  import { Type } from "@mariozechner/pi-ai";
3
3
 
4
4
  import { execSync, execFileSync } from "child_process";
5
- import { writeFileSync, unlinkSync, mkdirSync, existsSync, readdirSync, readFileSync, statSync, createWriteStream } from "fs";
5
+ import { writeFileSync, unlinkSync, mkdirSync, existsSync, readdirSync, readFileSync, statSync, createWriteStream, renameSync } from "fs";
6
6
  import { join, dirname } from "path";
7
7
  import { fileURLToPath } from "url";
8
8
  import { fork, type ChildProcess } from "child_process";
@@ -365,6 +365,49 @@ export interface IntegrationExecDeps {
365
365
  deleteBatchState: () => void;
366
366
  }
367
367
 
368
+ interface BatchHistorySnapshot {
369
+ filePath: string;
370
+ raw: string;
371
+ }
372
+
373
+ /**
374
+ * Preserve `.pi/batch-history.json` across integration merges.
375
+ *
376
+ * Runtime history is sidecar state, not source-controlled content. In environments
377
+ * where `.pi/batch-history.json` was previously tracked, merge/checkouts can
378
+ * replace newer runtime history with stale branch snapshots. This helper snapshots
379
+ * the file before integration and restores it afterward (best effort).
380
+ */
381
+ export function withPreservedBatchHistory<T>(stateRoot: string, operation: () => T): T {
382
+ const historyPath = join(stateRoot, ".pi", "batch-history.json");
383
+ let snapshot: BatchHistorySnapshot | null = null;
384
+ try {
385
+ if (existsSync(historyPath)) {
386
+ snapshot = {
387
+ filePath: historyPath,
388
+ raw: readFileSync(historyPath, "utf-8"),
389
+ };
390
+ }
391
+ } catch {
392
+ // Best effort only — integration should never fail due to snapshot capture.
393
+ }
394
+
395
+ try {
396
+ return operation();
397
+ } finally {
398
+ if (!snapshot) return;
399
+ try {
400
+ const dir = dirname(snapshot.filePath);
401
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
402
+ const tmpPath = snapshot.filePath + ".tmp";
403
+ writeFileSync(tmpPath, snapshot.raw);
404
+ renameSync(tmpPath, snapshot.filePath);
405
+ } catch {
406
+ // Best effort only — never block integration completion.
407
+ }
408
+ }
409
+ }
410
+
368
411
  /**
369
412
  * Execute the integration operation for the resolved context.
370
413
  *
@@ -1324,10 +1367,13 @@ export function buildIntegrationExecutor(repoRoot: string, opId?: string, stateR
1324
1367
  },
1325
1368
  };
1326
1369
 
1327
- const result = executeIntegration(mode as IntegrateMode, {
1328
- ...context,
1329
- currentBranch: context.baseBranch,
1330
- }, deps);
1370
+ const effectiveStateRoot = stateRoot ?? repoRoot;
1371
+ const result = withPreservedBatchHistory(effectiveStateRoot, () =>
1372
+ executeIntegration(mode as IntegrateMode, {
1373
+ ...context,
1374
+ currentBranch: context.baseBranch,
1375
+ }, deps),
1376
+ );
1331
1377
 
1332
1378
  // TP-051: Clean up stale task/* and saved/* branches after successful integration.
1333
1379
  // This ensures auto-mode integration (supervisor path) gets the same cleanup
@@ -3116,44 +3162,51 @@ export default function (pi: ExtensionAPI) {
3116
3162
  reposToIntegrate.push({ id: "(default)", root: repoRoot });
3117
3163
  }
3118
3164
 
3119
- let totalCommits = 0;
3120
- let allSucceeded = true;
3121
- const repoMessages: string[] = [];
3122
-
3123
- for (const repo of reposToIntegrate) {
3124
- const preCountResult = runGit(["rev-list", "--count", `HEAD..${resolvedOrchBranch}`], repo.root);
3125
- const repoCommitsBefore = preCountResult.ok ? parseInt(preCountResult.stdout) || 0 : 0;
3126
-
3127
- const integrationResult = executeIntegration(parsed.mode, resolution as IntegrationContext, {
3128
- runGit: (gitArgs: string[]) => runGit(gitArgs, repo.root),
3129
- runCommand: (cmd: string, cmdArgs: string[]) => {
3130
- try {
3131
- const stdout = execFileSync(cmd, cmdArgs, {
3132
- encoding: "utf-8",
3133
- timeout: 60_000,
3134
- cwd: repo.root,
3135
- stdio: ["pipe", "pipe", "pipe"],
3136
- }).trim();
3137
- return { ok: true, stdout, stderr: "" };
3138
- } catch (err: unknown) {
3139
- const e = err as { stdout?: string; stderr?: string; message?: string };
3140
- return {
3141
- ok: false,
3142
- stdout: (e.stdout ?? "").toString().trim(),
3143
- stderr: (e.stderr ?? e.message ?? "unknown error").toString().trim(),
3144
- };
3145
- }
3146
- },
3147
- deleteBatchState: () => { /* handled once after all repos */ },
3148
- });
3165
+ const integrationRun = withPreservedBatchHistory(stateRoot, () => {
3166
+ let totalCommits = 0;
3167
+ const repoMessages: string[] = [];
3168
+
3169
+ for (const repo of reposToIntegrate) {
3170
+ const preCountResult = runGit(["rev-list", "--count", `HEAD..${resolvedOrchBranch}`], repo.root);
3171
+ const repoCommitsBefore = preCountResult.ok ? parseInt(preCountResult.stdout) || 0 : 0;
3149
3172
 
3150
- if (!integrationResult.success) {
3151
- return { message: `❌ Integration failed in ${repo.id}:\n${integrationResult.error}`, error: true };
3173
+ const integrationResult = executeIntegration(parsed.mode, resolution as IntegrationContext, {
3174
+ runGit: (gitArgs: string[]) => runGit(gitArgs, repo.root),
3175
+ runCommand: (cmd: string, cmdArgs: string[]) => {
3176
+ try {
3177
+ const stdout = execFileSync(cmd, cmdArgs, {
3178
+ encoding: "utf-8",
3179
+ timeout: 60_000,
3180
+ cwd: repo.root,
3181
+ stdio: ["pipe", "pipe", "pipe"],
3182
+ }).trim();
3183
+ return { ok: true, stdout, stderr: "" };
3184
+ } catch (err: unknown) {
3185
+ const e = err as { stdout?: string; stderr?: string; message?: string };
3186
+ return {
3187
+ ok: false,
3188
+ stdout: (e.stdout ?? "").toString().trim(),
3189
+ stderr: (e.stderr ?? e.message ?? "unknown error").toString().trim(),
3190
+ };
3191
+ }
3192
+ },
3193
+ deleteBatchState: () => { /* handled once after all repos */ },
3194
+ });
3195
+
3196
+ if (!integrationResult.success) {
3197
+ return { ok: false as const, error: `❌ Integration failed in ${repo.id}:\n${integrationResult.error}` };
3198
+ }
3199
+
3200
+ totalCommits += repoCommitsBefore;
3201
+ repoMessages.push(` ${repo.id}: ${integrationResult.message}`);
3152
3202
  }
3153
3203
 
3154
- totalCommits += repoCommitsBefore;
3155
- repoMessages.push(` ${repo.id}: ${integrationResult.message}`);
3204
+ return { ok: true as const, totalCommits, repoMessages };
3205
+ });
3206
+ if (!integrationRun.ok) {
3207
+ return { message: integrationRun.error, error: true };
3156
3208
  }
3209
+ const { totalCommits, repoMessages } = integrationRun;
3157
3210
 
3158
3211
  // Post-integration cleanup & acceptance
3159
3212
  const allRepos: { id: string; root: string }[] = [];
@@ -1841,18 +1841,21 @@ export function saveBatchHistory(repoRoot: string, summary: BatchHistorySummary)
1841
1841
  const filePath = batchHistoryPath(repoRoot);
1842
1842
  try {
1843
1843
  const history = loadBatchHistory(repoRoot);
1844
+ // Upsert by batchId so resumed batches replace their earlier partial entry
1845
+ // instead of creating duplicates.
1846
+ const nextHistory = history.filter(entry => entry.batchId !== summary.batchId);
1844
1847
  // Prepend newest first
1845
- history.unshift(summary);
1848
+ nextHistory.unshift(summary);
1846
1849
  // Trim to max
1847
- if (history.length > BATCH_HISTORY_MAX_ENTRIES) {
1848
- history.length = BATCH_HISTORY_MAX_ENTRIES;
1850
+ if (nextHistory.length > BATCH_HISTORY_MAX_ENTRIES) {
1851
+ nextHistory.length = BATCH_HISTORY_MAX_ENTRIES;
1849
1852
  }
1850
1853
  const dir = dirname(filePath);
1851
1854
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
1852
1855
  const tmpPath = filePath + ".tmp";
1853
- writeFileSync(tmpPath, JSON.stringify(history, null, 2));
1856
+ writeFileSync(tmpPath, JSON.stringify(nextHistory, null, 2));
1854
1857
  renameSync(tmpPath, filePath);
1855
- execLog("batch", "history", `saved batch summary (${history.length} entries)`);
1858
+ execLog("batch", "history", `saved batch summary (${nextHistory.length} entries)`);
1856
1859
  } catch (err) {
1857
1860
  execLog("batch", "history", `failed to save batch history: ${err}`);
1858
1861
  }
@@ -127,7 +127,7 @@ export const SECTIONS: SectionDef[] = [
127
127
  {
128
128
  name: "Merge",
129
129
  fields: [
130
- { configPath: "orchestrator.merge.model", label: "Merge Model", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "mergeModel", description: "Merge-agent model (empty = inherit session)" },
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
132
  { configPath: "orchestrator.merge.order", label: "Merge Order", control: "toggle", layer: "L1", fieldType: "enum", values: ["fewest-files-first", "sequential"], description: "Lane merge ordering policy" },
133
133
  { 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)" },
@@ -152,14 +152,14 @@ export const SECTIONS: SectionDef[] = [
152
152
  {
153
153
  name: "Supervisor",
154
154
  fields: [
155
- { configPath: "orchestrator.supervisor.model", label: "Supervisor Model", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "supervisorModel", description: "Supervisor model (empty = inherit session)" },
155
+ { configPath: "orchestrator.supervisor.model", label: "Supervisor Model", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "supervisorModel", description: "Supervisor model (inherit = use session model)" },
156
156
  { configPath: "orchestrator.supervisor.autonomy", label: "Autonomy Level", control: "toggle", layer: "L1", fieldType: "enum", values: ["interactive", "supervised", "autonomous"], description: "Recovery action confirmation behavior" },
157
157
  ],
158
158
  },
159
159
  {
160
160
  name: "Worker",
161
161
  fields: [
162
- { configPath: "taskRunner.worker.model", label: "Worker Model", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "workerModel", description: "Worker model (empty = inherit session)" },
162
+ { configPath: "taskRunner.worker.model", label: "Worker Model", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "workerModel", description: "Worker model (inherit = use session model)" },
163
163
  { configPath: "taskRunner.worker.tools", label: "Worker Tools", control: "input", layer: "L1", fieldType: "string", description: "Worker tool allowlist" },
164
164
  { configPath: "taskRunner.worker.thinking", label: "Worker Thinking", control: "input", layer: "L1", fieldType: "string", description: "Worker thinking mode" },
165
165
  { 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." },
@@ -168,7 +168,7 @@ export const SECTIONS: SectionDef[] = [
168
168
  {
169
169
  name: "Reviewer",
170
170
  fields: [
171
- { configPath: "taskRunner.reviewer.model", label: "Reviewer Model", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "reviewerModel", description: "Reviewer model (empty = inherit session)" },
171
+ { configPath: "taskRunner.reviewer.model", label: "Reviewer Model", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "reviewerModel", description: "Reviewer model (inherit = use session model)" },
172
172
  { configPath: "taskRunner.reviewer.tools", label: "Reviewer Tools", control: "input", layer: "L1", fieldType: "string", description: "Reviewer tool allowlist" },
173
173
  { configPath: "taskRunner.reviewer.thinking", label: "Reviewer Thinking", control: "input", layer: "L1", fieldType: "string", description: "Reviewer thinking mode" },
174
174
  ],
@@ -929,6 +929,134 @@ function summarizeArray(arr: any[]): string {
929
929
  * @param configRoot - Workspace/repo root (from execCtx.workspaceRoot)
930
930
  * @param pointerConfigRoot - Optional pointer-resolved config root (workspace mode)
931
931
  */
932
+ // ── Model Picker (Sage-style provider → model selection) ────────────
933
+
934
+ /**
935
+ * Interactive two-level model picker: provider first, then model within provider.
936
+ * Returns the selected model string (e.g., "anthropic/claude-sonnet-4-20250514")
937
+ * or "" for inherit, or undefined if cancelled.
938
+ *
939
+ * Adapted from Sage's pickModel implementation.
940
+ */
941
+ async function pickModel(ctx: ExtensionContext, currentModel: string): Promise<string | undefined> {
942
+ const available = ctx.modelRegistry.getAvailable();
943
+ if (available.length === 0) {
944
+ ctx.ui.notify("No available models found in pi model registry", "warning");
945
+ // Fall back to manual input
946
+ const manual = await ctx.ui.input("Model (provider/model-id, or empty for inherit)", currentModel || "");
947
+ if (manual === null || manual === undefined) return undefined;
948
+ return manual;
949
+ }
950
+
951
+ const currentLower = (currentModel || "").trim().toLowerCase();
952
+ const providers = [...new Set(available.map((m: any) => m.provider))].sort();
953
+
954
+ while (true) {
955
+ // Level 1: Provider selection (with "inherit" as first option)
956
+ const providerOptions: string[] = [
957
+ "inherit (use current session model)",
958
+ ...providers.map((p: string) => {
959
+ const count = available.filter((m: any) => m.provider === p).length;
960
+ return `${p} (${count} models)`;
961
+ }),
962
+ ];
963
+
964
+ const providerChoice = await selectScrollable(ctx, "Choose model provider", providerOptions);
965
+ if (!providerChoice) return undefined; // Cancelled
966
+
967
+ if (providerChoice.startsWith("inherit")) {
968
+ return ""; // Empty string = inherit
969
+ }
970
+
971
+ // Extract provider name (strip " (N models)" suffix)
972
+ const provider = providerChoice.replace(/\s*\(\d+ models?\)$/, "");
973
+ const providerModels = available
974
+ .filter((m: any) => m.provider === provider)
975
+ .sort((a: any, b: any) => {
976
+ // Current model first, then alphabetical
977
+ const aComposite = `${a.provider}/${a.id}`.toLowerCase();
978
+ const bComposite = `${b.provider}/${b.id}`.toLowerCase();
979
+ if (aComposite === currentLower) return -1;
980
+ if (bComposite === currentLower) return 1;
981
+ return a.id.localeCompare(b.id);
982
+ });
983
+
984
+ // Level 2: Model selection within provider
985
+ const modelOptionMap = new Map<string, string>();
986
+ const modelOptions = ["← Back to providers"];
987
+
988
+ for (const model of providerModels) {
989
+ const composite = `${model.provider}/${model.id}`;
990
+ const isCurrent = composite.toLowerCase() === currentLower;
991
+ const label = `${model.id}${isCurrent ? " ✓ current" : ""}`;
992
+ modelOptions.push(label);
993
+ modelOptionMap.set(label, composite);
994
+ }
995
+
996
+ const modelChoice = await selectScrollable(ctx, `Choose model (${provider})`, modelOptions);
997
+ if (!modelChoice) continue; // Cancelled → back to providers
998
+ if (modelChoice === "← Back to providers") continue;
999
+
1000
+ const resolved = modelOptionMap.get(modelChoice);
1001
+ if (resolved) return resolved;
1002
+ }
1003
+ }
1004
+
1005
+ /**
1006
+ * Scrollable select list for model/provider picking.
1007
+ * Uses pi's TUI custom widget API.
1008
+ */
1009
+ async function selectScrollable(
1010
+ ctx: ExtensionContext,
1011
+ title: string,
1012
+ options: string[],
1013
+ maxVisible = 12,
1014
+ ): Promise<string | undefined> {
1015
+ if (options.length === 0) return undefined;
1016
+
1017
+ const items: SelectItem[] = options.map((option, index) => ({
1018
+ value: String(index),
1019
+ label: option,
1020
+ }));
1021
+
1022
+ const selectedValue = await ctx.ui.custom<string | undefined>((tui, theme, _keybindings, done) => {
1023
+ const container = new Container();
1024
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1025
+ container.addChild(new Text(theme.fg("accent", title), 1, 0));
1026
+ container.addChild(new Text("", 0, 0));
1027
+
1028
+ const selectList = new SelectList(items, Math.max(3, Math.min(maxVisible, items.length)), {
1029
+ selectedPrefix: (text: string) => theme.fg("accent", text),
1030
+ selectedText: (text: string) => theme.fg("accent", text),
1031
+ description: (text: string) => theme.fg("muted", text),
1032
+ scrollInfo: (text: string) => theme.fg("dim", text),
1033
+ noMatch: (text: string) => theme.fg("warning", text),
1034
+ });
1035
+
1036
+ selectList.onSelect = (item) => done(item.value);
1037
+ selectList.onCancel = () => done(undefined);
1038
+ container.addChild(selectList);
1039
+
1040
+ container.addChild(new Text("", 0, 0));
1041
+ container.addChild(new Text(theme.fg("dim", "↑↓ navigate • type to filter • enter select • esc back"), 1, 0));
1042
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1043
+
1044
+ return {
1045
+ render: (width: number) => container.render(width),
1046
+ invalidate: () => container.invalidate(),
1047
+ handleInput: (data: string) => {
1048
+ selectList.handleInput(data);
1049
+ tui.requestRender();
1050
+ },
1051
+ };
1052
+ });
1053
+
1054
+ if (selectedValue === undefined) return undefined;
1055
+ const selectedIndex = Number(selectedValue);
1056
+ if (!Number.isInteger(selectedIndex) || selectedIndex < 0 || selectedIndex >= options.length) return undefined;
1057
+ return options[selectedIndex];
1058
+ }
1059
+
932
1060
  export async function openSettingsTui(
933
1061
  ctx: ExtensionContext,
934
1062
  configRoot: string,
@@ -1120,26 +1248,36 @@ async function showSectionSettingsLoop(
1120
1248
 
1121
1249
  // Input fields: the submenu returned a sentinel — use ctx.ui.input() for actual editing
1122
1250
  if (result.rawValue === "__EDIT_REQUESTED__" && field.control === "input") {
1123
- const state = loadConfigState(configRoot, pointerConfigRoot);
1124
- const currentDisplay = getFieldDisplayValue(field, state.mergedConfig, state.prefs);
1125
- const currentClean = String(currentDisplay).replace(/\s+\((?:default|project|user)\)$/, "");
1126
- const placeholder = currentClean === "(not set)" || currentClean === "(inherit)" ? "" : currentClean;
1127
-
1128
- const newValue = await ctx.ui.input(
1129
- `${field.label}${field.description ? ` ${field.description}` : ""}`,
1130
- placeholder,
1131
- );
1132
-
1133
- if (newValue === null || newValue === undefined) continue; // Cancelled
1251
+ // Model fields: use interactive provider → model picker instead of free-text
1252
+ if (field.configPath.endsWith(".model")) {
1253
+ const state = loadConfigState(configRoot, pointerConfigRoot);
1254
+ const currentDisplay = getFieldDisplayValue(field, state.mergedConfig, state.prefs);
1255
+ const currentClean = String(currentDisplay).replace(/\s+\((?:default|project|user)\)$/, "");
1256
+ const selected = await pickModel(ctx, currentClean === "(inherit)" ? "" : currentClean);
1257
+ if (selected === undefined) continue; // Cancelled
1258
+ result.rawValue = selected;
1259
+ } else {
1260
+ const state = loadConfigState(configRoot, pointerConfigRoot);
1261
+ const currentDisplay = getFieldDisplayValue(field, state.mergedConfig, state.prefs);
1262
+ const currentClean = String(currentDisplay).replace(/\s+\((?:default|project|user)\)$/, "");
1263
+ const placeholder = currentClean === "(not set)" || currentClean === "(inherit)" ? "" : currentClean;
1264
+
1265
+ const newValue = await ctx.ui.input(
1266
+ `${field.label}${field.description ? ` — ${field.description}` : ""}`,
1267
+ placeholder,
1268
+ );
1269
+
1270
+ if (newValue === null || newValue === undefined) continue; // Cancelled
1271
+
1272
+ // Validate
1273
+ const validation = validateFieldInput(field, newValue);
1274
+ if (!validation.valid) {
1275
+ ctx.ui.notify(`❌ Invalid value: ${validation.error}`, "error");
1276
+ continue;
1277
+ }
1134
1278
 
1135
- // Validate
1136
- const validation = validateFieldInput(field, newValue);
1137
- if (!validation.valid) {
1138
- ctx.ui.notify(`❌ Invalid value: ${validation.error}`, "error");
1139
- continue;
1279
+ result.rawValue = newValue;
1140
1280
  }
1141
-
1142
- result.rawValue = newValue;
1143
1281
  }
1144
1282
 
1145
1283
  const typedValue = coerceValueForWrite(field, result.rawValue);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskplane",
3
- "version": "0.24.15",
3
+ "version": "0.24.17",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",