taskplane 0.23.16 → 0.24.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.
package/bin/taskplane.mjs CHANGED
@@ -234,7 +234,7 @@ orchestrator:
234
234
  worktree_prefix: "${vars.worktree_prefix}"
235
235
  batch_id_format: "timestamp"
236
236
  spawn_mode: "${vars.spawn_mode}"
237
- tmux_prefix: "${vars.tmux_prefix}"
237
+ session_prefix: "${vars.session_prefix}"
238
238
 
239
239
  dependencies:
240
240
  source: "prompt"
@@ -315,7 +315,7 @@ function generateProjectConfig(vars) {
315
315
  worktreePrefix: vars.worktree_prefix,
316
316
  batchIdFormat: "timestamp",
317
317
  spawnMode: vars.spawn_mode,
318
- tmuxPrefix: vars.tmux_prefix,
318
+ sessionPrefix: vars.session_prefix,
319
319
  operatorId: "",
320
320
  },
321
321
  dependencies: { source: "prompt", cache: true },
@@ -993,23 +993,6 @@ function detectInitMode(dir) {
993
993
  };
994
994
  }
995
995
 
996
- // ─── tmux / spawn mode detection ────────────────────────────────────────────
997
-
998
- /**
999
- * Detect whether tmux is available and determine the default spawn_mode.
1000
- *
1001
- * Reusable for both repo mode (Step 3) and workspace mode (Step 4) init.
1002
- *
1003
- * @returns {{ spawnMode: string, hasTmux: boolean }}
1004
- */
1005
- function detectSpawnMode() {
1006
- const hasTmux = commandExists("tmux");
1007
- return {
1008
- spawnMode: hasTmux ? "tmux" : "subprocess",
1009
- hasTmux,
1010
- };
1011
- }
1012
-
1013
996
  // ─── init ───────────────────────────────────────────────────────────────────
1014
997
 
1015
998
  async function cmdInit(args) {
@@ -1308,14 +1291,8 @@ async function cmdInit(args) {
1308
1291
  vars = await getInteractiveVars(projectRoot, tasksRootOverride);
1309
1292
  }
1310
1293
 
1311
- // ── tmux / spawn mode detection ─────────────────────────────
1312
- const { spawnMode, hasTmux } = detectSpawnMode();
1313
- vars.spawn_mode = spawnMode;
1314
-
1315
- if (preset !== "runner-only" && !hasTmux) {
1316
- console.log(` ${WARN} tmux not found. Using subprocess mode.`);
1317
- console.log(` Run ${c.cyan}taskplane install-tmux${c.reset} for full orchestrator support.\n`);
1318
- }
1294
+ // Runtime V2 is subprocess-only.
1295
+ vars.spawn_mode = "subprocess";
1319
1296
 
1320
1297
  const exampleTemplateDirs = noExamples ? [] : listExampleTaskTemplates();
1321
1298
 
@@ -1530,18 +1507,8 @@ async function cmdInit(args) {
1530
1507
  vars = await getInteractiveVars(projectRoot, tasksRootOverride);
1531
1508
  }
1532
1509
 
1533
- // ── tmux / spawn mode detection ──────────────────────────────
1534
- // Detect tmux availability and set spawn_mode for orchestrator config.
1535
- // Runs for all init modes (repo and workspace) per spec.
1536
- // Silent when tmux is found; shows guidance when missing.
1537
- // Skipped for runner-only preset (no orchestrator config generated).
1538
- const { spawnMode, hasTmux } = detectSpawnMode();
1539
- vars.spawn_mode = spawnMode;
1540
-
1541
- if (preset !== "runner-only" && !hasTmux) {
1542
- console.log(` ${WARN} tmux not found. Using subprocess mode.`);
1543
- console.log(` Run ${c.cyan}taskplane install-tmux${c.reset} for full orchestrator support.\n`);
1544
- }
1510
+ // Runtime V2 is subprocess-only.
1511
+ vars.spawn_mode = "subprocess";
1545
1512
 
1546
1513
  const exampleTemplateDirs = noExamples ? [] : listExampleTaskTemplates();
1547
1514
 
@@ -1672,7 +1639,7 @@ function getPresetVars(preset, projectRoot, tasksRootOverride = null) {
1672
1639
  project_name: dirName,
1673
1640
  max_lanes: 3,
1674
1641
  worktree_prefix: `${slug}-wt`,
1675
- tmux_prefix: `${slug}-orch`,
1642
+ session_prefix: `${slug}-orch`,
1676
1643
  tasks_root: tasksRootOverride || "taskplane-tasks",
1677
1644
  default_area: "general",
1678
1645
  default_prefix: "TP",
@@ -1699,7 +1666,7 @@ async function getInteractiveVars(projectRoot, tasksRootOverride = null) {
1699
1666
  project_name,
1700
1667
  max_lanes,
1701
1668
  worktree_prefix: `${slug}-wt`,
1702
- tmux_prefix: `${slug}-orch`,
1669
+ session_prefix: `${slug}-orch`,
1703
1670
  tasks_root,
1704
1671
  default_area,
1705
1672
  default_prefix,
@@ -160,19 +160,18 @@ function showCopyToast(text) {
160
160
  toastTimer = setTimeout(() => toastEl.classList.remove("visible"), 2000);
161
161
  }
162
162
 
163
- function copyTmuxCmd(sessionName) {
164
- const cmd = `tmux attach -t ${sessionName}`;
165
- navigator.clipboard.writeText(cmd).then(() => {
166
- showCopyToast(cmd);
163
+ function copySessionId(sessionName) {
164
+ navigator.clipboard.writeText(sessionName).then(() => {
165
+ showCopyToast(`session ${sessionName}`);
167
166
  // Flash the button
168
- const btn = document.querySelector(`[data-tmux="${sessionName}"]`);
167
+ const btn = document.querySelector(`[data-session="${sessionName}"]`);
169
168
  if (btn) {
170
169
  btn.classList.add("copied");
171
170
  setTimeout(() => btn.classList.remove("copied"), 1500);
172
171
  }
173
172
  }).catch(() => {
174
173
  // Fallback: select the text
175
- const btn = document.querySelector(`[data-tmux="${sessionName}"]`);
174
+ const btn = document.querySelector(`[data-session="${sessionName}"]`);
176
175
  if (btn) {
177
176
  const range = document.createRange();
178
177
  range.selectNodeContents(btn);
@@ -517,30 +516,31 @@ function renderLanesTasks(batch, tmuxSessions) {
517
516
  }
518
517
 
519
518
  // TP-107: check V2 registry for liveness first, fall back to tmux
519
+ const laneSessionId = lane.laneSessionId;
520
520
  const v2Alive = isLaneAliveV2(lane.laneNumber);
521
- const alive = v2Alive !== null ? v2Alive : tmuxSet.has(lane.tmuxSessionName);
522
- const tmuxCmd = `tmux attach -t ${lane.tmuxSessionName}`;
521
+ const alive = v2Alive !== null ? v2Alive : tmuxSet.has(laneSessionId);
522
+ const sessionChip = `session: ${laneSessionId}`;
523
523
 
524
524
  // Lane header
525
525
  html += `<div class="lane-group">`;
526
526
  html += `<div class="lane-header">`;
527
527
  html += ` <span class="lane-num">${lane.laneNumber}</span>`;
528
528
  html += ` <div class="lane-meta">`;
529
- html += ` <span class="lane-session">${escapeHtml(lane.tmuxSessionName || "—")}</span>`;
529
+ html += ` <span class="lane-session">${escapeHtml(laneSessionId || "—")}</span>`;
530
530
  html += ` <span class="lane-branch">${escapeHtml(lane.branch || "—")}</span>`;
531
531
  if (showRepos && lane.repoId) {
532
532
  html += ` ${repoBadgeHtml(lane.repoId, "repo-badge-lane")}`;
533
533
  }
534
534
  html += ` </div>`;
535
535
  html += ` <div class="lane-right">`;
536
- html += ` <span class="tmux-dot ${alive ? "alive" : "dead"}" title="${alive ? "tmux alive" : "tmux dead"}"></span>`;
537
- // View button: shows conversation stream if available, else tmux pane
538
- const isViewingConv = viewerMode === 'conversation' && viewerTarget === lane.tmuxSessionName;
539
- html += ` <button class="tmux-view-btn${isViewingConv ? ' active' : ''}" onclick="viewConversation('${escapeHtml(lane.tmuxSessionName)}')" title="View worker conversation">👁 View</button>`;
536
+ html += ` <span class="tmux-dot ${alive ? "alive" : "dead"}" title="${alive ? "session alive" : "session not active"}"></span>`;
537
+ // View button: shows conversation stream when available
538
+ const isViewingConv = viewerMode === 'conversation' && viewerTarget === laneSessionId;
539
+ html += ` <button class="tmux-view-btn${isViewingConv ? ' active' : ''}" onclick="viewConversation('${escapeHtml(laneSessionId)}')" title="View worker conversation">👁 View</button>`;
540
540
  if (alive) {
541
- html += ` <span class="tmux-cmd" data-tmux="${escapeHtml(lane.tmuxSessionName)}" onclick="copyTmuxCmd('${escapeHtml(lane.tmuxSessionName)}')" title="Click to copy">${escapeHtml(tmuxCmd)}</span>`;
541
+ html += ` <span class="tmux-cmd" data-session="${escapeHtml(laneSessionId)}" onclick="copySessionId('${escapeHtml(laneSessionId)}')" title="Copy session ID">${escapeHtml(sessionChip)}</span>`;
542
542
  } else {
543
- html += ` <span class="tmux-cmd dead-session">${escapeHtml(tmuxCmd)}</span>`;
543
+ html += ` <span class="tmux-cmd dead-session">${escapeHtml(sessionChip)}</span>`;
544
544
  }
545
545
  html += ` </div>`;
546
546
  html += `</div>`;
@@ -555,9 +555,9 @@ function renderLanesTasks(batch, tmuxSessions) {
555
555
  // Get lane state and telemetry for worker stats
556
556
  // TP-107: V2 lane snapshots take precedence when present
557
557
  const v2snap = v2Snapshots[lane.laneNumber] || null;
558
- const legacyLs = laneStates[lane.tmuxSessionName] || null;
558
+ const legacyLs = laneStates[laneSessionId] || null;
559
559
  const ls = v2snap ? mergeV2LaneSnapshot(legacyLs, v2snap) : legacyLs;
560
- const tel = telemetry[lane.tmuxSessionName] || null;
560
+ const tel = telemetry[laneSessionId] || null;
561
561
 
562
562
  for (const task of laneTasks) {
563
563
  // Repo filtering at task level
@@ -768,8 +768,8 @@ function renderMergeAgents(batch, tmuxSessions) {
768
768
  // Extract the prefix-opId part from the first lane and use it to construct merge names.
769
769
  const lanes = batch?.lanes || [];
770
770
  let mergePrefix = "orch-merge"; // fallback for legacy/unknown patterns
771
- if (lanes.length > 0 && lanes[0].tmuxSessionName) {
772
- const laneName = lanes[0].tmuxSessionName;
771
+ if (lanes.length > 0 && lanes[0].laneSessionId) {
772
+ const laneName = lanes[0].laneSessionId;
773
773
  const laneMatch = laneName.match(/^(.+)-lane-\d+$/);
774
774
  if (laneMatch) {
775
775
  mergePrefix = laneMatch[1] + "-merge";
@@ -784,7 +784,7 @@ function renderMergeAgents(batch, tmuxSessions) {
784
784
  }
785
785
 
786
786
  let html = '<table class="merge-table"><thead><tr>';
787
- html += '<th>Wave</th><th>Status</th><th>Session</th><th>Telemetry</th><th>Attach</th><th>Details</th>';
787
+ html += '<th>Wave</th><th>Status</th><th>Session</th><th>Telemetry</th><th>Session ID</th><th>Details</th>';
788
788
  html += '</tr></thead><tbody>';
789
789
 
790
790
  // Track sessions shown in wave result rows so we don't duplicate them below
@@ -855,8 +855,8 @@ function renderMergeAgents(batch, tmuxSessions) {
855
855
  html += `<td class="merge-telemetry-cell">${mergeTelemetryHtml(mergeTel, effectiveAlive)}</td>`;
856
856
  html += `<td>`;
857
857
  if (effectiveAlive) {
858
- const cmd = `tmux attach -t ${effectiveSession}`;
859
- html += `<span class="tmux-cmd" data-tmux="${escapeHtml(effectiveSession)}" onclick="copyTmuxCmd('${escapeHtml(effectiveSession)}')" title="Click to copy">${escapeHtml(cmd)}</span>`;
858
+ const sessionChip = `session: ${effectiveSession}`;
859
+ html += `<span class="tmux-cmd" data-session="${escapeHtml(effectiveSession)}" onclick="copySessionId('${escapeHtml(effectiveSession)}')" title="Copy session ID">${escapeHtml(sessionChip)}</span>`;
860
860
  } else {
861
861
  html += '<span class="merge-no-data">—</span>';
862
862
  }
@@ -894,14 +894,14 @@ function renderMergeAgents(batch, tmuxSessions) {
894
894
  if (shownSessions.has(sess)) continue;
895
895
 
896
896
  const sessTel = telemetry[sess] || null;
897
- const cmd = `tmux attach -t ${sess}`;
897
+ const sessionChip = `session: ${sess}`;
898
898
  html += `<tr>`;
899
899
  html += `<td class="merge-wave-cell">—</td>`;
900
900
  html += `<td><span class="status-badge status-running"><span class="status-dot running"></span> merging</span></td>`;
901
901
  html += `<td class="merge-session-cell">${escapeHtml(sess)}</td>`;
902
902
  // Full telemetry cell for active merge session
903
903
  html += `<td class="merge-telemetry-cell">${mergeTelemetryHtml(sessTel, true)}</td>`;
904
- html += `<td><span class="tmux-cmd" data-tmux="${escapeHtml(sess)}" onclick="copyTmuxCmd('${escapeHtml(sess)}')" title="Click to copy">${escapeHtml(cmd)}</span></td>`;
904
+ html += `<td><span class="tmux-cmd" data-session="${escapeHtml(sess)}" onclick="copySessionId('${escapeHtml(sess)}')" title="Copy session ID">${escapeHtml(sessionChip)}</span></td>`;
905
905
  html += `<td>—</td>`;
906
906
  html += `</tr>`;
907
907
  }
@@ -68,10 +68,31 @@ Options:
68
68
 
69
69
  // ─── Data Loading (ported from orch-dashboard.cjs) ──────────────────────────
70
70
 
71
+ function normalizeBatchStateIngress(state) {
72
+ if (!state || typeof state !== "object" || !Array.isArray(state.lanes)) {
73
+ return state;
74
+ }
75
+
76
+ for (const lane of state.lanes) {
77
+ if (!lane || typeof lane !== "object") continue;
78
+ const laneSessionId = typeof lane.laneSessionId === "string"
79
+ ? lane.laneSessionId
80
+ : (typeof lane.tmuxSessionName === "string" ? lane.tmuxSessionName : undefined);
81
+ if (laneSessionId) {
82
+ lane.laneSessionId = laneSessionId;
83
+ }
84
+ if ("tmuxSessionName" in lane) {
85
+ delete lane.tmuxSessionName;
86
+ }
87
+ }
88
+
89
+ return state;
90
+ }
91
+
71
92
  function loadBatchState() {
72
93
  try {
73
94
  const raw = fs.readFileSync(BATCH_STATE_PATH, "utf-8");
74
- return JSON.parse(raw);
95
+ return normalizeBatchStateIngress(JSON.parse(raw));
75
96
  } catch {
76
97
  return null;
77
98
  }
@@ -344,13 +365,13 @@ function tailJsonlFile(filePath) {
344
365
 
345
366
  /**
346
367
  * Load and accumulate telemetry from .pi/telemetry/*.jsonl files.
347
- * Returns telemetry keyed by tmux session prefix (e.g., "orch-lane-1").
368
+ * Returns telemetry keyed by session prefix (e.g., "orch-lane-1").
348
369
  *
349
- * Uses batch-state lanes to map lane numbers → tmux prefixes.
370
+ * Uses batch-state lanes to map lane numbers → session prefixes.
350
371
  * For standalone /task mode (no lane number in filename), data is keyed as "standalone".
351
372
  *
352
373
  * @param {object|null} batchState - The batch state from batch-state.json
353
- * @returns {object} Map of tmuxPrefix → accumulated telemetry
374
+ * @returns {object} Map of sessionPrefix → accumulated telemetry
354
375
  */
355
376
 
356
377
  // ── Runtime V2 Data Loaders (TP-107) ─────────────────────────────
@@ -521,8 +542,9 @@ function loadTelemetryData(batchState) {
521
542
  const laneToPrefix = {};
522
543
  if (batchState && batchState.lanes) {
523
544
  for (const lane of batchState.lanes) {
524
- if (lane.laneNumber != null && lane.tmuxSessionName) {
525
- laneToPrefix[lane.laneNumber] = lane.tmuxSessionName;
545
+ const laneSessionId = lane.laneSessionId;
546
+ if (lane.laneNumber != null && laneSessionId) {
547
+ laneToPrefix[lane.laneNumber] = laneSessionId;
526
548
  }
527
549
  }
528
550
  }
@@ -1022,7 +1044,7 @@ function buildDashboardState() {
1022
1044
  for (const [laneNum, snap] of Object.entries(runtimeLaneSnapshots)) {
1023
1045
  // Find the matching lane record to get the session name key
1024
1046
  const laneRec = (state.lanes || []).find(l => l.laneNumber === Number(laneNum));
1025
- const key = laneRec ? laneRec.tmuxSessionName : `lane-${laneNum}`;
1047
+ const key = laneRec ? (laneRec.laneSessionId) : `lane-${laneNum}`;
1026
1048
  if (!laneStates[key] || (snap.updatedAt && snap.updatedAt > (laneStates[key].timestamp || 0))) {
1027
1049
  const w = snap.worker || {};
1028
1050
  const statusMap = { running: "running", spawning: "running", exited: "done", crashed: "error", killed: "error", timed_out: "error", wrapping_up: "running" };
@@ -28,7 +28,7 @@ import {
28
28
  } from "fs";
29
29
  import { tmpdir, userInfo } from "os";
30
30
  import { join, dirname, basename, resolve } from "path";
31
- import { loadProjectConfig, toTaskConfig } from "./taskplane/config-loader.ts";
31
+ import { ConfigLoadError, loadProjectConfig, toTaskConfig } from "./taskplane/config-loader.ts";
32
32
  import { loadWorkspaceConfig, resolvePointer } from "./taskplane/workspace.ts";
33
33
  import type { PointerResolution } from "./taskplane/types.ts";
34
34
  import {
@@ -292,8 +292,12 @@ export function loadConfig(cwd: string): TaskConfig {
292
292
  const pointer = resolveTaskRunnerPointer();
293
293
  const unified = loadProjectConfig(cwd, pointer?.configRoot);
294
294
  return toTaskConfig(unified);
295
- } catch {
296
- // If config loading fails (e.g., malformed JSON), fall back to defaults
295
+ } catch (err: unknown) {
296
+ if (err instanceof ConfigLoadError && err.code === "CONFIG_LEGACY_FIELD") {
297
+ // Hard-fail deprecated TMUX-era config/prefs with migration guidance.
298
+ throw err;
299
+ }
300
+ // For malformed/unreadable config, preserve historical fallback behavior.
297
301
  return { ...DEFAULT_CONFIG };
298
302
  }
299
303
  }
@@ -3,12 +3,11 @@
3
3
  * @module orch/abort
4
4
  */
5
5
  import { writeFileSync, existsSync } from "fs";
6
- import { execSync } from "child_process";
7
6
  import { join } from "path";
8
7
 
9
- import { execLog, resolveCanonicalTaskPaths, tmuxHasSession, tmuxKillSession } from "./execution.ts";
8
+ import { execLog, killV2LaneAgents, resolveCanonicalTaskPaths } from "./execution.ts";
10
9
  import { killMergeAgentV2, killAllMergeAgentsV2 } from "./merge.ts";
11
- import { deleteBatchState, parseOrchSessionNames, persistRuntimeState } from "./persistence.ts";
10
+ import { deleteBatchState, persistRuntimeState } from "./persistence.ts";
12
11
  import type { AbortActionStep, AbortErrorCode, AbortLaneResult, AbortMode, AbortResult, AbortTargetSession, AllocatedLane, OrchBatchRuntimeState, PersistedBatchState, PersistedLaneRecord } from "./types.ts";
13
12
 
14
13
  // ── Abort Pure Functions ─────────────────────────────────────────────
@@ -51,11 +50,11 @@ export function selectAbortTargetSessions(
51
50
  });
52
51
 
53
52
  // Build lookup from persisted lane records for workspace-aware laneId resolution.
54
- // Keyed by tmuxSessionName for direct session-to-lane mapping.
53
+ // Keyed by lane session ID for direct session-to-lane mapping.
55
54
  const persistedLaneLookup = new Map<string, PersistedLaneRecord>();
56
55
  if (persistedState?.lanes) {
57
56
  for (const lane of persistedState.lanes) {
58
- persistedLaneLookup.set(lane.tmuxSessionName, lane);
57
+ persistedLaneLookup.set(lane.laneSessionId, lane);
59
58
  }
60
59
  }
61
60
 
@@ -82,7 +81,7 @@ export function selectAbortTargetSessions(
82
81
  const runtimeLookup = new Map<string, { laneId: string; taskId: string | null; worktreePath: string; taskFolder: string | null }>();
83
82
  for (const lane of runtimeLanes) {
84
83
  const currentTask = lane.tasks.length > 0 ? lane.tasks[0] : null;
85
- runtimeLookup.set(lane.tmuxSessionName, {
84
+ runtimeLookup.set(lane.laneSessionId, {
86
85
  laneId: lane.laneId,
87
86
  taskId: currentTask?.taskId || null,
88
87
  worktreePath: lane.worktreePath,
@@ -143,6 +142,47 @@ export function planAbortActions(
143
142
  ];
144
143
  }
145
144
 
145
+ /**
146
+ * Discover abort target session names from Runtime V2 state sources.
147
+ *
148
+ * Sources (deduped):
149
+ * - in-memory runtime lanes (`batchState.currentLanes`)
150
+ * - persisted lane records (`persistedState.lanes`)
151
+ * - persisted task records (`persistedState.tasks[].sessionName`)
152
+ */
153
+ export function discoverAbortSessionNames(
154
+ prefix: string,
155
+ persistedState: PersistedBatchState | null,
156
+ runtimeLanes: AllocatedLane[],
157
+ ): string[] {
158
+ const names = new Set<string>();
159
+ const prefixWithDash = `${prefix}-`;
160
+ const add = (name: string | null | undefined) => {
161
+ if (!name) return;
162
+ const trimmed = name.trim();
163
+ if (!trimmed || !trimmed.startsWith(prefixWithDash)) return;
164
+ names.add(trimmed);
165
+ };
166
+
167
+ for (const lane of runtimeLanes) {
168
+ add(lane.laneSessionId);
169
+ }
170
+
171
+ if (persistedState?.lanes) {
172
+ for (const lane of persistedState.lanes) {
173
+ add(lane.laneSessionId);
174
+ }
175
+ }
176
+
177
+ if (persistedState?.tasks) {
178
+ for (const task of persistedState.tasks) {
179
+ add(task.sessionName);
180
+ }
181
+ }
182
+
183
+ return [...names];
184
+ }
185
+
146
186
 
147
187
  // ── Abort Orchestration Functions ────────────────────────────────────
148
188
 
@@ -198,14 +238,15 @@ export function writeWrapUpFiles(
198
238
  }
199
239
 
200
240
  /**
201
- * Wait for TMUX sessions to exit gracefully.
241
+ * Wait for graceful shutdown window to elapse.
202
242
  *
203
- * Polls every `pollIntervalMs` until all sessions have exited or the
204
- * grace period expires.
243
+ * Runtime V2 no longer relies on TMUX session liveness as an abort signal.
244
+ * We keep this grace window so workers can observe `.task-wrap-up` and exit
245
+ * naturally before forced cleanup.
205
246
  *
206
- * @param sessionNames - Session names to monitor
247
+ * @param sessionNames - Session names being tracked for abort
207
248
  * @param gracePeriodMs - Maximum time to wait
208
- * @param pollIntervalMs - Polling interval
249
+ * @param pollIntervalMs - Polling cadence for the grace wait loop
209
250
  * @returns Object with exited and remaining session names
210
251
  */
211
252
  export async function waitForSessionExit(
@@ -213,69 +254,52 @@ export async function waitForSessionExit(
213
254
  gracePeriodMs: number,
214
255
  pollIntervalMs: number,
215
256
  ): Promise<{ exited: string[]; remaining: string[] }> {
257
+ if (sessionNames.length === 0 || gracePeriodMs <= 0) {
258
+ return { exited: [], remaining: [...sessionNames] };
259
+ }
260
+
216
261
  const deadline = Date.now() + gracePeriodMs;
217
- const exited: string[] = [];
218
- const remaining = new Set(sessionNames);
219
-
220
- while (Date.now() < deadline && remaining.size > 0) {
221
- for (const name of [...remaining]) {
222
- if (!tmuxHasSession(name)) {
223
- remaining.delete(name);
224
- exited.push(name);
225
- }
226
- }
227
- if (remaining.size === 0) break;
228
- await new Promise(r => setTimeout(r, pollIntervalMs));
262
+ while (Date.now() < deadline) {
263
+ const sleepMs = Math.max(1, Math.min(pollIntervalMs, deadline - Date.now()));
264
+ await new Promise(r => setTimeout(r, sleepMs));
229
265
  }
230
266
 
231
- return { exited, remaining: [...remaining] };
267
+ return { exited: [], remaining: [...sessionNames] };
232
268
  }
233
269
 
234
270
  /**
235
- * Kill orchestrator TMUX sessions.
271
+ * Kill orchestrator Runtime V2 agents.
236
272
  *
237
- * Kills each session and its children (worker, reviewer).
238
- * Returns per-session kill results.
273
+ * Kills lane worker/reviewer agents and merge agents by process handle.
274
+ * Session names are normalized to base lane/merge IDs so child suffixes do
275
+ * not trigger duplicate cleanup attempts.
239
276
  *
240
277
  * @param sessionNames - Session names to kill
241
278
  * @returns Per-session kill results
242
279
  */
243
280
  export function killOrchSessions(
244
281
  sessionNames: string[],
282
+ options?: { stateRoot?: string; batchId?: string },
245
283
  ): Array<{ sessionName: string; killed: boolean; error: string | null }> {
246
284
  const results: Array<{ sessionName: string; killed: boolean; error: string | null }> = [];
285
+ const killedBaseSessions = new Set<string>();
286
+
287
+ for (const name of sessionNames) {
288
+ const baseSessionName = name.replace(/-(worker|reviewer)$/, "");
289
+ if (!killedBaseSessions.has(baseSessionName)) {
290
+ killV2LaneAgents(baseSessionName, {
291
+ stateRoot: options?.stateRoot,
292
+ batchId: options?.batchId,
293
+ logContext: "abort",
294
+ });
295
+ killMergeAgentV2(baseSessionName);
296
+ killedBaseSessions.add(baseSessionName);
297
+ }
247
298
 
248
- // Group into base sessions (lane/merge) and child sessions
249
- const baseSessionNames = sessionNames.filter(name =>
250
- !name.endsWith("-worker") && !name.endsWith("-reviewer"),
251
- );
252
- const childSessionNames = sessionNames.filter(name =>
253
- name.endsWith("-worker") || name.endsWith("-reviewer"),
254
- );
255
-
256
- // Kill explicitly-targeted child sessions first.
257
- for (const name of childSessionNames) {
258
- const killed = tmuxKillSession(name);
259
- results.push({
260
- sessionName: name,
261
- killed,
262
- error: killed ? null : `Session '${name}' still alive after kill attempt`,
263
- });
264
- }
265
-
266
- // Then kill base sessions (and defensively kill their children).
267
- for (const name of baseSessionNames) {
268
- // Best-effort child cleanup even if not explicitly targeted.
269
- tmuxKillSession(`${name}-worker`);
270
- tmuxKillSession(`${name}-reviewer`);
271
- // TP-108: Also kill V2 merge agents (no-op if not V2)
272
- killMergeAgentV2(name);
273
-
274
- const killed = tmuxKillSession(name);
275
299
  results.push({
276
300
  sessionName: name,
277
- killed,
278
- error: killed ? null : `Session '${name}' still alive after kill attempt`,
301
+ killed: true,
302
+ error: null,
279
303
  });
280
304
  }
281
305
 
@@ -296,7 +320,7 @@ export function killOrchSessions(
296
320
  * Non-goal: does NOT delete worktrees/branches (preserved for inspection).
297
321
  *
298
322
  * @param mode - Abort mode (graceful or hard)
299
- * @param prefix - TMUX session prefix (e.g., "orch")
323
+ * @param prefix - orchestrator session prefix (e.g., "orch")
300
324
  * @param repoRoot - Repository root path
301
325
  * @param batchState - Current batch runtime state (mutated: phase set to stopped)
302
326
  * @param persistedState - Loaded persisted state (for session enrichment)
@@ -342,28 +366,10 @@ export async function executeAbort(
342
366
  execLog("abort", batchState.batchId, `killed ${v2MergeKilled} V2 merge agent(s)`);
343
367
  }
344
368
 
345
- // Step 3: List all orch sessions (TMUX legacy + fallback)
346
- let allSessionNames: string[];
347
- try {
348
- allSessionNames = parseOrchSessionNames(
349
- (() => {
350
- try {
351
- return execSync('tmux list-sessions -F "#{session_name}"', {
352
- encoding: "utf-8",
353
- timeout: 5000,
354
- });
355
- } catch {
356
- return "";
357
- }
358
- })(),
359
- prefix,
360
- );
361
- } catch (err) {
362
- errors.push({
363
- code: "ABORT_TMUX_LIST_FAILED",
364
- message: err instanceof Error ? err.message : String(err),
365
- });
366
- allSessionNames = [];
369
+ // Step 3: Discover target sessions from Runtime V2 state sources.
370
+ const allSessionNames = discoverAbortSessionNames(prefix, persistedState, batchState.currentLanes);
371
+ if (allSessionNames.length === 0) {
372
+ execLog("abort", batchState.batchId, `No abort targets discovered for prefix "${prefix}" from runtime/persisted state.`);
367
373
  }
368
374
 
369
375
  // Step 4: Select and enrich target sessions
@@ -400,7 +406,10 @@ export async function executeAbort(
400
406
  // Step 5c: Force-kill remaining sessions
401
407
  const killResultBySession = new Map<string, { killed: boolean; error: string | null }>();
402
408
  if (waitResult.remaining.length > 0) {
403
- const killResults = killOrchSessions(waitResult.remaining);
409
+ const killResults = killOrchSessions(waitResult.remaining, {
410
+ stateRoot: repoRoot,
411
+ batchId: batchState.batchId,
412
+ });
404
413
  for (const kr of killResults) {
405
414
  killResultBySession.set(kr.sessionName, { killed: kr.killed, error: kr.error });
406
415
  }
@@ -434,7 +443,10 @@ export async function executeAbort(
434
443
  } else {
435
444
  // Hard mode: kill all immediately
436
445
  const allTargetNames = targets.map(t => t.sessionName);
437
- const killResults = killOrchSessions(allTargetNames);
446
+ const killResults = killOrchSessions(allTargetNames, {
447
+ stateRoot: repoRoot,
448
+ batchId: batchState.batchId,
449
+ });
438
450
  const killResultBySession = new Map<string, { killed: boolean; error: string | null }>();
439
451
  for (const kr of killResults) {
440
452
  killResultBySession.set(kr.sessionName, { killed: kr.killed, error: kr.error });
@@ -1,17 +1,16 @@
1
1
  /**
2
2
  * Agent Host — Direct-child Pi agent hosting for Runtime V2
3
3
  *
4
- * Spawns `pi --mode rpc` as a direct child process (no TMUX, no shell),
4
+ * Spawns `pi --mode rpc` as a direct child process (no terminal multiplexer, no shell),
5
5
  * parses RPC JSONL events, normalizes them into RuntimeAgentEvents,
6
6
  * manages mailbox delivery, and produces exit summaries.
7
7
  *
8
- * This replaces the TMUX-backed hosting path (spawnAgentTmux in
9
- * task-runner.ts + rpc-wrapper.mjs as a TMUX session command) with
8
+ * This replaces the legacy terminal-session hosting path with
10
9
  * a programmatic parent-child model where the caller has full process
11
10
  * ownership.
12
11
  *
13
12
  * Key differences from the legacy path:
14
- * 1. No TMUX — `spawn()` with `shell: false`
13
+ * 1. No terminal-session backend — `spawn()` with `shell: false`
15
14
  * 2. No sidecar tailing — events flow directly to the caller via callbacks
16
15
  * 3. No PID-file orphan guessing — caller owns the process handle
17
16
  * 4. Registry integration — manifests updated on status transitions
@@ -301,7 +300,7 @@ export function spawnAgent(
301
300
  piArgs.push("--no-skills");
302
301
  if (opts.thinking) piArgs.push("--thinking", opts.thinking);
303
302
 
304
- // Spawn directly — no shell, no TMUX
303
+ // Spawn directly — no shell, no terminal multiplexer
305
304
  const proc = spawn(process.execPath, piArgs, {
306
305
  shell: false,
307
306
  cwd: opts.cwd,