taskplane 0.8.0 → 0.8.1

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.
@@ -589,6 +589,7 @@ function renderMergeAgents(batch, tmuxSessions) {
589
589
  const mergeResults = batch?.mergeResults || [];
590
590
  const tmuxSet = new Set(tmuxSessions || []);
591
591
  const showRepos = knownRepos.length >= 2;
592
+ const telemetry = currentData?.telemetry || {};
592
593
 
593
594
  // Check for active merge sessions (convention: orch-merge-*)
594
595
  const mergeSessions = (tmuxSessions || []).filter(s => s.startsWith("orch-merge"));
@@ -599,7 +600,7 @@ function renderMergeAgents(batch, tmuxSessions) {
599
600
  }
600
601
 
601
602
  let html = '<table class="merge-table"><thead><tr>';
602
- html += '<th>Wave</th><th>Status</th><th>Session</th><th>Attach</th><th>Details</th>';
603
+ html += '<th>Wave</th><th>Status</th><th>Session</th><th>Telemetry</th><th>Attach</th><th>Details</th>';
603
604
  html += '</tr></thead><tbody>';
604
605
 
605
606
  // Show merge results
@@ -620,10 +621,29 @@ function renderMergeAgents(batch, tmuxSessions) {
620
621
  const sessionName = `orch-merge-w${mr.waveIndex + 1}`;
621
622
  const alive = tmuxSet.has(sessionName);
622
623
 
624
+ // Look for merge telemetry data
625
+ const mergeTel = telemetry[sessionName] || telemetry[`orch-merge-${mr.waveIndex + 1}`] || null;
626
+
623
627
  html += `<tr>`;
624
628
  html += `<td style="font-family:var(--font-mono);">Wave ${mr.waveIndex + 1}</td>`;
625
629
  html += `<td><span class="status-badge ${statusCls}">${mr.status}</span></td>`;
626
630
  html += `<td style="font-family:var(--font-mono);font-size:0.8rem;">${alive ? escapeHtml(sessionName) : "—"}</td>`;
631
+ // Telemetry cell
632
+ html += `<td style="font-size:0.75rem;">`;
633
+ if (mergeTel) {
634
+ const totalTok = (mergeTel.inputTokens || 0) + (mergeTel.outputTokens || 0);
635
+ const cost = mergeTel.cost || 0;
636
+ if (totalTok > 0 || cost > 0) {
637
+ html += `<span style="color:var(--text-muted);">${totalTok > 0 ? totalTok.toLocaleString() + " tok" : ""}`;
638
+ if (cost > 0) html += ` · $${cost.toFixed(4)}`;
639
+ html += `</span>`;
640
+ } else {
641
+ html += '<span style="color:var(--text-faint);">—</span>';
642
+ }
643
+ } else {
644
+ html += '<span style="color:var(--text-faint);">—</span>';
645
+ }
646
+ html += `</td>`;
627
647
  html += `<td>`;
628
648
  if (alive) {
629
649
  const cmd = `tmux attach -t ${sessionName}`;
@@ -652,7 +672,8 @@ function renderMergeAgents(batch, tmuxSessions) {
652
672
  html += `<td>${repoBadgeHtml(rr.repoId)}</td>`;
653
673
  html += `<td><span class="status-badge ${rrStatusCls}">${rr.status}</span></td>`;
654
674
  html += `<td style="font-family:var(--font-mono);font-size:0.75rem;color:var(--text-faint);">${rrLanes}</td>`;
655
- html += `<td></td>`;
675
+ html += `<td></td>`; /* telemetry placeholder */
676
+ html += `<td></td>`; /* attach placeholder */
656
677
  html += `<td style="font-size:0.75rem;color:var(--text-faint);">${rrDetail}</td>`;
657
678
  html += `</tr>`;
658
679
  }
@@ -664,11 +685,28 @@ function renderMergeAgents(batch, tmuxSessions) {
664
685
  const alreadyShown = mergeResults.some((mr) => `orch-merge-w${mr.waveIndex + 1}` === sess);
665
686
  if (alreadyShown) continue;
666
687
 
688
+ const sessTel = telemetry[sess] || null;
667
689
  const cmd = `tmux attach -t ${sess}`;
668
690
  html += `<tr>`;
669
691
  html += `<td style="font-family:var(--font-mono);">—</td>`;
670
692
  html += `<td><span class="status-badge status-running"><span class="status-dot running"></span> merging</span></td>`;
671
693
  html += `<td style="font-family:var(--font-mono);font-size:0.8rem;">${escapeHtml(sess)}</td>`;
694
+ // Telemetry cell for active merge session
695
+ html += `<td style="font-size:0.75rem;">`;
696
+ if (sessTel) {
697
+ const totalTok = (sessTel.inputTokens || 0) + (sessTel.outputTokens || 0);
698
+ const cost = sessTel.cost || 0;
699
+ if (totalTok > 0 || cost > 0) {
700
+ html += `<span style="color:var(--text-muted);">${totalTok > 0 ? totalTok.toLocaleString() + " tok" : ""}`;
701
+ if (cost > 0) html += ` · $${cost.toFixed(4)}`;
702
+ html += `</span>`;
703
+ } else {
704
+ html += '<span style="color:var(--text-faint);">—</span>';
705
+ }
706
+ } else {
707
+ html += '<span style="color:var(--text-faint);">—</span>';
708
+ }
709
+ html += `</td>`;
672
710
  html += `<td><span class="tmux-cmd" data-tmux="${escapeHtml(sess)}" onclick="copyTmuxCmd('${escapeHtml(sess)}')" title="Click to copy">${escapeHtml(cmd)}</span></td>`;
673
711
  html += `<td>—</td>`;
674
712
  html += `</tr>`;
@@ -239,7 +239,8 @@ const telemetryPrefixFiles = new Map();
239
239
  /**
240
240
  * Parse a telemetry JSONL filename to extract lane number and role.
241
241
  * Pattern: {opId}-{batchId}-{repoId}[-{taskId}][-lane-{N}]-{role}.jsonl
242
- * Returns { laneNumber: number|null, role: string } or null if unparseable.
242
+ * Roles: worker, reviewer, merger
243
+ * Returns { laneNumber: number|null, role: string, mergeNumber: number|null } or null if unparseable.
243
244
  */
244
245
  function parseTelemetryFilename(filename) {
245
246
  // Remove .jsonl extension
@@ -248,13 +249,17 @@ function parseTelemetryFilename(filename) {
248
249
  const lastDash = base.lastIndexOf("-");
249
250
  if (lastDash < 0) return null;
250
251
  const role = base.slice(lastDash + 1);
251
- if (role !== "worker" && role !== "reviewer") return null;
252
+ if (role !== "worker" && role !== "reviewer" && role !== "merger") return null;
252
253
 
253
254
  // Extract lane number from -lane-{N}- pattern
254
255
  const laneMatch = base.match(/-lane-(\d+)-/);
255
256
  const laneNumber = laneMatch ? parseInt(laneMatch[1], 10) : null;
256
257
 
257
- return { laneNumber, role };
258
+ // Extract merge number from -merge-{N}- pattern (merge agents)
259
+ const mergeMatch = base.match(/-merge-(\d+)-/);
260
+ const mergeNumber = mergeMatch ? parseInt(mergeMatch[1], 10) : null;
261
+
262
+ return { laneNumber, role, mergeNumber };
258
263
  }
259
264
 
260
265
  /**
@@ -375,7 +380,10 @@ function loadTelemetryData(batchState) {
375
380
 
376
381
  // Determine the key (tmux prefix)
377
382
  let prefix;
378
- if (parsed.laneNumber != null && laneToPrefix[parsed.laneNumber]) {
383
+ if (parsed.role === "merger") {
384
+ // Merge agent — key by merge session number or generic "merge"
385
+ prefix = parsed.mergeNumber != null ? `orch-merge-${parsed.mergeNumber}` : "orch-merge";
386
+ } else if (parsed.laneNumber != null && laneToPrefix[parsed.laneNumber]) {
379
387
  prefix = laneToPrefix[parsed.laneNumber];
380
388
  } else if (parsed.laneNumber != null) {
381
389
  // Lane number found but no batch-state mapping — use heuristic
@@ -2,9 +2,10 @@
2
2
  * Lane execution, monitoring, wave execution loop
3
3
  * @module orch/execution
4
4
  */
5
- import { readFileSync, existsSync, statSync, unlinkSync, mkdirSync } from "fs";
5
+ import { readFileSync, existsSync, statSync, unlinkSync, mkdirSync, writeFileSync } from "fs";
6
6
  import { spawnSync } from "child_process";
7
7
  import { join, dirname, resolve, relative, delimiter as pathDelimiter } from "path";
8
+ import { tmpdir, userInfo } from "os";
8
9
 
9
10
  import { DONE_GRACE_MS, EXECUTION_POLL_INTERVAL_MS, ExecutionError, SESSION_SPAWN_RETRY_MAX } from "./types.ts";
10
11
  import type { AllocatedLane, AllocatedTask, DependencyGraph, LaneExecutionResult, LaneMonitorSnapshot, LaneTaskOutcome, LaneTaskStatus, MonitorState, MtimeTracker, OrchestratorConfig, ParsedTask, TaskMonitorSnapshot, WaveExecutionResult, WorkspaceConfig } from "./types.ts";
@@ -58,6 +59,108 @@ function resolveTaskRunnerExtensionPath(repoRoot: string): string {
58
59
  return localPath;
59
60
  }
60
61
 
62
+ // ── RPC Wrapper Path Resolution ──────────────────────────────────────
63
+
64
+ /**
65
+ * Find the rpc-wrapper.mjs path for lane sessions.
66
+ *
67
+ * Resolution order mirrors resolveTaskRunnerExtensionPath:
68
+ * 1. Local project: {repoRoot}/bin/rpc-wrapper.mjs (for taskplane dev)
69
+ * 2. Global npm (Windows): {APPDATA}/npm/node_modules/taskplane/bin/rpc-wrapper.mjs
70
+ * 3. Global npm (Unix): /usr/local/lib/node_modules/taskplane/bin/rpc-wrapper.mjs
71
+ * 4. npm peer: resolve from pi's location
72
+ *
73
+ * @throws ExecutionError if rpc-wrapper.mjs cannot be found anywhere
74
+ */
75
+ export function resolveRpcWrapperPath(repoRoot: string): string {
76
+ const wrapperFile = join("bin", "rpc-wrapper.mjs");
77
+
78
+ // 1. Local project (taskplane development)
79
+ const localPath = join(resolve(repoRoot), wrapperFile);
80
+ if (existsSync(localPath)) return localPath;
81
+
82
+ // 2. Global npm install paths
83
+ const home = process.env.HOME || process.env.USERPROFILE || "";
84
+ const candidates: string[] = [];
85
+ if (process.env.APPDATA) {
86
+ candidates.push(join(process.env.APPDATA, "npm", "node_modules", "taskplane", wrapperFile));
87
+ }
88
+ if (home) {
89
+ candidates.push(join(home, "AppData", "Roaming", "npm", "node_modules", "taskplane", wrapperFile));
90
+ candidates.push(join(home, ".npm-global", "lib", "node_modules", "taskplane", wrapperFile));
91
+ }
92
+ candidates.push(join("/usr", "local", "lib", "node_modules", "taskplane", wrapperFile));
93
+
94
+ // 3. Peer of pi's package
95
+ try {
96
+ const piPath = process.argv[1] || "";
97
+ const piPkgDir = resolve(piPath, "..", "..");
98
+ candidates.push(join(piPkgDir, "..", "taskplane", wrapperFile));
99
+ } catch { /* ignore */ }
100
+
101
+ for (const candidate of candidates) {
102
+ if (existsSync(candidate)) return candidate;
103
+ }
104
+
105
+ // Fallback: return the local path (will fail at spawn time with a clear error)
106
+ return localPath;
107
+ }
108
+
109
+ // ── Telemetry Path Generation ────────────────────────────────────────
110
+
111
+ /**
112
+ * Generate telemetry file paths for a lane session.
113
+ *
114
+ * Naming contract from resilience roadmap:
115
+ * .pi/telemetry/{opId}-{batchId}-{repoId}[-{taskId}][-lane-{N}]-{role}.{ext}
116
+ *
117
+ * @param sessionName - TMUX session name (e.g., "orch-lane-1")
118
+ * @param sidecarRoot - Root dir for sidecar files (e.g., <workspace>/.pi or <repo>/.pi)
119
+ * @param taskId - Task identifier (e.g., "TP-049")
120
+ * @returns { sidecarPath, exitSummaryPath, telemetryDir }
121
+ */
122
+ export function generateTelemetryPaths(
123
+ sessionName: string,
124
+ sidecarRoot: string,
125
+ taskId?: string,
126
+ ): { sidecarPath: string; exitSummaryPath: string; telemetryDir: string } {
127
+ const telemetryTs = Date.now();
128
+
129
+ // Resolve opId: same priority chain as naming.ts resolveOperatorId()
130
+ let opId = "op";
131
+ const envOpId = process.env.TASKPLANE_OPERATOR_ID;
132
+ if (envOpId?.trim()) {
133
+ opId = envOpId.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
134
+ } else {
135
+ try {
136
+ const username = userInfo().username;
137
+ if (username?.trim()) {
138
+ opId = username.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
139
+ }
140
+ } catch { /* userInfo() can throw on some platforms */ }
141
+ }
142
+
143
+ const batchId = String(telemetryTs);
144
+ const repoId = "default";
145
+
146
+ // Extract role from sessionName — lane sessions are "worker" role
147
+ const role = "worker";
148
+ const laneMatch = sessionName.match(/lane-(\d+)/);
149
+ const laneSuffix = laneMatch ? `-lane-${laneMatch[1]}` : "";
150
+
151
+ // Include taskId when available
152
+ const taskIdSegment = taskId
153
+ ? `-${taskId.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 30)}`
154
+ : "";
155
+ const telemetryBasename = `${opId}-${batchId}-${repoId}${taskIdSegment}${laneSuffix}-${role}`;
156
+ const telemetryDir = join(sidecarRoot, "telemetry");
157
+ if (!existsSync(telemetryDir)) mkdirSync(telemetryDir, { recursive: true });
158
+ const sidecarPath = join(telemetryDir, `${telemetryBasename}.jsonl`);
159
+ const exitSummaryPath = join(telemetryDir, `${telemetryBasename}-exit.json`);
160
+
161
+ return { sidecarPath, exitSummaryPath, telemetryDir };
162
+ }
163
+
61
164
  // ── Execution Helpers ────────────────────────────────────────────────
62
165
 
63
166
  /**
@@ -231,7 +334,12 @@ export function toTmuxPath(pathValue: string): string {
231
334
  *
232
335
  * Constructs a properly escaped command that:
233
336
  * 1. Sets env vars (TASK_AUTOSTART, TASK_RUNNER_SPAWN_MODE, TASK_RUNNER_TMUX_PREFIX)
234
- * 2. Runs `pi --no-session -e extensions/task-runner.ts` in the worktree directory
337
+ * 2. Runs `node rpc-wrapper.mjs` to spawn pi with the task-runner extension,
338
+ * producing structured telemetry (sidecar JSONL + exit summary JSON).
339
+ *
340
+ * The RPC wrapper spawns pi in RPC mode with the task-runner extension loaded.
341
+ * The extension's TASK_AUTOSTART env var triggers task execution on init.
342
+ * A minimal prompt file is created to satisfy the wrapper's --prompt-file requirement.
235
343
  *
236
344
  * Shell escaping: env var values are single-quoted to prevent expansion.
237
345
  * Path args are single-quoted to handle spaces and special characters.
@@ -241,6 +349,8 @@ export function toTmuxPath(pathValue: string): string {
241
349
  * @param repoRoot - Absolute path to main repo (for extension absolute path)
242
350
  * @param envVars - Environment variables to set
243
351
  * @param laneLogPath - Optional path to write lane session stdout/stderr
352
+ * @param sidecarPath - Path for RPC telemetry sidecar JSONL file
353
+ * @param exitSummaryPath - Path for RPC telemetry exit summary JSON file
244
354
  * @returns Array of arguments for spawnSync("tmux", args)
245
355
  */
246
356
  export function buildTmuxSpawnArgs(
@@ -249,6 +359,8 @@ export function buildTmuxSpawnArgs(
249
359
  repoRoot: string,
250
360
  envVars: Record<string, string>,
251
361
  laneLogPath?: string,
362
+ sidecarPath?: string,
363
+ exitSummaryPath?: string,
252
364
  ): string[] {
253
365
  // Shell-quote a value for safe embedding in a command string.
254
366
  // Wraps in single quotes, escaping any internal single quotes.
@@ -260,18 +372,44 @@ export function buildTmuxSpawnArgs(
260
372
  };
261
373
 
262
374
  // Build the command string that runs inside the TMUX session.
263
- // Format: ENV_VAR1=value1 ENV_VAR2=value2 pi --no-session -e extensions/task-runner.ts
264
375
  const envParts = Object.entries(envVars)
265
376
  .map(([key, val]) => `${key}=${shellQuote(val)}`)
266
377
  .join(" ");
267
378
 
268
379
  const taskRunnerExtPath = resolveTaskRunnerExtensionPath(repoRoot);
269
- const basePiCommand = `${envParts} pi --no-session -e ${shellQuote(taskRunnerExtPath)}`;
380
+
381
+ let piCommand: string;
382
+
383
+ if (sidecarPath && exitSummaryPath) {
384
+ // ── RPC Wrapper mode: structured telemetry ──────────────
385
+ // Spawn `node rpc-wrapper.mjs` instead of `pi` directly.
386
+ // The wrapper runs pi in RPC mode, captures telemetry to
387
+ // sidecar JSONL, and writes exit summary on process exit.
388
+ const rpcWrapperPath = resolveRpcWrapperPath(repoRoot);
389
+
390
+ // Create a minimal prompt file for the RPC wrapper.
391
+ // The task-runner extension handles execution via TASK_AUTOSTART;
392
+ // this prompt satisfies the wrapper's --prompt-file requirement.
393
+ const promptId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
394
+ const promptTmpFile = join(tmpdir(), `pi-lane-prompt-${promptId}.txt`);
395
+ writeFileSync(promptTmpFile, "Execute the task as configured by the task-runner extension.");
396
+
397
+ piCommand = [
398
+ envParts,
399
+ "node", shellQuote(rpcWrapperPath),
400
+ "--sidecar-path", shellQuote(sidecarPath),
401
+ "--exit-summary-path", shellQuote(exitSummaryPath),
402
+ "--prompt-file", shellQuote(promptTmpFile),
403
+ "--extensions", shellQuote(taskRunnerExtPath),
404
+ ].filter(Boolean).join(" ");
405
+ } else {
406
+ // ── Legacy mode: direct pi spawn (no telemetry) ─────────
407
+ piCommand = `${envParts} pi --no-session -e ${shellQuote(taskRunnerExtPath)}`;
408
+ }
270
409
 
271
410
  // NOTE: Do not redirect lane output here. Shell redirection has proven
272
411
  // fragile across Windows + tmux environments and can prevent session spawn.
273
412
  // Diagnostics use tmux pane capture + STATUS tail in pollUntilTaskComplete().
274
- const piCommand = basePiCommand;
275
413
 
276
414
  const tmuxWorktreePath = toTmuxPath(worktreePath);
277
415
  const wrappedCommand = `cd ${shellQuote(tmuxWorktreePath)} && ${piCommand}`;
@@ -550,8 +688,16 @@ export function spawnLaneSession(
550
688
  // Best effort — session can still run without log file setup
551
689
  }
552
690
 
553
- // Build tmux args
554
- const tmuxArgs = buildTmuxSpawnArgs(sessionName, lane.worktreePath, repoRoot, envVars, laneLogRelativePath);
691
+ // Generate telemetry file paths for RPC wrapper sidecar
692
+ const sidecarRoot = join(workspaceRoot || repoRoot, ".pi");
693
+ const telemetry = generateTelemetryPaths(sessionName, sidecarRoot, task.taskId);
694
+ execLog(laneId, task.taskId, "telemetry paths generated", {
695
+ sidecar: telemetry.sidecarPath,
696
+ exitSummary: telemetry.exitSummaryPath,
697
+ });
698
+
699
+ // Build tmux args (with RPC wrapper telemetry)
700
+ const tmuxArgs = buildTmuxSpawnArgs(sessionName, lane.worktreePath, repoRoot, envVars, laneLogRelativePath, telemetry.sidecarPath, telemetry.exitSummaryPath);
555
701
 
556
702
  // Clean up stale session if exists
557
703
  if (tmuxHasSession(sessionName)) {
@@ -6,7 +6,7 @@ import { readFileSync, writeFileSync, existsSync, unlinkSync, copyFileSync, mkdi
6
6
  import { execSync, spawnSync } from "child_process";
7
7
  import { join, dirname, resolve, relative } from "path";
8
8
 
9
- import { buildLaneEnvVars, buildTmuxSpawnArgs, execLog, tmuxHasSession, tmuxKillSession, toTmuxPath } from "./execution.ts";
9
+ import { buildLaneEnvVars, buildTmuxSpawnArgs, execLog, generateTelemetryPaths, resolveRpcWrapperPath, tmuxHasSession, tmuxKillSession, toTmuxPath } from "./execution.ts";
10
10
  import { resolveOperatorId } from "./naming.ts";
11
11
  import { MERGE_POLL_INTERVAL_MS, MERGE_RESULT_GRACE_MS, MERGE_RESULT_READ_RETRIES, MERGE_RESULT_READ_RETRY_DELAY_MS, MERGE_SPAWN_RETRY_MAX, MERGE_TIMEOUT_MAX_RETRIES, MERGE_TIMEOUT_MS, MergeError, VALID_MERGE_STATUSES } from "./types.ts";
12
12
  import type { AllocatedLane, LaneExecutionResult, MergeLaneResult, MergeResult, MergeResultStatus, MergeWaveResult, OrchestratorConfig, RepoMergeOutcome, TaskRunnerConfig, TransactionRecord, TransactionStatus, VerificationBaselineResult, WaveExecutionResult, WorkspaceConfig } from "./types.ts";
@@ -18,6 +18,58 @@ import { loadOrchestratorConfig } from "./config.ts";
18
18
  import { captureBaseline, diffFingerprints, runVerificationCommands, parseTestOutput, deduplicateFingerprints } from "./verification.ts";
19
19
  import type { VerificationBaseline, FingerprintDiff, TestFingerprint } from "./verification.ts";
20
20
 
21
+ // ── Merge Telemetry Helpers ───────────────────────────────────────────
22
+
23
+ import { userInfo } from "os";
24
+
25
+ /**
26
+ * Generate telemetry file paths for a merge agent session.
27
+ *
28
+ * Naming: {opId}-{batchId}-{repoId}[-merge-w{N}-lane-{N}]-merger.{ext}
29
+ * Role is always "merger" to distinguish from worker/reviewer in the dashboard.
30
+ *
31
+ * @param sessionName - TMUX session name (e.g., "orch-merge-1")
32
+ * @param sidecarRoot - Root dir for sidecar files (e.g., <workspace>/.pi)
33
+ * @returns { sidecarPath, exitSummaryPath }
34
+ */
35
+ function generateMergeTelemetryPaths(
36
+ sessionName: string,
37
+ sidecarRoot: string,
38
+ ): { sidecarPath: string; exitSummaryPath: string } {
39
+ const telemetryTs = Date.now();
40
+
41
+ // Resolve opId: same priority chain as execution.ts
42
+ let opId = "op";
43
+ const envOpId = process.env.TASKPLANE_OPERATOR_ID;
44
+ if (envOpId?.trim()) {
45
+ opId = envOpId.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
46
+ } else {
47
+ try {
48
+ const username = userInfo().username;
49
+ if (username?.trim()) {
50
+ opId = username.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
51
+ }
52
+ } catch { /* userInfo() can throw on some platforms */ }
53
+ }
54
+
55
+ const batchId = String(telemetryTs);
56
+ const repoId = "default";
57
+
58
+ // Extract merge-specific info from sessionName (e.g., "orch-merge-1")
59
+ const mergeMatch = sessionName.match(/merge-(\d+)/);
60
+ const mergeSuffix = mergeMatch ? `-merge-${mergeMatch[1]}` : "";
61
+
62
+ const role = "merger";
63
+ const telemetryBasename = `${opId}-${batchId}-${repoId}${mergeSuffix}-${role}`;
64
+ const telemetryDir = join(sidecarRoot, "telemetry");
65
+ if (!existsSync(telemetryDir)) mkdirSync(telemetryDir, { recursive: true });
66
+
67
+ return {
68
+ sidecarPath: join(telemetryDir, `${telemetryBasename}.jsonl`),
69
+ exitSummaryPath: join(telemetryDir, `${telemetryBasename}-exit.json`),
70
+ };
71
+ }
72
+
21
73
  // ── Merge Implementation ─────────────────────────────────────────────
22
74
 
23
75
  /**
@@ -374,10 +426,10 @@ export async function spawnMergeAgent(
374
426
  await sleepAsync(500);
375
427
  }
376
428
 
377
- // Build the pi command for the merge agent.
378
- // Uses --no-session to prevent interactive session management.
379
- // --append-system-prompt loads the merger agent definition.
380
- // The merge request file is passed as a prompt via @file syntax.
429
+ // Build the merge agent command.
430
+ // Uses rpc-wrapper.mjs to produce structured telemetry (sidecar JSONL + exit summary).
431
+ // The merger agent definition is loaded via --system-prompt-file.
432
+ // The merge request is passed as the --prompt-file.
381
433
  const shellQuote = (s: string): string => {
382
434
  if (/[\s"'`$\\!&|;()<>{}#*?~]/.test(s)) {
383
435
  return `'${s.replace(/'/g, "'\\''")}'`;
@@ -385,25 +437,43 @@ export async function spawnMergeAgent(
385
437
  return s;
386
438
  };
387
439
 
388
- // Build model args if specified
389
- const modelArgs = config.merge.model ? `--model ${shellQuote(config.merge.model)}` : "";
440
+ // Generate telemetry paths for this merge session
441
+ // Naming: {opId}-{batchId}-{repoId}-merger.jsonl (or with wave/lane info from sessionName)
442
+ const sidecarRoot = join(stateRoot ?? repoRoot, ".pi");
443
+ const telemetry = generateMergeTelemetryPaths(sessionName, sidecarRoot);
444
+ execLog("merge", sessionName, "telemetry paths generated", {
445
+ sidecar: telemetry.sidecarPath,
446
+ exitSummary: telemetry.exitSummaryPath,
447
+ });
448
+
449
+ // Resolve paths
450
+ const rpcWrapperPath = resolveRpcWrapperPath(repoRoot);
451
+ const systemPromptPath = agentRoot ? join(agentRoot, "task-merger.md") : join(stateRoot ?? repoRoot, ".pi", "agents", "task-merger.md");
452
+
453
+ // Build RPC wrapper command
454
+ const wrapperParts = [
455
+ "TERM=xterm-256color",
456
+ "node", shellQuote(rpcWrapperPath),
457
+ "--sidecar-path", shellQuote(telemetry.sidecarPath),
458
+ "--exit-summary-path", shellQuote(telemetry.exitSummaryPath),
459
+ "--prompt-file", shellQuote(mergeRequestPath),
460
+ "--system-prompt-file", shellQuote(systemPromptPath),
461
+ ];
390
462
 
391
- // Build tools override if specified
392
- const toolsArgs = config.merge.tools ? `--tools ${shellQuote(config.merge.tools)}` : "";
463
+ // Add model args if specified
464
+ if (config.merge.model) {
465
+ wrapperParts.push("--model", shellQuote(config.merge.model));
466
+ }
467
+
468
+ // Add tools override if specified
469
+ if (config.merge.tools) {
470
+ wrapperParts.push("--tools", shellQuote(config.merge.tools));
471
+ }
393
472
 
394
- const piCommand = [
395
- "pi --no-session",
396
- modelArgs,
397
- toolsArgs,
398
- `--append-system-prompt ${shellQuote(agentRoot ? join(agentRoot, "task-merger.md") : join(stateRoot ?? repoRoot, ".pi", "agents", "task-merger.md"))}`,
399
- `@${shellQuote(mergeRequestPath)}`,
400
- ].filter(Boolean).join(" ");
473
+ const piCommand = wrapperParts.filter(Boolean).join(" ");
401
474
 
402
475
  const tmuxMergeDir = toTmuxPath(mergeWorkDir);
403
- // Pi's TUI (ink/react) hangs silently with TERM=tmux-256color (tmux default).
404
- // Force xterm-256color so pi can render and start execution.
405
- // Same fix as buildTmuxSpawnArgs / buildLaneEnvVars.
406
- const wrappedCommand = `cd ${shellQuote(tmuxMergeDir)} && TERM=xterm-256color ${piCommand}`;
476
+ const wrappedCommand = `cd ${shellQuote(tmuxMergeDir)} && ${piCommand}`;
407
477
  const tmuxArgs = [
408
478
  "new-session", "-d",
409
479
  "-s", sessionName,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskplane",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",