taskplane 0.0.1 → 0.1.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.
Files changed (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2 -20
  3. package/bin/taskplane.mjs +706 -0
  4. package/dashboard/public/app.js +900 -0
  5. package/dashboard/public/index.html +92 -0
  6. package/dashboard/public/style.css +924 -0
  7. package/dashboard/server.cjs +531 -0
  8. package/extensions/task-orchestrator.ts +28 -0
  9. package/extensions/task-runner.ts +1923 -0
  10. package/extensions/taskplane/abort.ts +466 -0
  11. package/extensions/taskplane/config.ts +102 -0
  12. package/extensions/taskplane/discovery.ts +988 -0
  13. package/extensions/taskplane/engine.ts +758 -0
  14. package/extensions/taskplane/execution.ts +1752 -0
  15. package/extensions/taskplane/extension.ts +577 -0
  16. package/extensions/taskplane/formatting.ts +718 -0
  17. package/extensions/taskplane/git.ts +38 -0
  18. package/extensions/taskplane/index.ts +22 -0
  19. package/extensions/taskplane/merge.ts +795 -0
  20. package/extensions/taskplane/messages.ts +134 -0
  21. package/extensions/taskplane/persistence.ts +1121 -0
  22. package/extensions/taskplane/resume.ts +1092 -0
  23. package/extensions/taskplane/sessions.ts +92 -0
  24. package/extensions/taskplane/types.ts +1514 -0
  25. package/extensions/taskplane/waves.ts +900 -0
  26. package/extensions/taskplane/worktree.ts +1624 -0
  27. package/package.json +50 -4
  28. package/skills/create-taskplane-task/SKILL.md +326 -0
  29. package/skills/create-taskplane-task/references/context-template.md +78 -0
  30. package/skills/create-taskplane-task/references/prompt-template.md +246 -0
  31. package/templates/agents/task-merger.md +256 -0
  32. package/templates/agents/task-reviewer.md +81 -0
  33. package/templates/agents/task-worker.md +140 -0
  34. package/templates/config/task-orchestrator.yaml +89 -0
  35. package/templates/config/task-runner.yaml +99 -0
  36. package/templates/tasks/CONTEXT.md +31 -0
  37. package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +90 -0
  38. package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -0
@@ -0,0 +1,577 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+
3
+ import { execSync } from "child_process";
4
+
5
+ import {
6
+ DEFAULT_ORCHESTRATOR_CONFIG,
7
+ DEFAULT_TASK_RUNNER_CONFIG,
8
+ ORCH_MESSAGES,
9
+ computeWaveAssignments,
10
+ createOrchWidget,
11
+ deleteBatchState,
12
+ detectOrphanSessions,
13
+ executeAbort,
14
+ executeLane,
15
+ executeOrchBatch,
16
+ formatDependencyGraph,
17
+ formatDiscoveryResults,
18
+ formatOrchSessions,
19
+ formatPreflightResults,
20
+ formatWavePlan,
21
+ freshOrchBatchState,
22
+ listOrchSessions,
23
+ loadBatchState,
24
+ loadOrchestratorConfig,
25
+ loadTaskRunnerConfig,
26
+ parseOrchSessionNames,
27
+ resumeOrchBatch,
28
+ runDiscovery,
29
+ runPreflight,
30
+ } from "./index.ts";
31
+ import type {
32
+ AbortMode,
33
+ MonitorState,
34
+ OrchestratorConfig,
35
+ PersistedBatchState,
36
+ TaskRunnerConfig,
37
+ } from "./index.ts";
38
+
39
+ // ── Extension ────────────────────────────────────────────────────────
40
+
41
+ export default function (pi: ExtensionAPI) {
42
+ let orchBatchState = freshOrchBatchState();
43
+ let orchConfig: OrchestratorConfig = { ...DEFAULT_ORCHESTRATOR_CONFIG };
44
+ let runnerConfig: TaskRunnerConfig = { ...DEFAULT_TASK_RUNNER_CONFIG };
45
+ let orchWidgetCtx: ExtensionContext | undefined;
46
+ let latestMonitorState: MonitorState | null = null;
47
+
48
+ // ── Widget Rendering ─────────────────────────────────────────────
49
+
50
+ function updateOrchWidget() {
51
+ if (!orchWidgetCtx) return;
52
+ const ctx = orchWidgetCtx;
53
+ const prefix = orchConfig.orchestrator.tmux_prefix;
54
+
55
+ ctx.ui.setWidget(
56
+ "task-orchestrator",
57
+ createOrchWidget(
58
+ () => orchBatchState,
59
+ () => latestMonitorState,
60
+ prefix,
61
+ ),
62
+ );
63
+ }
64
+
65
+ // ── Commands ─────────────────────────────────────────────────────
66
+
67
+ pi.registerCommand("orch", {
68
+ description: "Start batch execution: /orch <areas|paths|all>",
69
+ handler: async (args, ctx) => {
70
+ if (!args?.trim()) {
71
+ ctx.ui.notify(
72
+ "Usage: /orch <areas|paths|all>\n\n" +
73
+ "Examples:\n" +
74
+ " /orch all Run all pending tasks\n" +
75
+ " /orch time-off performance-management Run specific areas\n" +
76
+ " /orch path/to/tasks Scan directory\n" +
77
+ " /orch path/to/PROMPT.md Single task with isolation",
78
+ "info",
79
+ );
80
+ return;
81
+ }
82
+
83
+ // Prevent concurrent batch execution (merging is an active state)
84
+ if (orchBatchState.phase !== "idle" && orchBatchState.phase !== "completed" && orchBatchState.phase !== "failed" && orchBatchState.phase !== "stopped") {
85
+ ctx.ui.notify(
86
+ `⚠️ A batch is already ${orchBatchState.phase} (${orchBatchState.batchId}). ` +
87
+ `Use /orch-pause to pause or wait for completion.`,
88
+ "warning",
89
+ );
90
+ return;
91
+ }
92
+
93
+ // ── Orphan detection (TS-009 Step 3) ─────────────────────
94
+ const orphanResult = detectOrphanSessions(
95
+ orchConfig.orchestrator.tmux_prefix,
96
+ ctx.cwd,
97
+ );
98
+
99
+ switch (orphanResult.recommendedAction) {
100
+ case "resume": {
101
+ // Safety net: if the persisted phase is not actually resumable (e.g. "failed",
102
+ // "stopped") — which can happen when the batch crashed after writing a terminal
103
+ // phase but before /orch-abort cleaned up — auto-delete the state file and
104
+ // fall through to start fresh rather than blocking the user with a catch-22.
105
+ const resumablePhases = ["paused", "executing", "merging"];
106
+ const phase = orphanResult.loadedState?.phase ?? "";
107
+ const hasOrphans = orphanResult.orphanSessions.length > 0;
108
+ if (!hasOrphans && !resumablePhases.includes(phase)) {
109
+ try { deleteBatchState(ctx.cwd); } catch { /* best effort */ }
110
+ ctx.ui.notify(
111
+ `🧹 Cleared non-resumable stale batch (${orphanResult.loadedState?.batchId}, phase=${phase}). Starting fresh.`,
112
+ "info",
113
+ );
114
+ break; // fall through to start a new batch
115
+ }
116
+ // Genuinely resumable or has live orphan sessions — prompt user
117
+ ctx.ui.notify(orphanResult.userMessage, "warning");
118
+ return;
119
+ }
120
+
121
+ case "abort-orphans":
122
+ // Orphan sessions without usable state
123
+ ctx.ui.notify(orphanResult.userMessage, "warning");
124
+ return;
125
+
126
+ case "cleanup-stale":
127
+ // No orphans + stale/invalid state file — auto-delete and continue
128
+ try {
129
+ deleteBatchState(ctx.cwd);
130
+ } catch {
131
+ // Best-effort cleanup — proceed even if delete fails
132
+ }
133
+ if (orphanResult.userMessage) {
134
+ ctx.ui.notify(orphanResult.userMessage, "info");
135
+ }
136
+ break;
137
+
138
+ case "start-fresh":
139
+ // No orphans, no state file — proceed normally
140
+ break;
141
+ }
142
+
143
+ // Reset batch state for new execution
144
+ orchBatchState = freshOrchBatchState();
145
+ latestMonitorState = null;
146
+ updateOrchWidget();
147
+
148
+ await executeOrchBatch(
149
+ args,
150
+ orchConfig,
151
+ runnerConfig,
152
+ ctx.cwd,
153
+ orchBatchState,
154
+ (message, level) => {
155
+ ctx.ui.notify(message, level);
156
+ updateOrchWidget(); // Refresh widget on every phase message
157
+ },
158
+ (monState: MonitorState) => {
159
+ const changed = !latestMonitorState ||
160
+ latestMonitorState.totalDone !== monState.totalDone ||
161
+ latestMonitorState.totalFailed !== monState.totalFailed ||
162
+ latestMonitorState.lanes.some((l, i) =>
163
+ l.currentTaskId !== monState.lanes[i]?.currentTaskId ||
164
+ l.currentStep !== monState.lanes[i]?.currentStep ||
165
+ l.completedChecks !== monState.lanes[i]?.completedChecks,
166
+ );
167
+ latestMonitorState = monState;
168
+ if (changed) updateOrchWidget(); // Only refresh on actual state change
169
+ },
170
+ );
171
+
172
+ // Final widget update after batch completes
173
+ updateOrchWidget();
174
+ },
175
+ });
176
+
177
+ pi.registerCommand("orch-plan", {
178
+ description: "Preview execution plan: /orch-plan <areas|paths|all> [--refresh]",
179
+ handler: async (args, ctx) => {
180
+ if (!args?.trim()) {
181
+ ctx.ui.notify(
182
+ "Usage: /orch-plan <areas|paths|all> [--refresh]\n\n" +
183
+ "Shows the execution plan (tasks, waves, lane assignments)\n" +
184
+ "without actually executing anything.\n\n" +
185
+ "Options:\n" +
186
+ " --refresh Force re-scan of areas (bypass dependency cache)\n\n" +
187
+ "Examples:\n" +
188
+ " /orch-plan all\n" +
189
+ " /orch-plan time-off notifications\n" +
190
+ " /orch-plan docs/task-management/domains/time-off/tasks\n" +
191
+ " /orch-plan all --refresh",
192
+ "info",
193
+ );
194
+ return;
195
+ }
196
+
197
+ // Parse --refresh flag
198
+ const hasRefresh = /--refresh/.test(args);
199
+ const cleanArgs = args.replace(/--refresh/g, "").trim();
200
+ if (!cleanArgs) {
201
+ ctx.ui.notify(
202
+ "Usage: /orch-plan <areas|paths|all> [--refresh]\n" +
203
+ "Error: target argument required (e.g., 'all', area name, or path)",
204
+ "error",
205
+ );
206
+ return;
207
+ }
208
+ if (hasRefresh) {
209
+ ctx.ui.notify("🔄 Refresh mode: re-scanning all areas (cache bypassed)", "info");
210
+ }
211
+
212
+ // ── Section 1: Preflight ─────────────────────────────────
213
+ const preflight = runPreflight(orchConfig);
214
+ ctx.ui.notify(formatPreflightResults(preflight), preflight.passed ? "info" : "error");
215
+ if (!preflight.passed) return;
216
+
217
+ // ── Section 2: Discovery ─────────────────────────────────
218
+ const discovery = runDiscovery(cleanArgs, runnerConfig.task_areas, ctx.cwd, {
219
+ refreshDependencies: hasRefresh,
220
+ dependencySource: orchConfig.dependencies.source,
221
+ useDependencyCache: orchConfig.dependencies.cache,
222
+ });
223
+ ctx.ui.notify(formatDiscoveryResults(discovery), discovery.errors.length > 0 ? "warning" : "info");
224
+
225
+ // Check for fatal errors
226
+ const fatalErrors = discovery.errors.filter(
227
+ (e) =>
228
+ e.code === "DUPLICATE_ID" ||
229
+ e.code === "DEP_UNRESOLVED" ||
230
+ e.code === "DEP_PENDING" ||
231
+ e.code === "DEP_AMBIGUOUS" ||
232
+ e.code === "PARSE_MISSING_ID",
233
+ );
234
+ if (fatalErrors.length > 0) {
235
+ ctx.ui.notify("❌ Cannot compute plan due to discovery errors above.", "error");
236
+ return;
237
+ }
238
+
239
+ if (discovery.pending.size === 0) {
240
+ ctx.ui.notify("No pending tasks found. Nothing to plan.", "info");
241
+ return;
242
+ }
243
+
244
+ // ── Section 3: Dependency Graph ──────────────────────────
245
+ ctx.ui.notify(
246
+ formatDependencyGraph(discovery.pending, discovery.completed),
247
+ "info",
248
+ );
249
+
250
+ // ── Section 4: Waves + Estimate ──────────────────────────
251
+ // Uses computeWaveAssignments pipeline only — NO re-parsing
252
+ const waveResult = computeWaveAssignments(
253
+ discovery.pending,
254
+ discovery.completed,
255
+ orchConfig,
256
+ );
257
+
258
+ ctx.ui.notify(
259
+ formatWavePlan(waveResult, orchConfig.assignment.size_weights),
260
+ waveResult.errors.length > 0 ? "error" : "info",
261
+ );
262
+ },
263
+ });
264
+
265
+ pi.registerCommand("orch-status", {
266
+ description: "Show current batch progress",
267
+ handler: async (_args, ctx) => {
268
+ if (orchBatchState.phase === "idle") {
269
+ ctx.ui.notify("No batch is running. Use /orch <areas|paths|all> to start.", "info");
270
+ return;
271
+ }
272
+
273
+ const elapsedSec = orchBatchState.endedAt
274
+ ? Math.round((orchBatchState.endedAt - orchBatchState.startedAt) / 1000)
275
+ : Math.round((Date.now() - orchBatchState.startedAt) / 1000);
276
+
277
+ const lines: string[] = [
278
+ `📊 Batch ${orchBatchState.batchId} — ${orchBatchState.phase}`,
279
+ ` Wave: ${orchBatchState.currentWaveIndex + 1}/${orchBatchState.totalWaves}`,
280
+ ` Tasks: ${orchBatchState.succeededTasks} succeeded, ${orchBatchState.failedTasks} failed, ${orchBatchState.skippedTasks} skipped, ${orchBatchState.blockedTasks} blocked / ${orchBatchState.totalTasks} total`,
281
+ ` Elapsed: ${elapsedSec}s`,
282
+ ];
283
+
284
+ if (orchBatchState.errors.length > 0) {
285
+ lines.push(` Errors: ${orchBatchState.errors.length}`);
286
+ }
287
+
288
+ ctx.ui.notify(lines.join("\n"), "info");
289
+ },
290
+ });
291
+
292
+ pi.registerCommand("orch-pause", {
293
+ description: "Pause batch after current tasks finish",
294
+ handler: async (_args, ctx) => {
295
+ if (orchBatchState.phase === "idle" || orchBatchState.phase === "completed" || orchBatchState.phase === "failed" || orchBatchState.phase === "stopped") {
296
+ ctx.ui.notify(ORCH_MESSAGES.pauseNoBatch(), "warning");
297
+ return;
298
+ }
299
+ if (orchBatchState.phase === "paused" || orchBatchState.pauseSignal.paused) {
300
+ ctx.ui.notify(ORCH_MESSAGES.pauseAlreadyPaused(orchBatchState.batchId), "warning");
301
+ return;
302
+ }
303
+ // Set pause signal — executeLane() checks this between tasks
304
+ orchBatchState.pauseSignal.paused = true;
305
+ ctx.ui.notify(ORCH_MESSAGES.pauseActivated(orchBatchState.batchId), "info");
306
+ updateOrchWidget();
307
+ },
308
+ });
309
+
310
+ pi.registerCommand("orch-resume", {
311
+ description: "Resume a paused or interrupted batch",
312
+ handler: async (_args, ctx) => {
313
+ // Prevent resume if a batch is actively running
314
+ if (orchBatchState.phase === "executing" || orchBatchState.phase === "merging" || orchBatchState.phase === "planning") {
315
+ ctx.ui.notify(
316
+ `⚠️ A batch is currently ${orchBatchState.phase} (${orchBatchState.batchId}). Cannot resume.`,
317
+ "warning",
318
+ );
319
+ return;
320
+ }
321
+
322
+ // Reset batch state for resume
323
+ orchBatchState = freshOrchBatchState();
324
+ latestMonitorState = null;
325
+ updateOrchWidget();
326
+
327
+ await resumeOrchBatch(
328
+ orchConfig,
329
+ runnerConfig,
330
+ ctx.cwd,
331
+ orchBatchState,
332
+ (message, level) => {
333
+ ctx.ui.notify(message, level);
334
+ updateOrchWidget();
335
+ },
336
+ (monState: MonitorState) => {
337
+ latestMonitorState = monState;
338
+ updateOrchWidget();
339
+ },
340
+ );
341
+
342
+ // Final widget update
343
+ updateOrchWidget();
344
+ },
345
+ });
346
+
347
+ pi.registerCommand("orch-abort", {
348
+ description: "Abort batch: /orch-abort [--hard]",
349
+ handler: async (args, ctx) => {
350
+ const hard = args?.trim() === "--hard";
351
+ const mode: AbortMode = hard ? "hard" : "graceful";
352
+ const prefix = orchConfig.orchestrator.tmux_prefix;
353
+ const gracePeriodMs = orchConfig.orchestrator.abort_grace_period * 1000;
354
+
355
+ // Check for active in-memory batch
356
+ const hasActiveBatch = orchBatchState.phase !== "idle" &&
357
+ orchBatchState.phase !== "completed" &&
358
+ orchBatchState.phase !== "failed" &&
359
+ orchBatchState.phase !== "stopped";
360
+
361
+ // Also check for persisted state (abort can work on orphaned batches too)
362
+ let persistedState: PersistedBatchState | null = null;
363
+ try {
364
+ persistedState = loadBatchState(ctx.cwd);
365
+ } catch {
366
+ // Ignore — we may still have in-memory state or orphan sessions
367
+ }
368
+
369
+ // If no in-memory batch AND no persisted state, check for orphan sessions
370
+ if (!hasActiveBatch && !persistedState) {
371
+ // Last chance: check for orphan sessions
372
+ const sessionNames = parseOrchSessionNames(
373
+ (() => {
374
+ try {
375
+ return execSync('tmux list-sessions -F "#{session_name}"', {
376
+ encoding: "utf-8",
377
+ timeout: 5000,
378
+ });
379
+ } catch {
380
+ return "";
381
+ }
382
+ })(),
383
+ prefix,
384
+ );
385
+ if (sessionNames.length === 0) {
386
+ ctx.ui.notify(ORCH_MESSAGES.abortNoBatch(), "warning");
387
+ return;
388
+ }
389
+ // If orphan sessions exist, proceed with abort (will kill them)
390
+ }
391
+
392
+ const batchId = orchBatchState.batchId || persistedState?.batchId || "unknown";
393
+
394
+ // Notify user of abort start
395
+ if (mode === "graceful") {
396
+ const sessionCount = orchBatchState.currentLanes.length || persistedState?.tasks.length || 0;
397
+ ctx.ui.notify(ORCH_MESSAGES.abortGracefulStarting(batchId, sessionCount), "info");
398
+ ctx.ui.notify(
399
+ ORCH_MESSAGES.abortGracefulWaiting(batchId, orchConfig.orchestrator.abort_grace_period),
400
+ "info",
401
+ );
402
+ } else {
403
+ const sessionCount = orchBatchState.currentLanes.length || persistedState?.tasks.length || 0;
404
+ ctx.ui.notify(ORCH_MESSAGES.abortHardStarting(batchId, sessionCount), "info");
405
+ }
406
+
407
+ // Execute abort
408
+ const result = await executeAbort(
409
+ mode,
410
+ prefix,
411
+ ctx.cwd,
412
+ orchBatchState,
413
+ persistedState,
414
+ gracePeriodMs,
415
+ );
416
+
417
+ // Update in-memory batch state
418
+ orchBatchState.phase = "stopped";
419
+ orchBatchState.endedAt = result.durationMs + Date.now() - result.durationMs; // Use actual time
420
+ updateOrchWidget();
421
+
422
+ // Notify results
423
+ const durationSec = Math.round(result.durationMs / 1000);
424
+ if (mode === "graceful") {
425
+ const forceKilled = result.sessionsKilled - result.gracefulExits;
426
+ if (forceKilled > 0) {
427
+ ctx.ui.notify(
428
+ ORCH_MESSAGES.abortGracefulForceKill(forceKilled),
429
+ "warning",
430
+ );
431
+ }
432
+ ctx.ui.notify(
433
+ ORCH_MESSAGES.abortGracefulComplete(batchId, result.gracefulExits, forceKilled, durationSec),
434
+ "info",
435
+ );
436
+ } else {
437
+ ctx.ui.notify(
438
+ ORCH_MESSAGES.abortHardComplete(batchId, result.sessionsKilled, durationSec),
439
+ "info",
440
+ );
441
+ }
442
+
443
+ // Report errors if any
444
+ if (result.errors.length > 0) {
445
+ const errorDetails = result.errors.map(e => ` • [${e.code}] ${e.message}`).join("\n");
446
+ ctx.ui.notify(
447
+ `${ORCH_MESSAGES.abortPartialFailure(result.errors.length)}\n${errorDetails}`,
448
+ "warning",
449
+ );
450
+ }
451
+
452
+ // Final message
453
+ ctx.ui.notify(
454
+ ORCH_MESSAGES.abortComplete(mode, result.sessionsKilled),
455
+ "info",
456
+ );
457
+ },
458
+ });
459
+
460
+ pi.registerCommand("orch-deps", {
461
+ description: "Show dependency graph: /orch-deps <areas|paths|all> [--refresh] [--task <id>]",
462
+ handler: async (args, ctx) => {
463
+ if (!args?.trim()) {
464
+ ctx.ui.notify(
465
+ "Usage: /orch-deps <areas|paths|all> [--refresh] [--task <id>]\n\n" +
466
+ "Shows the dependency graph for tasks in the specified areas.\n\n" +
467
+ "Options:\n" +
468
+ " --refresh Force re-scan of areas (bypass dependency cache)\n" +
469
+ " --task <id> Show dependencies for a single task only\n\n" +
470
+ "Examples:\n" +
471
+ " /orch-deps all\n" +
472
+ " /orch-deps all --task TO-014\n" +
473
+ " /orch-deps time-off --refresh\n" +
474
+ " /orch-deps all --task COMP-006 --refresh",
475
+ "info",
476
+ );
477
+ return;
478
+ }
479
+
480
+ // Parse --refresh flag
481
+ const hasRefresh = /--refresh/.test(args);
482
+
483
+ // Parse --task <id> flag
484
+ let filterTaskId: string | undefined;
485
+ const taskMatch = args.match(/--task\s+([A-Z]+-\d+)/i);
486
+ if (taskMatch) {
487
+ filterTaskId = taskMatch[1].toUpperCase();
488
+ }
489
+
490
+ // Strip flags to get clean area/path arguments
491
+ let cleanArgs = args
492
+ .replace(/--refresh/g, "")
493
+ .replace(/--task\s+[A-Z]+-\d+/gi, "")
494
+ .trim();
495
+
496
+ if (!cleanArgs) {
497
+ ctx.ui.notify(
498
+ "Usage: /orch-deps <areas|paths|all> [--refresh] [--task <id>]\n" +
499
+ "Error: target argument required (e.g., 'all', area name, or path)",
500
+ "error",
501
+ );
502
+ return;
503
+ }
504
+
505
+ if (hasRefresh) {
506
+ ctx.ui.notify("🔄 Refresh mode: re-scanning all areas (dependency cache bypassed)", "info");
507
+ }
508
+
509
+ // Run discovery (no preflight needed for deps view)
510
+ const discovery = runDiscovery(cleanArgs, runnerConfig.task_areas, ctx.cwd, {
511
+ refreshDependencies: hasRefresh,
512
+ dependencySource: orchConfig.dependencies.source,
513
+ useDependencyCache: orchConfig.dependencies.cache,
514
+ });
515
+ ctx.ui.notify(
516
+ formatDiscoveryResults(discovery),
517
+ discovery.errors.length > 0 ? "warning" : "info",
518
+ );
519
+
520
+ // Show dependency graph (full or filtered)
521
+ if (discovery.pending.size > 0) {
522
+ ctx.ui.notify(
523
+ formatDependencyGraph(
524
+ discovery.pending,
525
+ discovery.completed,
526
+ filterTaskId,
527
+ ),
528
+ "info",
529
+ );
530
+ }
531
+ },
532
+ });
533
+
534
+ pi.registerCommand("orch-sessions", {
535
+ description: "List active orchestrator TMUX sessions",
536
+ handler: async (_args, ctx) => {
537
+ const sessions = listOrchSessions(orchConfig.orchestrator.tmux_prefix, orchBatchState);
538
+ ctx.ui.notify(formatOrchSessions(sessions), "info");
539
+ },
540
+ });
541
+
542
+ // ── Session Lifecycle ────────────────────────────────────────────
543
+
544
+ pi.on("session_start", async (_event, ctx) => {
545
+ // Load configs
546
+ orchConfig = loadOrchestratorConfig(ctx.cwd);
547
+ runnerConfig = loadTaskRunnerConfig(ctx.cwd);
548
+
549
+ // Store widget context for dashboard updates
550
+ orchWidgetCtx = ctx;
551
+
552
+ // Set status line
553
+ const areaCount = Object.keys(runnerConfig.task_areas).length;
554
+ ctx.ui.setStatus(
555
+ "task-orchestrator",
556
+ `🔀 Orchestrator · ${areaCount} areas · ${orchConfig.orchestrator.max_lanes} lanes`,
557
+ );
558
+
559
+ // Register initial dashboard widget (idle state)
560
+ updateOrchWidget();
561
+
562
+ // Notify user of available commands
563
+ ctx.ui.notify(
564
+ "Task Orchestrator ready\n\n" +
565
+ `Config: ${orchConfig.orchestrator.max_lanes} lanes, ` +
566
+ `${orchConfig.orchestrator.spawn_mode} mode, ` +
567
+ `${orchConfig.dependencies.source} deps\n` +
568
+ `Areas: ${areaCount} registered\n\n` +
569
+ "/orch <areas|all> Start batch execution\n" +
570
+ "/orch-plan <areas|all> Preview execution plan\n" +
571
+ "/orch-deps <areas|all> Show dependency graph\n" +
572
+ "/orch-sessions List TMUX sessions",
573
+ "info",
574
+ );
575
+ });
576
+ }
577
+