@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.
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +18 -19
- package/dist/commands/agents.js.map +1 -1
- package/dist/commands/dashboard-pipeline.d.ts.map +1 -1
- package/dist/commands/dashboard-pipeline.js +360 -23
- package/dist/commands/dashboard-pipeline.js.map +1 -1
- package/dist/commands/done.d.ts.map +1 -1
- package/dist/commands/done.js +14 -1
- package/dist/commands/done.js.map +1 -1
- package/dist/commands/mcp.d.ts +1 -0
- package/dist/commands/mcp.d.ts.map +1 -1
- package/dist/commands/mcp.js +30 -11
- package/dist/commands/mcp.js.map +1 -1
- package/dist/commands/pipeline-commands.d.ts +75 -3
- package/dist/commands/pipeline-commands.d.ts.map +1 -1
- package/dist/commands/pipeline-commands.js +370 -5
- package/dist/commands/pipeline-commands.js.map +1 -1
- package/dist/commands/pipeline-setup.d.ts +23 -0
- package/dist/commands/pipeline-setup.d.ts.map +1 -0
- package/dist/commands/pipeline-setup.js +817 -0
- package/dist/commands/pipeline-setup.js.map +1 -0
- package/dist/commands/start.js +1 -1
- package/dist/commands/start.js.map +1 -1
- package/dist/commands/stop.d.ts.map +1 -1
- package/dist/commands/stop.js +14 -1
- package/dist/commands/stop.js.map +1 -1
- package/dist/commands/worktree.d.ts.map +1 -1
- package/dist/commands/worktree.js +10 -0
- package/dist/commands/worktree.js.map +1 -1
- package/dist/config.d.ts +37 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +20 -0
- package/dist/config.js.map +1 -1
- package/dist/index.js +45 -1
- package/dist/index.js.map +1 -1
- package/dist/pipeline-registry.d.ts +1 -1
- package/dist/pipeline-registry.d.ts.map +1 -1
- package/dist/pipeline-registry.js +7 -27
- package/dist/pipeline-registry.js.map +1 -1
- package/dist/terminal-utils.d.ts +56 -5
- package/dist/terminal-utils.d.ts.map +1 -1
- package/dist/terminal-utils.js +161 -37
- package/dist/terminal-utils.js.map +1 -1
- package/dist/worktree-utils.d.ts.map +1 -1
- package/dist/worktree-utils.js +22 -7
- package/dist/worktree-utils.js.map +1 -1
- 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 {
|
|
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
|
|
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 !==
|
|
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(
|
|
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(
|
|
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(
|
|
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 !==
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
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 (
|
|
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 —
|
|
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 (
|
|
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 (
|
|
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);
|