@bretwardjames/ghp-cli 0.16.3 → 0.18.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.
Files changed (47) hide show
  1. package/dist/commands/agents.d.ts.map +1 -1
  2. package/dist/commands/agents.js +18 -19
  3. package/dist/commands/agents.js.map +1 -1
  4. package/dist/commands/dashboard-pipeline.d.ts.map +1 -1
  5. package/dist/commands/dashboard-pipeline.js +360 -23
  6. package/dist/commands/dashboard-pipeline.js.map +1 -1
  7. package/dist/commands/done.d.ts.map +1 -1
  8. package/dist/commands/done.js +14 -1
  9. package/dist/commands/done.js.map +1 -1
  10. package/dist/commands/mcp.d.ts +1 -0
  11. package/dist/commands/mcp.d.ts.map +1 -1
  12. package/dist/commands/mcp.js +30 -11
  13. package/dist/commands/mcp.js.map +1 -1
  14. package/dist/commands/pipeline-commands.d.ts +75 -3
  15. package/dist/commands/pipeline-commands.d.ts.map +1 -1
  16. package/dist/commands/pipeline-commands.js +370 -5
  17. package/dist/commands/pipeline-commands.js.map +1 -1
  18. package/dist/commands/pipeline-setup.d.ts +23 -0
  19. package/dist/commands/pipeline-setup.d.ts.map +1 -0
  20. package/dist/commands/pipeline-setup.js +817 -0
  21. package/dist/commands/pipeline-setup.js.map +1 -0
  22. package/dist/commands/start.js +1 -1
  23. package/dist/commands/start.js.map +1 -1
  24. package/dist/commands/stop.d.ts.map +1 -1
  25. package/dist/commands/stop.js +14 -1
  26. package/dist/commands/stop.js.map +1 -1
  27. package/dist/commands/worktree.d.ts.map +1 -1
  28. package/dist/commands/worktree.js +10 -0
  29. package/dist/commands/worktree.js.map +1 -1
  30. package/dist/config.d.ts +37 -2
  31. package/dist/config.d.ts.map +1 -1
  32. package/dist/config.js +20 -0
  33. package/dist/config.js.map +1 -1
  34. package/dist/index.js +45 -1
  35. package/dist/index.js.map +1 -1
  36. package/dist/pipeline-registry.d.ts +1 -1
  37. package/dist/pipeline-registry.d.ts.map +1 -1
  38. package/dist/pipeline-registry.js +7 -27
  39. package/dist/pipeline-registry.js.map +1 -1
  40. package/dist/terminal-utils.d.ts +56 -5
  41. package/dist/terminal-utils.d.ts.map +1 -1
  42. package/dist/terminal-utils.js +161 -37
  43. package/dist/terminal-utils.js.map +1 -1
  44. package/dist/worktree-utils.d.ts.map +1 -1
  45. package/dist/worktree-utils.js +22 -7
  46. package/dist/worktree-utils.js.map +1 -1
  47. package/package.json +1 -1
@@ -6,15 +6,17 @@
6
6
  * - window mode: agents are in separate windows. [1-9] pulls pane into dashboard window.
7
7
  */
8
8
  import chalk from 'chalk';
9
- import { execFile } from 'child_process';
9
+ import { execFile, spawn } from 'child_process';
10
10
  import { promisify } from 'util';
11
11
  import { getAgentSummaries } from '@bretwardjames/ghp-core';
12
12
  import { getMainWorktreeRoot } from '../git-utils.js';
13
- import { getConfig } from '../config.js';
14
- import { getAllPipelineEntries, getIntegrationTriggerStage, getStageEmoji } from '../pipeline-registry.js';
13
+ import { getConfig, getParallelWorkConfig } from '../config.js';
14
+ import { resolveHookScript, runUserHookScript } from './pipeline-commands.js';
15
+ import { getAllPipelineEntries, getIntegrationTriggerStage, getPipelineStages, getStageEmoji } from '../pipeline-registry.js';
15
16
  import { readSwapState } from './worktree-swap-state.js';
16
17
  import { worktreeCleanCommand, worktreeNextCommand } from './worktree-swap.js';
17
18
  import { registerCleanupHandler, resetExitState } from '../exit.js';
19
+ import { adminWindowName, agentSessionName, getTmuxPrefix, tmuxSessionExists } from '../terminal-utils.js';
18
20
  // Guard: when true, the dashboard cleanup handler is a no-op
19
21
  let suppressCleanup = false;
20
22
  /**
@@ -124,6 +126,57 @@ async function selectPane(paneId) {
124
126
  }
125
127
  catch { /* ignore */ }
126
128
  }
129
+ // ---------------------------------------------------------------------------
130
+ // tmux helpers — session mode (viewport with nested attach)
131
+ // ---------------------------------------------------------------------------
132
+ /** Create the viewport pane next to the dashboard. Returns the pane ID. */
133
+ async function createViewportPane(dashPaneId) {
134
+ const { dashboard: dbConfig } = getParallelWorkConfig();
135
+ const dirFlag = dbConfig.focusedAgent.direction === 'horizontal' ? '-h' : '-v';
136
+ const size = dbConfig.focusedAgent.size;
137
+ try {
138
+ const { stdout } = await execFileAsync('tmux', [
139
+ 'split-window', dirFlag, '-l', size, '-t', dashPaneId, '-d',
140
+ '-P', '-F', '#{pane_id}',
141
+ 'echo "Press [1-9] to view an agent"; read',
142
+ ]);
143
+ return stdout.trim() || null;
144
+ }
145
+ catch {
146
+ return null;
147
+ }
148
+ }
149
+ /** Attach the viewport pane to an agent session via respawn-pane. */
150
+ async function attachViewportToSession(viewportPaneId, sessionName) {
151
+ try {
152
+ // TMUX="" prevents nested-tmux errors; the respawn replaces the viewport process.
153
+ // The = prefix forces exact session name matching (prevents ghp-agent-8 matching ghp-agent-86).
154
+ // Session names are validated to [a-zA-Z0-9_-]+ via prefix validation + integer issue numbers.
155
+ const escaped = sessionName.replace(/'/g, "'\\''");
156
+ await execFileAsync('tmux', [
157
+ 'respawn-pane', '-k', '-t', viewportPaneId,
158
+ `TMUX="" tmux attach-session -t '=${escaped}'`,
159
+ ]);
160
+ }
161
+ catch { /* best effort */ }
162
+ }
163
+ /** Detach the viewport pane (show placeholder). */
164
+ async function detachViewport(viewportPaneId) {
165
+ try {
166
+ await execFileAsync('tmux', [
167
+ 'respawn-pane', '-k', '-t', viewportPaneId,
168
+ 'echo "Press [1-9] to view an agent"; read',
169
+ ]);
170
+ }
171
+ catch { /* best effort */ }
172
+ }
173
+ /** Kill the viewport pane. */
174
+ async function killViewportPane(viewportPaneId) {
175
+ try {
176
+ await execFileAsync('tmux', ['kill-pane', '-t', viewportPaneId]);
177
+ }
178
+ catch { /* best effort */ }
179
+ }
127
180
  /** Get the pane ID of the dashboard itself (this process). */
128
181
  function getDashboardPaneId() {
129
182
  // TMUX_PANE is set by tmux for each pane's process — unlike display-message
@@ -131,10 +184,33 @@ function getDashboardPaneId() {
131
184
  return process.env.TMUX_PANE || null;
132
185
  }
133
186
  // ---------------------------------------------------------------------------
187
+ // Hook: dashboard-opened
188
+ // ---------------------------------------------------------------------------
189
+ async function fireDashboardOpenedHook(paneId, mode) {
190
+ const repoRoot = await getMainWorktreeRoot();
191
+ if (!repoRoot)
192
+ return;
193
+ const scriptPath = resolveHookScript(repoRoot, 'dashboard-opened', mode);
194
+ if (!scriptPath)
195
+ return;
196
+ try {
197
+ const child = spawn(scriptPath, [], {
198
+ cwd: repoRoot,
199
+ stdio: ['pipe', 'ignore', 'ignore'],
200
+ detached: true,
201
+ });
202
+ child.stdin.write(JSON.stringify({ pane_id: paneId, window_name: adminWindowName() }));
203
+ child.stdin.end();
204
+ child.unref();
205
+ }
206
+ catch { /* silent */ }
207
+ }
208
+ // ---------------------------------------------------------------------------
134
209
  // Coordinator pane detection
135
210
  // ---------------------------------------------------------------------------
136
211
  async function findCoordinatorPane() {
137
- const candidates = ['ghp-root', 'ghp-coordinator', 'ghp-main'];
212
+ const prefix = getTmuxPrefix();
213
+ const candidates = [`${prefix}-root`, `${prefix}-coordinator`, `${prefix}-main`];
138
214
  for (const name of candidates) {
139
215
  try {
140
216
  const { stdout } = await execFileAsync('tmux', ['display-message', '-t', name, '-p', '#{window_name}']);
@@ -194,18 +270,24 @@ function renderEntry(e, prefix, attached, showAction) {
194
270
  console.log(` ${chalk.dim(`└─ ${e.agent.currentAction.substring(0, 55)}`)}`);
195
271
  }
196
272
  }
197
- function renderDashboard(entries, tmuxMode, attached, now, mainDirty) {
273
+ function renderDashboard(entries, tmuxMode, attached, now, mainDirty, hookMode, hookModes) {
198
274
  process.stdout.write('\x1b[2J\x1b[H'); // clear
199
275
  const triggerStage = getIntegrationTriggerStage();
276
+ const stages = getPipelineStages();
277
+ const hasTriggerStage = stages.includes(triggerStage);
200
278
  const waiting = entries.filter(e => e.agent?.waitingForInput || e.pipeline.stage === 'needs_attention');
201
- const ready = entries.filter(e => e.pipeline.stage === triggerStage && !e.inMainRepo);
279
+ const ready = hasTriggerStage ? entries.filter(e => e.pipeline.stage === triggerStage && !e.inMainRepo) : [];
202
280
  const testing = entries.filter(e => e.inMainRepo);
281
+ const stopped = entries.filter(e => e.pipeline.stage === 'stopped' &&
282
+ !e.agent?.waitingForInput &&
283
+ !e.inMainRepo);
203
284
  const working = entries.filter(e => !e.agent?.waitingForInput &&
204
285
  e.pipeline.stage !== 'needs_attention' &&
205
- e.pipeline.stage !== triggerStage &&
286
+ e.pipeline.stage !== 'stopped' &&
287
+ (!hasTriggerStage || e.pipeline.stage !== triggerStage) &&
206
288
  !e.inMainRepo);
207
- // Number all entries for keypress selection (attention first, then working)
208
- const numbered = [...waiting, ...working];
289
+ // Number all entries for keypress selection (attention first, then stopped, then working)
290
+ const numbered = [...waiting, ...stopped, ...working];
209
291
  numbered.forEach((e, i) => { e.attentionIndex = i + 1; });
210
292
  const attachedLabel = attached
211
293
  ? chalk.bgCyan.black(` VIEWING: #${attached.issueNumber} `)
@@ -220,6 +302,14 @@ function renderDashboard(entries, tmuxMode, attached, now, mainDirty) {
220
302
  }
221
303
  console.log();
222
304
  }
305
+ if (stopped.length > 0) {
306
+ console.log(chalk.dim.bold(' STOPPED'));
307
+ for (const e of stopped) {
308
+ const key = e.attentionIndex ? chalk.dim(`[${e.attentionIndex}]`) : ' ';
309
+ renderEntry(e, `${key} ${chalk.dim('⏸')}`, attached, false);
310
+ }
311
+ console.log();
312
+ }
223
313
  if (ready.length > 0) {
224
314
  const blocked = mainDirty ? chalk.red.bold(' BLOCKED') + chalk.red(' — main repo has uncommitted changes') : '';
225
315
  console.log(chalk.green.bold(' READY FOR INTEGRATION') + blocked);
@@ -261,15 +351,26 @@ function renderDashboard(entries, tmuxMode, attached, now, mainDirty) {
261
351
  console.log();
262
352
  }
263
353
  console.log(chalk.dim('─'.repeat(70)));
354
+ const modeLabel = (hookModes && hookModes.length > 0)
355
+ ? ` [m] mode: ${hookMode ? chalk.cyan(hookMode) : chalk.dim('default')}`
356
+ : '';
264
357
  if (tmuxMode === 'pane') {
265
- console.log(chalk.dim('[1-9] focus agent [i] next integration [x] clean [q] quit'));
358
+ console.log(chalk.dim(`[1-9] focus agent${modeLabel} [i] next integration [x] clean [q] quit`));
359
+ }
360
+ else if (tmuxMode === 'session') {
361
+ if (attached) {
362
+ console.log(chalk.dim(`[1-9] swap [esc] detach${modeLabel} [i] next integration [x] clean [q] quit`));
363
+ }
364
+ else {
365
+ console.log(chalk.dim(`[1-9] attach session${modeLabel} [i] next integration [x] clean [q] quit`));
366
+ }
266
367
  }
267
368
  else {
268
369
  if (attached) {
269
- console.log(chalk.dim('[1-9] swap [esc] send back [c] coordinator [q] quit'));
370
+ console.log(chalk.dim(`[1-9] swap [esc] send back${modeLabel} [c] coordinator [q] quit`));
270
371
  }
271
372
  else {
272
- console.log(chalk.dim('[1-9] pull pane [i] next integration [x] clean [c] coordinator [q] quit'));
373
+ console.log(chalk.dim(`[1-9] pull pane${modeLabel} [i] next integration [x] clean [c] coordinator [q] quit`));
273
374
  }
274
375
  }
275
376
  }
@@ -292,6 +393,18 @@ export async function pipelineDashboardCommand(options = {}) {
292
393
  let dashboardPaneId = null;
293
394
  let coordinatorWindow = null;
294
395
  let refreshTimer = null;
396
+ // Session mode viewport state
397
+ let viewport = null;
398
+ // Hook mode state — runtime only, initialized from config
399
+ const pipelineConfig = getConfig('pipeline');
400
+ const hookModes = pipelineConfig?.hookModes ?? [];
401
+ let currentHookMode = pipelineConfig?.defaultHookMode ?? null;
402
+ // Validate that defaultHookMode is in the hookModes list
403
+ if (currentHookMode && hookModes.length > 0 && !hookModes.includes(currentHookMode)) {
404
+ currentHookMode = hookModes[0];
405
+ }
406
+ if (hookModes.length === 0)
407
+ currentHookMode = null;
295
408
  if (process.env.TMUX) {
296
409
  dashboardPaneId = getDashboardPaneId();
297
410
  if (tmuxMode === 'window') {
@@ -315,19 +428,43 @@ export async function pipelineDashboardCommand(options = {}) {
315
428
  }));
316
429
  }
317
430
  async function refresh() {
431
+ // Session mode: validate attached session still exists
432
+ if (tmuxMode === 'session' && viewport?.attachedIssue != null) {
433
+ const sessionName = agentSessionName(viewport.attachedIssue);
434
+ const exists = await tmuxSessionExists(sessionName);
435
+ if (!exists) {
436
+ // Fire agent-unfocused hook before clearing state (fire-and-forget)
437
+ const repoRoot = await getMainWorktreeRoot();
438
+ if (repoRoot) {
439
+ const entry = getAllPipelineEntries(repoRoot).find(e => e.issueNumber === viewport.attachedIssue);
440
+ if (entry) {
441
+ runUserHookScript('agent-unfocused', agentPayload(entry), entry.worktreePath, currentHookMode);
442
+ }
443
+ }
444
+ viewport.attachedIssue = null;
445
+ attached = null;
446
+ await detachViewport(viewport.paneId);
447
+ }
448
+ }
318
449
  const repoRoot = await getMainWorktreeRoot();
319
450
  const entries = await buildEntries();
320
451
  const dirty = repoRoot ? await isMainRepoDirty(repoRoot) : false;
321
- renderDashboard(entries, tmuxMode, attached, new Date().toLocaleTimeString(), dirty);
452
+ renderDashboard(entries, tmuxMode, attached, new Date().toLocaleTimeString(), dirty, currentHookMode, hookModes);
322
453
  }
323
454
  function getNumberedEntry(entries, digit) {
324
455
  const triggerStage = getIntegrationTriggerStage();
456
+ const stages = getPipelineStages();
457
+ const hasTriggerStage = stages.includes(triggerStage);
325
458
  const waiting = entries.filter(e => e.agent?.waitingForInput || e.pipeline.stage === 'needs_attention');
459
+ const stopped = entries.filter(e => e.pipeline.stage === 'stopped' &&
460
+ !e.agent?.waitingForInput &&
461
+ !e.inMainRepo);
326
462
  const working = entries.filter(e => !e.agent?.waitingForInput &&
327
463
  e.pipeline.stage !== 'needs_attention' &&
328
- e.pipeline.stage !== triggerStage &&
464
+ e.pipeline.stage !== 'stopped' &&
465
+ (!hasTriggerStage || e.pipeline.stage !== triggerStage) &&
329
466
  !e.inMainRepo);
330
- const numbered = [...waiting, ...working];
467
+ const numbered = [...waiting, ...stopped, ...working];
331
468
  return numbered[digit - 1];
332
469
  }
333
470
  // ---------------------------------------------------------------------------
@@ -348,12 +485,68 @@ export async function pipelineDashboardCommand(options = {}) {
348
485
  // ---------------------------------------------------------------------------
349
486
  // Window mode: pull/release
350
487
  // ---------------------------------------------------------------------------
488
+ /** Build the JSON payload for a focused/unfocused hook. */
489
+ function agentPayload(entry) {
490
+ return JSON.stringify({
491
+ issueNumber: entry.issueNumber,
492
+ worktreePath: entry.worktreePath,
493
+ branch: entry.branch,
494
+ });
495
+ }
496
+ /** Fire the agent-swapped hook, or fall back to sequential unfocus→focus. */
497
+ async function fireSwapHook(oldEntry, newEntry) {
498
+ const repoRoot = await getMainWorktreeRoot();
499
+ if (!repoRoot)
500
+ return;
501
+ const swapScript = resolveHookScript(repoRoot, 'agent-swapped', currentHookMode);
502
+ if (swapScript) {
503
+ // Atomic swap hook
504
+ const payload = JSON.stringify({
505
+ old: { issueNumber: oldEntry.issueNumber, worktreePath: oldEntry.worktreePath, branch: oldEntry.branch },
506
+ new: { issueNumber: newEntry.issueNumber, worktreePath: newEntry.worktreePath, branch: newEntry.branch },
507
+ });
508
+ try {
509
+ const child = spawn(swapScript, [], {
510
+ cwd: newEntry.worktreePath,
511
+ stdio: ['pipe', 'ignore', 'ignore'],
512
+ detached: true,
513
+ });
514
+ child.stdin.write(payload);
515
+ child.stdin.end();
516
+ child.unref();
517
+ }
518
+ catch { /* silent */ }
519
+ }
520
+ else {
521
+ // Fallback: sequential unfocus→focus (respecting hookModeSwapOrder)
522
+ // Await the first to guarantee ordering; second is fire-and-forget
523
+ const swapOrder = pipelineConfig?.hookModeSwapOrder ?? 'unfocus-first';
524
+ if (swapOrder === 'focus-first') {
525
+ await runUserHookScript('agent-focused', agentPayload(newEntry), newEntry.worktreePath, currentHookMode);
526
+ runUserHookScript('agent-unfocused', agentPayload(oldEntry), oldEntry.worktreePath, currentHookMode);
527
+ }
528
+ else {
529
+ await runUserHookScript('agent-unfocused', agentPayload(oldEntry), oldEntry.worktreePath, currentHookMode);
530
+ runUserHookScript('agent-focused', agentPayload(newEntry), newEntry.worktreePath, currentHookMode);
531
+ }
532
+ }
533
+ }
351
534
  async function pullAgentPane(issueNumber) {
352
- // Hot-swap: release current pane before pulling the new one
535
+ let previousEntry = null;
536
+ // Hot-swap: if already viewing a different agent
353
537
  if (attached) {
354
538
  if (attached.issueNumber === issueNumber)
355
539
  return; // already showing this one
356
- await sendPaneBack();
540
+ // Look up the old entry before releasing
541
+ if (attached.issueNumber > 0) {
542
+ const repoRoot = await getMainWorktreeRoot();
543
+ if (repoRoot) {
544
+ previousEntry = getAllPipelineEntries(repoRoot).find(e => e.issueNumber === attached.issueNumber) ?? null;
545
+ }
546
+ }
547
+ // Release the tmux pane
548
+ await releasePaneToWindow(attached);
549
+ attached = null;
357
550
  }
358
551
  if (!dashboardPaneId)
359
552
  return;
@@ -367,9 +560,13 @@ export async function pipelineDashboardCommand(options = {}) {
367
560
  const agent = await findAgentByWorktreePath(pipelineEntry.worktreePath);
368
561
  if (!agent)
369
562
  return;
563
+ // Read focused agent config for direction/size
564
+ const { dashboard: dbConfig } = getParallelWorkConfig();
565
+ const dirFlag = dbConfig.focusedAgent.direction === 'horizontal' ? '-h' : '-v';
566
+ const size = dbConfig.focusedAgent.size;
370
567
  // Pull the pane into dashboard
371
568
  try {
372
- await execFileAsync('tmux', ['join-pane', '-v', '-l', '50%', '-t', dashboardPaneId, '-s', agent.paneId]);
569
+ await execFileAsync('tmux', ['join-pane', dirFlag, '-l', size, '-t', dashboardPaneId, '-s', agent.paneId]);
373
570
  }
374
571
  catch {
375
572
  return;
@@ -380,24 +577,120 @@ export async function pipelineDashboardCommand(options = {}) {
380
577
  }
381
578
  catch { /* best effort */ }
382
579
  attached = { issueNumber, paneId: agent.paneId, sourceWindowName: agent.windowName };
580
+ // Fire hooks
581
+ if (previousEntry) {
582
+ // Hot-swap: atomic agent-swapped or sequential fallback
583
+ fireSwapHook(previousEntry, pipelineEntry);
584
+ }
585
+ else {
586
+ // Fresh pull: agent-focused only
587
+ runUserHookScript('agent-focused', agentPayload(pipelineEntry), pipelineEntry.worktreePath, currentHookMode);
588
+ }
383
589
  await refresh();
384
590
  }
385
591
  async function sendPaneBack() {
386
592
  if (!attached)
387
593
  return;
594
+ // Fire agent-unfocused hook before releasing (fire-and-forget)
595
+ if (attached.issueNumber > 0) {
596
+ const repoRoot = await getMainWorktreeRoot();
597
+ if (repoRoot) {
598
+ const entry = getAllPipelineEntries(repoRoot).find(e => e.issueNumber === attached.issueNumber);
599
+ if (entry) {
600
+ runUserHookScript('agent-unfocused', agentPayload(entry), entry.worktreePath, currentHookMode);
601
+ }
602
+ }
603
+ }
388
604
  await releasePaneToWindow(attached);
389
605
  attached = null;
390
606
  await refresh();
391
607
  }
392
608
  // ---------------------------------------------------------------------------
609
+ // Session mode: attach/detach via viewport
610
+ // ---------------------------------------------------------------------------
611
+ async function attachSessionToViewport(issueNumber) {
612
+ if (!viewport)
613
+ return;
614
+ if (viewport.attachedIssue === issueNumber)
615
+ return; // already attached
616
+ const sessionName = agentSessionName(issueNumber);
617
+ const exists = await tmuxSessionExists(sessionName);
618
+ if (!exists)
619
+ return;
620
+ let previousEntry = null;
621
+ // Hot-swap: if already viewing a different agent
622
+ if (viewport.attachedIssue != null) {
623
+ const repoRoot = await getMainWorktreeRoot();
624
+ if (repoRoot) {
625
+ previousEntry = getAllPipelineEntries(repoRoot).find(e => e.issueNumber === viewport.attachedIssue) ?? null;
626
+ }
627
+ }
628
+ await attachViewportToSession(viewport.paneId, sessionName);
629
+ viewport.attachedIssue = issueNumber;
630
+ attached = { issueNumber, paneId: viewport.paneId, sourceWindowName: '' };
631
+ // Set pane border title
632
+ try {
633
+ await execFileAsync('tmux', ['select-pane', '-t', viewport.paneId, '-T', `Agent #${issueNumber}`]);
634
+ }
635
+ catch { /* best effort */ }
636
+ // Fire hooks
637
+ const repoRoot = await getMainWorktreeRoot();
638
+ if (repoRoot) {
639
+ const newEntry = getAllPipelineEntries(repoRoot).find(e => e.issueNumber === issueNumber);
640
+ if (newEntry) {
641
+ if (previousEntry) {
642
+ fireSwapHook(previousEntry, newEntry);
643
+ }
644
+ else {
645
+ runUserHookScript('agent-focused', agentPayload(newEntry), newEntry.worktreePath, currentHookMode);
646
+ }
647
+ }
648
+ }
649
+ await refresh();
650
+ }
651
+ /** Fire agent-unfocused hook and kill the viewport pane. Fire-and-forget safe. */
652
+ function cleanupViewport() {
653
+ if (!viewport)
654
+ return;
655
+ if (viewport.attachedIssue != null) {
656
+ getMainWorktreeRoot().then(repoRoot => {
657
+ if (!repoRoot)
658
+ return;
659
+ const entry = getAllPipelineEntries(repoRoot).find(e => e.issueNumber === viewport.attachedIssue);
660
+ if (entry)
661
+ runUserHookScript('agent-unfocused', agentPayload(entry), entry.worktreePath, currentHookMode);
662
+ }).catch(() => { });
663
+ }
664
+ killViewportPane(viewport.paneId).catch(() => { });
665
+ }
666
+ async function detachSessionFromViewport() {
667
+ if (!viewport || viewport.attachedIssue == null)
668
+ return;
669
+ // Fire agent-unfocused hook
670
+ const repoRoot = await getMainWorktreeRoot();
671
+ if (repoRoot) {
672
+ const entry = getAllPipelineEntries(repoRoot).find(e => e.issueNumber === viewport.attachedIssue);
673
+ if (entry) {
674
+ runUserHookScript('agent-unfocused', agentPayload(entry), entry.worktreePath, currentHookMode);
675
+ }
676
+ }
677
+ await detachViewport(viewport.paneId);
678
+ viewport.attachedIssue = null;
679
+ attached = null;
680
+ await refresh();
681
+ }
682
+ // ---------------------------------------------------------------------------
393
683
  // Keypress handler
394
684
  // ---------------------------------------------------------------------------
395
685
  async function handleKey(key) {
396
- // esc — send pane back (window mode) or refocus dashboard (pane mode)
686
+ // esc — send pane back (window mode), detach (session mode), or refocus dashboard (pane mode)
397
687
  if (key === '\x1b') {
398
688
  if (tmuxMode === 'pane' && dashboardPaneId) {
399
689
  await selectPane(dashboardPaneId);
400
690
  }
691
+ else if (tmuxMode === 'session') {
692
+ await detachSessionFromViewport();
693
+ }
401
694
  else if (tmuxMode === 'window' && attached) {
402
695
  await sendPaneBack();
403
696
  }
@@ -405,8 +698,12 @@ export async function pipelineDashboardCommand(options = {}) {
405
698
  }
406
699
  // q — quit
407
700
  if (key === 'q' || key === 'Q') {
408
- if (attached)
701
+ if (tmuxMode === 'session' && viewport) {
702
+ cleanupViewport();
703
+ }
704
+ else if (attached) {
409
705
  await sendPaneBack();
706
+ }
410
707
  if (refreshTimer)
411
708
  clearInterval(refreshTimer);
412
709
  if (process.stdin.isTTY) {
@@ -445,13 +742,31 @@ export async function pipelineDashboardCommand(options = {}) {
445
742
  await refresh();
446
743
  return;
447
744
  }
745
+ // m — cycle hook modes (includes null/"default" as the last position)
746
+ if (key === 'm' || key === 'M') {
747
+ if (hookModes.length > 0) {
748
+ const oldMode = currentHookMode;
749
+ const currentIdx = currentHookMode ? hookModes.indexOf(currentHookMode) : -1;
750
+ const nextIdx = currentIdx + 1;
751
+ // After the last named mode, wrap to null (default/unsuffixed hooks)
752
+ currentHookMode = nextIdx < hookModes.length ? hookModes[nextIdx] : null;
753
+ // Fire mode-switched hook (fire-and-forget, never mode-suffixed)
754
+ const modePayload = JSON.stringify({ oldMode: oldMode ?? null, newMode: currentHookMode ?? null });
755
+ const modeRoot = await getMainWorktreeRoot();
756
+ if (modeRoot) {
757
+ runUserHookScript('mode-switched', modePayload, modeRoot, null);
758
+ }
759
+ await refresh();
760
+ }
761
+ return;
762
+ }
448
763
  // x — clean
449
764
  if (key === 'x' || key === 'X') {
450
765
  await safeExec(() => worktreeCleanCommand({}));
451
766
  await refresh();
452
767
  return;
453
768
  }
454
- // 1-9 — zoom (pane mode) or pull (window mode)
769
+ // 1-9 — focus (pane mode), pull (window mode), or attach (session mode)
455
770
  const digit = parseInt(key, 10);
456
771
  if (!isNaN(digit) && digit >= 1 && digit <= 9) {
457
772
  const entries = await buildEntries();
@@ -461,6 +776,9 @@ export async function pipelineDashboardCommand(options = {}) {
461
776
  if (tmuxMode === 'pane') {
462
777
  await focusAgent(entry.pipeline.issueNumber);
463
778
  }
779
+ else if (tmuxMode === 'session') {
780
+ await attachSessionToViewport(entry.pipeline.issueNumber);
781
+ }
464
782
  else {
465
783
  await pullAgentPane(entry.pipeline.issueNumber);
466
784
  }
@@ -470,7 +788,18 @@ export async function pipelineDashboardCommand(options = {}) {
470
788
  // ---------------------------------------------------------------------------
471
789
  // Start
472
790
  // ---------------------------------------------------------------------------
791
+ // Session mode: create viewport pane at startup
792
+ if (tmuxMode === 'session' && dashboardPaneId) {
793
+ const vpPaneId = await createViewportPane(dashboardPaneId);
794
+ if (vpPaneId) {
795
+ viewport = { paneId: vpPaneId, attachedIssue: null };
796
+ }
797
+ }
473
798
  await refresh();
799
+ // Fire dashboard-opened hook (fire-and-forget)
800
+ if (dashboardPaneId) {
801
+ fireDashboardOpenedHook(dashboardPaneId, currentHookMode);
802
+ }
474
803
  refreshTimer = setInterval(() => {
475
804
  refresh().catch(() => { });
476
805
  }, intervalMs);
@@ -479,8 +808,12 @@ export async function pipelineDashboardCommand(options = {}) {
479
808
  return;
480
809
  if (refreshTimer)
481
810
  clearInterval(refreshTimer);
482
- if (attached)
811
+ if (viewport) {
812
+ cleanupViewport();
813
+ }
814
+ else if (attached) {
483
815
  releasePaneToWindow(attached).catch(() => { });
816
+ }
484
817
  process.stdin.setRawMode?.(false);
485
818
  process.stdin.pause();
486
819
  });
@@ -490,8 +823,12 @@ export async function pipelineDashboardCommand(options = {}) {
490
823
  process.stdin.setEncoding('utf-8');
491
824
  process.stdin.on('data', (key) => {
492
825
  if (key === '\u0003') {
493
- if (attached)
826
+ if (viewport) {
827
+ cleanupViewport();
828
+ }
829
+ else if (attached) {
494
830
  releasePaneToWindow(attached).catch(() => { });
831
+ }
495
832
  if (refreshTimer)
496
833
  clearInterval(refreshTimer);
497
834
  process.stdin.setRawMode(false);