taskplane 0.6.0 → 0.7.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.
@@ -2,7 +2,7 @@ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-age
2
2
 
3
3
  import { execSync, execFileSync } from "child_process";
4
4
  import { writeFileSync, unlinkSync, mkdirSync, existsSync, readdirSync } from "fs";
5
- import { join, resolve } from "path";
5
+ import { join } from "path";
6
6
 
7
7
  import {
8
8
  DEFAULT_ORCHESTRATOR_CONFIG,
@@ -25,10 +25,13 @@ import {
25
25
  formatWavePlan,
26
26
  freshOrchBatchState,
27
27
  getCurrentBranch,
28
+ hasConfigFiles,
29
+ resolveConfigRoot,
28
30
  listOrchSessions,
29
31
  listWorktrees,
30
32
  loadBatchState,
31
33
  loadOrchestratorConfig,
34
+ loadSupervisorConfig,
32
35
  loadTaskRunnerConfig,
33
36
  parseOrchSessionNames,
34
37
  resolveOperatorId,
@@ -40,6 +43,20 @@ import {
40
43
  } from "./index.ts";
41
44
  import { buildExecutionContext } from "./workspace.ts";
42
45
  import { openSettingsTui } from "./settings-tui.ts";
46
+ import {
47
+ activateSupervisor,
48
+ deactivateSupervisor,
49
+ freshSupervisorState,
50
+ registerSupervisorPromptHook,
51
+ checkSupervisorLockOnStartup,
52
+ buildTakeoverSummary,
53
+ isProcessAlive,
54
+ isBatchTerminal,
55
+ DEFAULT_SUPERVISOR_CONFIG,
56
+ triggerSupervisorIntegration,
57
+ presentBatchSummary,
58
+ } from "./supervisor.ts";
59
+ import type { SupervisorConfig, IntegrationExecutor, CiDeps, SummaryDeps } from "./supervisor.ts";
43
60
  import type {
44
61
  AbortMode,
45
62
  ExecutionContext,
@@ -683,6 +700,350 @@ export function collectRepoCleanupFindings(
683
700
  return findings;
684
701
  }
685
702
 
703
+ // ── TP-040: Non-Blocking Engine Launch Helper ───────────────────────
704
+
705
+ /**
706
+ * Start an engine execution (batch or resume) without blocking the caller.
707
+ *
708
+ * Launches `engineFn` as a fire-and-forget promise — the command handler
709
+ * returns immediately so the pi session stays interactive. All state
710
+ * transitions are communicated via the existing callback mechanism
711
+ * (onNotify, onMonitorUpdate) and engine events.
712
+ *
713
+ * The `.catch()` error boundary handles unexpected rejections from the
714
+ * engine by:
715
+ * 1. Setting the batch state to "failed" with the error
716
+ * 2. Notifying the operator
717
+ * 3. Refreshing the dashboard widget
718
+ *
719
+ * This prevents unhandled promise rejections from crashing the session
720
+ * or leaving batch state inconsistent.
721
+ */
722
+ export function startBatchAsync(
723
+ engineFn: () => Promise<void>,
724
+ batchState: import("./types.ts").OrchBatchRuntimeState,
725
+ ctx: ExtensionContext,
726
+ updateWidget: () => void,
727
+ onTerminal?: () => void,
728
+ ): void {
729
+ // Detach engine start to the next tick so the command handler returns
730
+ // immediately. Without this, the synchronous planning/discovery phase
731
+ // of the engine would block the handler until its first await.
732
+ setTimeout(() => {
733
+ engineFn()
734
+ .then(() => {
735
+ // Engine completed normally — final widget update
736
+ updateWidget();
737
+ // TP-041 R002-3: Deactivate supervisor on all terminal paths
738
+ onTerminal?.();
739
+ })
740
+ .catch((err: unknown) => {
741
+ // Unhandled engine rejection — surface to operator and update state
742
+ const errMsg = err instanceof Error ? err.message : String(err);
743
+ if (batchState.phase !== "completed" && batchState.phase !== "failed") {
744
+ batchState.phase = "failed";
745
+ batchState.endedAt = Date.now();
746
+ batchState.errors.push(`Unhandled engine error: ${errMsg}`);
747
+ }
748
+ ctx.ui.notify(
749
+ `❌ Engine crashed with unhandled error: ${errMsg}\n` +
750
+ ` Batch ${batchState.batchId} marked as failed.`,
751
+ "error",
752
+ );
753
+ updateWidget();
754
+ // TP-041 R002-3: Deactivate supervisor on all terminal paths
755
+ onTerminal?.();
756
+ });
757
+ }, 0);
758
+ }
759
+
760
+ // ── TP-043 R002-2: Integration Executor Builder ─────────────────────
761
+
762
+ /**
763
+ * Build an integration executor callback for `triggerSupervisorIntegration`.
764
+ *
765
+ * Wraps `executeIntegration` with the appropriate deps (runGit, runCommand,
766
+ * deleteBatchState) so the supervisor module can execute integration without
767
+ * importing from extension.ts (avoiding circular dependencies).
768
+ *
769
+ * The executor ensures the working directory is on the base branch before
770
+ * executing, matching the behavior of `/orch-integrate`.
771
+ *
772
+ * @param repoRoot - Repository root directory for git operations
773
+ * @returns Integration executor callback
774
+ *
775
+ * @since TP-043 R002
776
+ */
777
+ export function buildIntegrationExecutor(repoRoot: string): IntegrationExecutor {
778
+ return (mode, context) => {
779
+ // Ensure we're on the base branch before integrating
780
+ const currentBranch = getCurrentBranch(repoRoot);
781
+ if (currentBranch && currentBranch !== context.baseBranch) {
782
+ const checkoutResult = runGit(["checkout", context.baseBranch], repoRoot);
783
+ if (!checkoutResult.ok) {
784
+ return {
785
+ success: false,
786
+ integratedLocally: false,
787
+ commitCount: "0",
788
+ message: "",
789
+ error: `Failed to switch to base branch ${context.baseBranch}: ${checkoutResult.stderr}`,
790
+ };
791
+ }
792
+ }
793
+
794
+ // Build deps matching the /orch-integrate handler pattern
795
+ const deps: IntegrationExecDeps = {
796
+ runGit: (gitArgs: string[]) => runGit(gitArgs, repoRoot),
797
+ runCommand: (cmd: string, cmdArgs: string[]) => {
798
+ try {
799
+ const stdout = execFileSync(cmd, cmdArgs, {
800
+ encoding: "utf-8",
801
+ timeout: 60_000,
802
+ cwd: repoRoot,
803
+ stdio: ["pipe", "pipe", "pipe"],
804
+ }).trim();
805
+ return { ok: true, stdout, stderr: "" };
806
+ } catch (err: unknown) {
807
+ const e = err as { stdout?: string; stderr?: string; message?: string };
808
+ return {
809
+ ok: false,
810
+ stdout: (e.stdout ?? "").toString().trim(),
811
+ stderr: (e.stderr ?? e.message ?? "unknown error").toString().trim(),
812
+ };
813
+ }
814
+ },
815
+ deleteBatchState: () => {
816
+ try { deleteBatchState(repoRoot); } catch { /* best effort */ }
817
+ },
818
+ };
819
+
820
+ return executeIntegration(mode as IntegrateMode, {
821
+ ...context,
822
+ currentBranch: context.baseBranch,
823
+ }, deps);
824
+ };
825
+ }
826
+
827
+ /**
828
+ * Build CI deps for programmatic PR polling and merge (R002-2).
829
+ *
830
+ * Creates the `CiDeps` object needed by `triggerSupervisorIntegration`
831
+ * for auto/PR mode CI status polling and PR merge operations.
832
+ *
833
+ * @param repoRoot - Repository root directory
834
+ * @returns CiDeps with gh CLI and git wrappers
835
+ *
836
+ * @since TP-043
837
+ */
838
+ export function buildCiDeps(repoRoot: string): CiDeps {
839
+ return {
840
+ runCommand: (cmd: string, cmdArgs: string[]) => {
841
+ try {
842
+ const stdout = execFileSync(cmd, cmdArgs, {
843
+ encoding: "utf-8",
844
+ timeout: 60_000,
845
+ cwd: repoRoot,
846
+ stdio: ["pipe", "pipe", "pipe"],
847
+ }).trim();
848
+ return { ok: true, stdout, stderr: "" };
849
+ } catch (err: unknown) {
850
+ const e = err as { stdout?: string; stderr?: string; message?: string };
851
+ return {
852
+ ok: false,
853
+ stdout: (e.stdout ?? "").toString().trim(),
854
+ stderr: (e.stderr ?? e.message ?? "unknown error").toString().trim(),
855
+ };
856
+ }
857
+ },
858
+ runGit: (gitArgs: string[]) => runGit(gitArgs, repoRoot),
859
+ deleteBatchState: () => {
860
+ try { deleteBatchState(repoRoot); } catch { /* best effort */ }
861
+ },
862
+ };
863
+ }
864
+
865
+ // ── /orch Routing Logic (TP-042) ─────────────────────────────────────
866
+
867
+ /**
868
+ * Project state for /orch no-args routing.
869
+ *
870
+ * Evaluated in strict precedence order (R001-3) — the first matching state wins:
871
+ * 1. active-batch → Batch is running (non-terminal phase) → status report
872
+ * 2. completed-batch → Completed batch + orch branch exists → offer integration
873
+ * 3. no-config → No taskplane config exists → onboarding flow
874
+ * 4. pending-tasks → Config exists, pending tasks found → offer to start batch
875
+ * 5. no-tasks → Config exists, no pending tasks → help create tasks
876
+ *
877
+ * Active batch and completed batch are checked before no-config so that an
878
+ * orphaned batch-state.json or orch branch isn't silently ignored even if
879
+ * the config file was deleted.
880
+ *
881
+ * @since TP-042
882
+ */
883
+ export type OrchProjectState =
884
+ | "no-config"
885
+ | "active-batch"
886
+ | "completed-batch"
887
+ | "pending-tasks"
888
+ | "no-tasks";
889
+
890
+ /**
891
+ * Result of detectOrchState — provides the detected state plus
892
+ * context data for supervisor routing (e.g., batch info, task count).
893
+ *
894
+ * @since TP-042
895
+ */
896
+ export interface OrchStateDetection {
897
+ /** The detected project state */
898
+ state: OrchProjectState;
899
+ /** Human-readable context message for the supervisor activation prompt */
900
+ contextMessage: string;
901
+ /** Number of pending tasks (only set for pending-tasks state) */
902
+ pendingTaskCount?: number;
903
+ /** Batch ID (set for active-batch and completed-batch states) */
904
+ batchId?: string;
905
+ /** Batch phase (set for active-batch state) */
906
+ batchPhase?: string;
907
+ /** Orch branch name (set for completed-batch state) */
908
+ orchBranch?: string;
909
+ }
910
+
911
+ /**
912
+ * Dependencies injected into detectOrchState for testability.
913
+ *
914
+ * @since TP-042
915
+ */
916
+ export interface OrchStateDetectionDeps {
917
+ /** Check if any taskplane config file exists (JSON or YAML) */
918
+ hasConfig: () => boolean;
919
+ /** Load persisted batch state (null if no state file) */
920
+ loadBatchState: () => PersistedBatchState | null;
921
+ /** List local orch/* branches */
922
+ listOrchBranches: () => string[];
923
+ /** Run task discovery to count pending tasks */
924
+ countPendingTasks: () => number;
925
+ }
926
+
927
+ /**
928
+ * Detect the current project state for /orch no-args routing.
929
+ *
930
+ * Evaluates state in strict precedence order (see OrchProjectState).
931
+ * The first matching condition wins — no further checks are performed.
932
+ *
933
+ * This is a pure function with injected dependencies for testability.
934
+ *
935
+ * @param deps - Injected dependencies for state detection
936
+ * @returns Detection result with state and context message
937
+ *
938
+ * @since TP-042
939
+ */
940
+ export function detectOrchState(deps: OrchStateDetectionDeps): OrchStateDetection {
941
+ // Precedence order (R001-3): active batch → completed-needs-integration
942
+ // → no-config → pending tasks → no tasks. Active batch is checked first
943
+ // because an orphaned batch-state.json should be surfaced even if config
944
+ // was deleted. No-config (onboarding) is checked after batch states so
945
+ // an in-progress/completed batch isn't silently ignored.
946
+
947
+ // ── 1. Active batch (non-terminal phase) → status report ─────
948
+ try {
949
+ const batchState = deps.loadBatchState();
950
+ if (batchState && !isBatchTerminal(batchState.phase)) {
951
+ const elapsed = batchState.endedAt
952
+ ? Math.round((batchState.endedAt - batchState.startedAt) / 1000)
953
+ : Math.round((Date.now() - batchState.startedAt) / 1000);
954
+
955
+ return {
956
+ state: "active-batch",
957
+ batchId: batchState.batchId,
958
+ batchPhase: batchState.phase,
959
+ contextMessage:
960
+ `Batch ${batchState.batchId} is currently ${batchState.phase}. ` +
961
+ `Wave ${batchState.currentWaveIndex + 1}/${batchState.totalWaves ?? "?"}, ` +
962
+ `${batchState.succeededTasks ?? 0} succeeded, ` +
963
+ `${batchState.failedTasks ?? 0} failed, ` +
964
+ `${batchState.skippedTasks ?? 0} skipped / ` +
965
+ `${batchState.totalTasks ?? "?"} total. ` +
966
+ `Elapsed: ${elapsed}s.`,
967
+ };
968
+ }
969
+
970
+ // ── 2. Completed batch + orch branch → offer integration ───
971
+ // R002-2: Validate that the orch branch still exists in git before
972
+ // offering integration. Stale batch-state can reference a deleted branch.
973
+ if (batchState && batchState.phase === "completed" && batchState.orchBranch) {
974
+ const existingBranches = deps.listOrchBranches();
975
+ const branchExists = existingBranches.includes(batchState.orchBranch);
976
+ if (branchExists) {
977
+ return {
978
+ state: "completed-batch",
979
+ batchId: batchState.batchId,
980
+ orchBranch: batchState.orchBranch,
981
+ contextMessage:
982
+ `Your last batch (${batchState.batchId}) completed — ` +
983
+ `${batchState.succeededTasks ?? 0}/${batchState.totalTasks ?? "?"} tasks succeeded. ` +
984
+ `The orch branch \`${batchState.orchBranch}\` is ready to integrate. ` +
985
+ `Want me to create a PR to ${batchState.baseBranch}, or integrate directly?`,
986
+ };
987
+ }
988
+ // Branch was deleted — fall through to remaining checks
989
+ }
990
+ } catch {
991
+ // Batch state unreadable — fall through to check for orch branches
992
+ }
993
+
994
+ // ── 2b. No batch state but orch branches exist → offer integration
995
+ // Covers the case where batch-state.json was deleted but an orch branch remains.
996
+ const orchBranches = deps.listOrchBranches();
997
+ if (orchBranches.length > 0) {
998
+ const branchList = orchBranches.map(b => `\`${b}\``).join(", ");
999
+ return {
1000
+ state: "completed-batch",
1001
+ orchBranch: orchBranches[0],
1002
+ contextMessage:
1003
+ orchBranches.length === 1
1004
+ ? `I found an orch branch (${branchList}) that hasn't been integrated yet. ` +
1005
+ `Want me to integrate it, or would you like to start fresh?`
1006
+ : `I found ${orchBranches.length} orch branches (${branchList}) that haven't been integrated. ` +
1007
+ `Would you like to integrate one, or start fresh?`,
1008
+ };
1009
+ }
1010
+
1011
+ // ── 3. No config exists → onboarding ─────────────────────────
1012
+ if (!deps.hasConfig()) {
1013
+ return {
1014
+ state: "no-config",
1015
+ contextMessage:
1016
+ "Welcome to Taskplane! I don't see a configuration for this project yet. " +
1017
+ "Let me help you get set up. I'll analyze your project structure, help you " +
1018
+ "define task areas, check your git branching strategy, and generate the config files.",
1019
+ };
1020
+ }
1021
+
1022
+ // ── 4. Pending tasks exist → offer to start batch ────────────
1023
+ const pendingCount = deps.countPendingTasks();
1024
+ if (pendingCount > 0) {
1025
+ return {
1026
+ state: "pending-tasks",
1027
+ pendingTaskCount: pendingCount,
1028
+ contextMessage:
1029
+ `Welcome back! You have ${pendingCount} pending task${pendingCount === 1 ? "" : "s"} ready to run. ` +
1030
+ `Want me to start the batch, or would you like to review the plan first?`,
1031
+ };
1032
+ }
1033
+
1034
+ // ── 5. No pending tasks → help create tasks ──────────────────
1035
+ return {
1036
+ state: "no-tasks",
1037
+ contextMessage:
1038
+ "No pending tasks right now. Here's what I can help with:\n" +
1039
+ "• Create tasks from a spec or design doc\n" +
1040
+ "• Pull in GitHub Issues\n" +
1041
+ "• Write a new spec for something you want to build\n" +
1042
+ "• Run a project health check\n" +
1043
+ "What interests you?",
1044
+ };
1045
+ }
1046
+
686
1047
  // ── Extension ────────────────────────────────────────────────────────
687
1048
 
688
1049
  export default function (pi: ExtensionAPI) {
@@ -692,6 +1053,14 @@ export default function (pi: ExtensionAPI) {
692
1053
  let orchWidgetCtx: ExtensionContext | undefined;
693
1054
  let latestMonitorState: MonitorState | null = null;
694
1055
 
1056
+ // ── Supervisor State (TP-041) ────────────────────────────────────
1057
+ let supervisorState = freshSupervisorState();
1058
+ let supervisorConfig: SupervisorConfig = { ...DEFAULT_SUPERVISOR_CONFIG };
1059
+
1060
+ // Register supervisor prompt hook: while active, injects supervisor
1061
+ // system prompt on every LLM turn. No-op when supervisor is inactive.
1062
+ registerSupervisorPromptHook(pi, supervisorState);
1063
+
695
1064
  /**
696
1065
  * Execution context loaded at session start. Null if startup failed
697
1066
  * (e.g., workspace config present but invalid). Commands check this
@@ -735,18 +1104,83 @@ export default function (pi: ExtensionAPI) {
735
1104
  // ── Commands ─────────────────────────────────────────────────────
736
1105
 
737
1106
  pi.registerCommand("orch", {
738
- description: "Start batch execution: /orch <areas|paths|all>",
1107
+ description: "Start batch execution or supervisor: /orch [<areas|paths|all>]",
739
1108
  handler: async (args, ctx) => {
1109
+ // ── TP-042: No-args → supervisor routing ─────────────────
1110
+ // When /orch is called without arguments, detect project state
1111
+ // and activate the supervisor with routing context instead of
1112
+ // showing usage. The supervisor then guides the operator through
1113
+ // the appropriate flow (onboarding, batch planning, etc.).
740
1114
  if (!args?.trim()) {
741
- ctx.ui.notify(
742
- "Usage: /orch <areas|paths|all>\n\n" +
743
- "Examples:\n" +
744
- " /orch all Run all pending tasks\n" +
745
- " /orch time-off performance-management Run specific areas\n" +
746
- " /orch path/to/tasks Scan directory\n" +
747
- " /orch path/to/PROMPT.md Single task with isolation",
748
- "info",
1115
+ // For "no-config" state we don't need execCtx — just send the
1116
+ // routing context. For all other states, we need it.
1117
+ // R002-1: Mirror the config loading resolution chain (resolveConfigRoot)
1118
+ // so /orch routing detects config in the same location the loader uses.
1119
+ // This handles pointer-based workspace setups where config lives at
1120
+ // pointer.configRoot, not at the worktree cwd.
1121
+ const cwd = execCtx?.workspaceRoot ?? execCtx?.repoRoot ?? ctx.cwd;
1122
+ const pointerConfigRoot = execCtx?.pointer?.configRoot;
1123
+ const resolvedConfigRoot = resolveConfigRoot(cwd, pointerConfigRoot);
1124
+ const stateRoot = execCtx?.repoRoot ?? ctx.cwd;
1125
+ const repoRoot = execCtx?.repoRoot ?? ctx.cwd;
1126
+
1127
+ // Detect project state with strict precedence order
1128
+ const detection = detectOrchState({
1129
+ hasConfig: () => hasConfigFiles(resolvedConfigRoot),
1130
+ loadBatchState: () => {
1131
+ try { return loadBatchState(stateRoot); }
1132
+ catch { return null; }
1133
+ },
1134
+ listOrchBranches: () => {
1135
+ const result = runGit(["branch", "--list", "orch/*"], repoRoot);
1136
+ return result.ok
1137
+ ? result.stdout.split("\n").map(b => b.replace(/^\*?\s+/, "").trim()).filter(Boolean)
1138
+ : [];
1139
+ },
1140
+ countPendingTasks: () => {
1141
+ if (!execCtx) return 0;
1142
+ try {
1143
+ const discovery = runDiscovery("all", runnerConfig.task_areas, execCtx.workspaceRoot, {
1144
+ dependencySource: orchConfig.dependencies.source,
1145
+ useDependencyCache: orchConfig.dependencies.cache,
1146
+ workspaceConfig: execCtx.workspaceConfig,
1147
+ });
1148
+ return discovery.pending.size;
1149
+ } catch { return 0; }
1150
+ },
1151
+ });
1152
+
1153
+ // ── Active batch → show status only (supervisor already running) ─
1154
+ if (detection.state === "active-batch") {
1155
+ ctx.ui.notify(
1156
+ `🔀 ${detection.contextMessage}\n\n` +
1157
+ `Use /orch-status for full details, or /orch-pause to pause.`,
1158
+ "info",
1159
+ );
1160
+ return;
1161
+ }
1162
+
1163
+ // For non-onboarding states, we need execCtx
1164
+ if (detection.state !== "no-config" && !requireExecCtx(ctx)) return;
1165
+
1166
+ // Activate supervisor with routing context.
1167
+ // The routingContext parameter skips lockfile/heartbeat/event-tailer
1168
+ // (no active batch to monitor) and sends a routing-specific activation
1169
+ // message instead of the generic "Batch started" one.
1170
+ activateSupervisor(
1171
+ pi,
1172
+ supervisorState,
1173
+ orchBatchState,
1174
+ orchConfig,
1175
+ supervisorConfig,
1176
+ stateRoot,
1177
+ ctx,
1178
+ {
1179
+ routingState: detection.state,
1180
+ contextMessage: detection.contextMessage,
1181
+ },
749
1182
  );
1183
+
750
1184
  return;
751
1185
  }
752
1186
 
@@ -834,37 +1268,139 @@ export default function (pi: ExtensionAPI) {
834
1268
  // Reset batch state for new execution
835
1269
  orchBatchState = freshOrchBatchState();
836
1270
  latestMonitorState = null;
1271
+
1272
+ // ── TP-040: Set launching phase synchronously ────────────
1273
+ // Mark as "launching" before the setTimeout detach so that
1274
+ // /orch-status, /orch-pause, /orch-abort issued immediately
1275
+ // after /orch returns can see that a batch is being started.
1276
+ // The engine will transition from "launching" → "planning"
1277
+ // on the next tick when it actually begins work.
1278
+ orchBatchState.phase = "launching";
1279
+ orchBatchState.startedAt = Date.now();
837
1280
  updateOrchWidget();
838
1281
 
839
- await executeOrchBatch(
840
- args,
841
- orchConfig,
842
- runnerConfig,
843
- repoRoot,
1282
+ // ── TP-040: Non-blocking engine launch ───────────────────
1283
+ // Start the engine without awaiting — the command handler returns
1284
+ // immediately so the pi session remains interactive (enables
1285
+ // supervisor agent and operator conversation during batch).
1286
+ // The .catch() error boundary ensures unhandled rejections from
1287
+ // the engine are surfaced to the operator and reflected in state.
1288
+ startBatchAsync(
1289
+ () => executeOrchBatch(
1290
+ args,
1291
+ orchConfig,
1292
+ runnerConfig,
1293
+ repoRoot,
1294
+ orchBatchState,
1295
+ (message, level) => {
1296
+ ctx.ui.notify(message, level);
1297
+ updateOrchWidget(); // Refresh widget on every phase message
1298
+ },
1299
+ (monState: MonitorState) => {
1300
+ const changed = !latestMonitorState ||
1301
+ latestMonitorState.totalDone !== monState.totalDone ||
1302
+ latestMonitorState.totalFailed !== monState.totalFailed ||
1303
+ latestMonitorState.lanes.some((l, i) =>
1304
+ l.currentTaskId !== monState.lanes[i]?.currentTaskId ||
1305
+ l.currentStep !== monState.lanes[i]?.currentStep ||
1306
+ l.completedChecks !== monState.lanes[i]?.completedChecks,
1307
+ );
1308
+ latestMonitorState = monState;
1309
+ if (changed) updateOrchWidget(); // Only refresh on actual state change
1310
+ },
1311
+ execCtx!.workspaceConfig,
1312
+ execCtx!.workspaceRoot,
1313
+ execCtx!.pointer?.agentRoot,
1314
+ ),
844
1315
  orchBatchState,
845
- (message, level) => {
846
- ctx.ui.notify(message, level);
847
- updateOrchWidget(); // Refresh widget on every phase message
848
- },
849
- (monState: MonitorState) => {
850
- const changed = !latestMonitorState ||
851
- latestMonitorState.totalDone !== monState.totalDone ||
852
- latestMonitorState.totalFailed !== monState.totalFailed ||
853
- latestMonitorState.lanes.some((l, i) =>
854
- l.currentTaskId !== monState.lanes[i]?.currentTaskId ||
855
- l.currentStep !== monState.lanes[i]?.currentStep ||
856
- l.completedChecks !== monState.lanes[i]?.completedChecks,
1316
+ ctx,
1317
+ updateOrchWidget,
1318
+ // TP-043: Deferred supervisor deactivation (R002-1).
1319
+ // Integration is ONLY triggered when batch completes successfully
1320
+ // (phase === "completed"). For paused/stopped/crash states, the
1321
+ // supervisor is deactivated immediately — no integration on partial
1322
+ // batches.
1323
+ // TP-043 Step 2: Batch summary is generated on all terminal paths
1324
+ // before supervisor deactivation.
1325
+ () => {
1326
+ const mode = orchConfig.orchestrator.integration;
1327
+ // TP-043: Build summary deps for all terminal paths
1328
+ const opId = resolveOperatorId(orchConfig);
1329
+ const sDeps: SummaryDeps = {
1330
+ opId,
1331
+ diagnostics: orchBatchState.diagnostics ?? null,
1332
+ mergeResults: (orchBatchState.mergeResults || []).map(mr => ({
1333
+ waveIndex: mr.waveIndex,
1334
+ status: mr.status,
1335
+ failedLane: mr.failedLane,
1336
+ failureReason: mr.failureReason,
1337
+ })),
1338
+ };
1339
+ if (
1340
+ orchBatchState.phase === "completed" &&
1341
+ (mode === "supervised" || mode === "auto")
1342
+ ) {
1343
+ // Supervisor stays alive — trigger programmatic integration
1344
+ // flow. Supervisor deactivates itself after integration
1345
+ // completes (or fails) via the callback in
1346
+ // triggerSupervisorIntegration. Summary generated there.
1347
+ triggerSupervisorIntegration(
1348
+ pi,
1349
+ supervisorState,
1350
+ orchBatchState,
1351
+ mode,
1352
+ repoRoot,
1353
+ buildIntegrationExecutor(repoRoot),
1354
+ buildCiDeps(repoRoot),
1355
+ sDeps,
1356
+ );
1357
+ return;
1358
+ }
1359
+ // Non-completed phase or manual mode — deactivate immediately.
1360
+ // Inform operator if integration was expected but skipped.
1361
+ if (
1362
+ (mode === "supervised" || mode === "auto") &&
1363
+ orchBatchState.phase !== "completed"
1364
+ ) {
1365
+ pi.sendMessage(
1366
+ {
1367
+ customType: "supervisor-integration-skipped",
1368
+ content: [{
1369
+ type: "text",
1370
+ text:
1371
+ `📋 **Batch ended** (phase: ${orchBatchState.phase}). ` +
1372
+ `Integration skipped — only completed batches are eligible.\n` +
1373
+ `Use \`/orch-resume\` to continue or \`/orch-integrate\` manually after resolving issues.`,
1374
+ }],
1375
+ display: `Integration skipped — batch ${orchBatchState.phase}`,
1376
+ },
1377
+ { triggerTurn: false },
857
1378
  );
858
- latestMonitorState = monState;
859
- if (changed) updateOrchWidget(); // Only refresh on actual state change
1379
+ }
1380
+ // TP-043: Generate summary before deactivation (manual mode or non-completed)
1381
+ presentBatchSummary(pi, orchBatchState, execCtx!.workspaceRoot, opId, orchBatchState.diagnostics, sDeps.mergeResults);
1382
+ deactivateSupervisor(pi, supervisorState);
860
1383
  },
861
- execCtx!.workspaceConfig,
862
- execCtx!.workspaceRoot,
863
- execCtx!.pointer?.agentRoot,
864
1384
  );
865
1385
 
866
- // Final widget update after batch completes
867
- updateOrchWidget();
1386
+ // ── TP-041: Activate supervisor agent ────────────────────
1387
+ // After the engine is launched (non-blocking), activate the
1388
+ // supervisor in this pi session. The system prompt is rebuilt
1389
+ // dynamically on each LLM turn from the live batchState ref,
1390
+ // ensuring batch metadata (batchId, wave/task counts) is always
1391
+ // current even though the engine populates it asynchronously.
1392
+ // Model override is resolved inside activateSupervisor via ctx.
1393
+ // Uses workspaceRoot (not repoRoot) so lockfile/events/batch-state
1394
+ // all resolve to the same .pi tree the engine writes to (R006-1).
1395
+ activateSupervisor(
1396
+ pi,
1397
+ supervisorState,
1398
+ orchBatchState,
1399
+ orchConfig,
1400
+ supervisorConfig,
1401
+ execCtx!.workspaceRoot,
1402
+ ctx,
1403
+ );
868
1404
  },
869
1405
  });
870
1406
 
@@ -978,8 +1514,42 @@ export default function (pi: ExtensionAPI) {
978
1514
  pi.registerCommand("orch-status", {
979
1515
  description: "Show current batch progress",
980
1516
  handler: async (_args, ctx) => {
1517
+ // ── TP-040: Disk fallback for idle in-memory state ────────
1518
+ // When in-memory state is idle, try loading from persisted
1519
+ // batch-state.json. This covers fresh-session queries (pi
1520
+ // restarted while a batch was running in tmux lanes) and
1521
+ // post-crash recovery where in-memory state was lost.
981
1522
  if (orchBatchState.phase === "idle") {
982
- ctx.ui.notify("No batch is running. Use /orch <areas|paths|all> to start.", "info");
1523
+ const stateRoot = execCtx?.workspaceRoot ?? execCtx?.repoRoot ?? ctx.cwd;
1524
+ let diskState: PersistedBatchState | null = null;
1525
+ try {
1526
+ diskState = loadBatchState(stateRoot);
1527
+ } catch {
1528
+ // Ignore errors — fall through to "no batch" message
1529
+ }
1530
+
1531
+ if (!diskState) {
1532
+ ctx.ui.notify("No batch is running. Use /orch <areas|paths|all> to start.", "info");
1533
+ return;
1534
+ }
1535
+
1536
+ // Show status from persisted state
1537
+ const elapsedSec = diskState.endedAt
1538
+ ? Math.round((diskState.endedAt - diskState.startedAt) / 1000)
1539
+ : Math.round((Date.now() - diskState.startedAt) / 1000);
1540
+
1541
+ const lines: string[] = [
1542
+ `📊 Batch ${diskState.batchId} — ${diskState.phase} (from disk)`,
1543
+ ` Wave: ${diskState.currentWaveIndex + 1}/${diskState.totalWaves}`,
1544
+ ` Tasks: ${diskState.succeededTasks} succeeded, ${diskState.failedTasks} failed, ${diskState.skippedTasks} skipped, ${diskState.blockedTasks} blocked / ${diskState.totalTasks} total`,
1545
+ ` Elapsed: ${elapsedSec}s`,
1546
+ ];
1547
+
1548
+ if (diskState.errors.length > 0) {
1549
+ lines.push(` Errors: ${diskState.errors.length}`);
1550
+ }
1551
+
1552
+ ctx.ui.notify(lines.join("\n"), "info");
983
1553
  return;
984
1554
  }
985
1555
 
@@ -1032,8 +1602,8 @@ export default function (pi: ExtensionAPI) {
1032
1602
  return;
1033
1603
  }
1034
1604
 
1035
- // Prevent resume if a batch is actively running
1036
- if (orchBatchState.phase === "executing" || orchBatchState.phase === "merging" || orchBatchState.phase === "planning") {
1605
+ // Prevent resume if a batch is actively running (includes "launching" from non-blocking detach)
1606
+ if (orchBatchState.phase === "launching" || orchBatchState.phase === "executing" || orchBatchState.phase === "merging" || orchBatchState.phase === "planning") {
1037
1607
  ctx.ui.notify(
1038
1608
  `⚠️ A batch is currently ${orchBatchState.phase} (${orchBatchState.batchId}). Cannot resume.`,
1039
1609
  "warning",
@@ -1044,29 +1614,107 @@ export default function (pi: ExtensionAPI) {
1044
1614
  // Reset batch state for resume
1045
1615
  orchBatchState = freshOrchBatchState();
1046
1616
  latestMonitorState = null;
1617
+
1618
+ // ── TP-040: Set launching phase synchronously ────────────
1619
+ // Same as /orch — mark as "launching" before setTimeout detach
1620
+ // so commands issued immediately see an active batch.
1621
+ orchBatchState.phase = "launching";
1622
+ orchBatchState.startedAt = Date.now();
1047
1623
  updateOrchWidget();
1048
1624
 
1049
- await resumeOrchBatch(
1050
- orchConfig,
1051
- runnerConfig,
1052
- execCtx!.repoRoot,
1625
+ // ── TP-040: Non-blocking resume launch ───────────────────
1626
+ // Same fire-and-forget pattern as /orch — see startBatchAsync.
1627
+ startBatchAsync(
1628
+ () => resumeOrchBatch(
1629
+ orchConfig,
1630
+ runnerConfig,
1631
+ execCtx!.repoRoot,
1632
+ orchBatchState,
1633
+ (message, level) => {
1634
+ ctx.ui.notify(message, level);
1635
+ updateOrchWidget();
1636
+ },
1637
+ (monState: MonitorState) => {
1638
+ latestMonitorState = monState;
1639
+ updateOrchWidget();
1640
+ },
1641
+ execCtx!.workspaceConfig,
1642
+ execCtx!.workspaceRoot,
1643
+ execCtx!.pointer?.agentRoot,
1644
+ parsed.force,
1645
+ ),
1053
1646
  orchBatchState,
1054
- (message, level) => {
1055
- ctx.ui.notify(message, level);
1056
- updateOrchWidget();
1057
- },
1058
- (monState: MonitorState) => {
1059
- latestMonitorState = monState;
1060
- updateOrchWidget();
1647
+ ctx,
1648
+ updateOrchWidget,
1649
+ // TP-043: Deferred supervisor deactivation (R002-1, parity with /orch).
1650
+ // Only trigger integration on completed batches.
1651
+ // TP-043 Step 2: Batch summary on all terminal paths.
1652
+ () => {
1653
+ const mode = orchConfig.orchestrator.integration;
1654
+ const opId = resolveOperatorId(orchConfig);
1655
+ const sDeps: SummaryDeps = {
1656
+ opId,
1657
+ diagnostics: orchBatchState.diagnostics ?? null,
1658
+ mergeResults: (orchBatchState.mergeResults || []).map(mr => ({
1659
+ waveIndex: mr.waveIndex,
1660
+ status: mr.status,
1661
+ failedLane: mr.failedLane,
1662
+ failureReason: mr.failureReason,
1663
+ })),
1664
+ };
1665
+ if (
1666
+ orchBatchState.phase === "completed" &&
1667
+ (mode === "supervised" || mode === "auto")
1668
+ ) {
1669
+ triggerSupervisorIntegration(
1670
+ pi,
1671
+ supervisorState,
1672
+ orchBatchState,
1673
+ mode,
1674
+ execCtx!.repoRoot,
1675
+ buildIntegrationExecutor(execCtx!.repoRoot),
1676
+ buildCiDeps(execCtx!.repoRoot),
1677
+ sDeps,
1678
+ );
1679
+ return;
1680
+ }
1681
+ if (
1682
+ (mode === "supervised" || mode === "auto") &&
1683
+ orchBatchState.phase !== "completed"
1684
+ ) {
1685
+ pi.sendMessage(
1686
+ {
1687
+ customType: "supervisor-integration-skipped",
1688
+ content: [{
1689
+ type: "text",
1690
+ text:
1691
+ `📋 **Batch ended** (phase: ${orchBatchState.phase}). ` +
1692
+ `Integration skipped — only completed batches are eligible.\n` +
1693
+ `Use \`/orch-resume\` to continue or \`/orch-integrate\` manually after resolving issues.`,
1694
+ }],
1695
+ display: `Integration skipped — batch ${orchBatchState.phase}`,
1696
+ },
1697
+ { triggerTurn: false },
1698
+ );
1699
+ }
1700
+ // TP-043: Generate summary before deactivation
1701
+ presentBatchSummary(pi, orchBatchState, execCtx!.workspaceRoot, opId, orchBatchState.diagnostics, sDeps.mergeResults);
1702
+ deactivateSupervisor(pi, supervisorState);
1061
1703
  },
1062
- execCtx!.workspaceConfig,
1063
- execCtx!.workspaceRoot,
1064
- execCtx!.pointer?.agentRoot,
1065
- parsed.force,
1066
1704
  );
1067
1705
 
1068
- // Final widget update
1069
- updateOrchWidget();
1706
+ // ── TP-041: Activate supervisor agent on resume ──────────
1707
+ // supervisorConfig is loaded at session_start from unified config.
1708
+ // Uses workspaceRoot so supervisor state root matches engine (R006-1).
1709
+ activateSupervisor(
1710
+ pi,
1711
+ supervisorState,
1712
+ orchBatchState,
1713
+ orchConfig,
1714
+ supervisorConfig,
1715
+ execCtx!.workspaceRoot,
1716
+ ctx,
1717
+ );
1070
1718
  },
1071
1719
  });
1072
1720
 
@@ -1182,6 +1830,9 @@ export default function (pi: ExtensionAPI) {
1182
1830
  }
1183
1831
 
1184
1832
  // ── Step 6: Clean up batch state ────────────────────────
1833
+ // TP-041: Deactivate supervisor on abort
1834
+ deactivateSupervisor(pi, supervisorState);
1835
+
1185
1836
  try {
1186
1837
  orchBatchState.phase = "stopped";
1187
1838
  orchBatchState.endedAt = Date.now();
@@ -1306,6 +1957,136 @@ export default function (pi: ExtensionAPI) {
1306
1957
  },
1307
1958
  });
1308
1959
 
1960
+ // ── TP-041 Step 2: /orch-takeover — force supervisor takeover ────
1961
+ pi.registerCommand("orch-takeover", {
1962
+ description: "Force takeover supervisor from another session: /orch-takeover",
1963
+ handler: async (_args, ctx) => {
1964
+ // Use workspaceRoot so supervisor state root matches engine (R006-1).
1965
+ const stateRoot = execCtx.workspaceRoot;
1966
+
1967
+ // If this session already owns the supervisor, nothing to do.
1968
+ if (supervisorState.active) {
1969
+ ctx.ui.notify(
1970
+ "✅ This session is already the active supervisor.\n\n" +
1971
+ ` Session: ${supervisorState.lockSessionId}\n` +
1972
+ ` Batch: ${supervisorState.batchId || orchBatchState.batchId}`,
1973
+ "info",
1974
+ );
1975
+ return;
1976
+ }
1977
+
1978
+ // Re-check lock state (may have changed since session_start).
1979
+ const lockResult = checkSupervisorLockOnStartup(stateRoot, loadBatchState);
1980
+
1981
+ switch (lockResult.status) {
1982
+ case "no-active-batch":
1983
+ ctx.ui.notify(
1984
+ "No active batch to supervise.\n\nStart a batch with /orch first.",
1985
+ "info",
1986
+ );
1987
+ return;
1988
+
1989
+ case "no-lockfile":
1990
+ case "corrupt":
1991
+ case "stale": {
1992
+ // No live lock to take over — just activate normally.
1993
+ const batchState = lockResult.batchState;
1994
+ const summary = buildTakeoverSummary(stateRoot, batchState);
1995
+ const reason =
1996
+ lockResult.status === "stale"
1997
+ ? (isProcessAlive(lockResult.lock.pid)
1998
+ ? `Previous supervisor (PID ${lockResult.lock.pid}) has a stale heartbeat (last: ${lockResult.lock.heartbeat}).`
1999
+ : `Previous supervisor (PID ${lockResult.lock.pid}) process is dead.`)
2000
+ : lockResult.status === "corrupt"
2001
+ ? "Found a corrupt supervisor lockfile."
2002
+ : "No supervisor lockfile found.";
2003
+
2004
+ ctx.ui.notify(
2005
+ `🔄 **${reason}** Activating supervisor.\n\n` + summary,
2006
+ "info",
2007
+ );
2008
+
2009
+ // Populate orchBatchState from persisted state
2010
+ orchBatchState.batchId = batchState.batchId;
2011
+ orchBatchState.phase = batchState.phase as typeof orchBatchState.phase;
2012
+ orchBatchState.baseBranch = batchState.baseBranch;
2013
+ orchBatchState.orchBranch = batchState.orchBranch ?? "";
2014
+ orchBatchState.currentWaveIndex = batchState.currentWaveIndex;
2015
+ orchBatchState.totalWaves = batchState.wavePlan?.length ?? batchState.totalWaves ?? 0;
2016
+ orchBatchState.totalTasks = batchState.totalTasks ?? 0;
2017
+ orchBatchState.succeededTasks = batchState.succeededTasks ?? 0;
2018
+ orchBatchState.failedTasks = batchState.failedTasks ?? 0;
2019
+ orchBatchState.skippedTasks = batchState.skippedTasks ?? 0;
2020
+ orchBatchState.blockedTasks = batchState.blockedTasks ?? 0;
2021
+ orchBatchState.startedAt = batchState.startedAt;
2022
+ orchBatchState.endedAt = batchState.endedAt ?? null;
2023
+
2024
+ await activateSupervisor(
2025
+ pi,
2026
+ supervisorState,
2027
+ orchBatchState,
2028
+ orchConfig,
2029
+ supervisorConfig,
2030
+ stateRoot,
2031
+ ctx,
2032
+ );
2033
+
2034
+ updateOrchWidget();
2035
+ break;
2036
+ }
2037
+
2038
+ case "live": {
2039
+ // Force takeover from another live session.
2040
+ // Write a new lock — the old session's heartbeat will detect
2041
+ // the sessionId mismatch and yield gracefully.
2042
+ const lock = lockResult.lock;
2043
+ const batchState = lockResult.batchState;
2044
+ const summary = buildTakeoverSummary(stateRoot, batchState);
2045
+
2046
+ ctx.ui.notify(
2047
+ `⚡ **Forcing supervisor takeover from PID ${lock.pid}.**\n\n` +
2048
+ ` Previous session: ${lock.sessionId}\n` +
2049
+ ` Previous heartbeat: ${lock.heartbeat}\n\n` +
2050
+ `The other session will yield on its next heartbeat check.\n\n` +
2051
+ summary,
2052
+ "warning",
2053
+ );
2054
+
2055
+ // Populate orchBatchState from persisted state
2056
+ orchBatchState.batchId = batchState.batchId;
2057
+ orchBatchState.phase = batchState.phase as typeof orchBatchState.phase;
2058
+ orchBatchState.baseBranch = batchState.baseBranch;
2059
+ orchBatchState.orchBranch = batchState.orchBranch ?? "";
2060
+ orchBatchState.currentWaveIndex = batchState.currentWaveIndex;
2061
+ orchBatchState.totalWaves = batchState.wavePlan?.length ?? batchState.totalWaves ?? 0;
2062
+ orchBatchState.totalTasks = batchState.totalTasks ?? 0;
2063
+ orchBatchState.succeededTasks = batchState.succeededTasks ?? 0;
2064
+ orchBatchState.failedTasks = batchState.failedTasks ?? 0;
2065
+ orchBatchState.skippedTasks = batchState.skippedTasks ?? 0;
2066
+ orchBatchState.blockedTasks = batchState.blockedTasks ?? 0;
2067
+ orchBatchState.startedAt = batchState.startedAt;
2068
+ orchBatchState.endedAt = batchState.endedAt ?? null;
2069
+
2070
+ // activateSupervisor writes a new lock with this session's ID.
2071
+ // The old session's heartbeat timer will detect the sessionId
2072
+ // mismatch and deactivate automatically.
2073
+ await activateSupervisor(
2074
+ pi,
2075
+ supervisorState,
2076
+ orchBatchState,
2077
+ orchConfig,
2078
+ supervisorConfig,
2079
+ stateRoot,
2080
+ ctx,
2081
+ );
2082
+
2083
+ updateOrchWidget();
2084
+ break;
2085
+ }
2086
+ }
2087
+ },
2088
+ });
2089
+
1309
2090
  pi.registerCommand("orch-integrate", {
1310
2091
  description: "Integrate completed orch batch into your working branch",
1311
2092
  handler: async (args, ctx) => {
@@ -1512,6 +2293,17 @@ export default function (pi: ExtensionAPI) {
1512
2293
  const summary = integrationSummary + "\n" + cleanupResult.report;
1513
2294
 
1514
2295
  ctx.ui.notify(summary, cleanupResult.notifyLevel);
2296
+
2297
+ // TP-043 R004: If supervisor has a deferred batch summary (supervised mode),
2298
+ // present it now that integration is complete, then deactivate.
2299
+ if (supervisorState.active && supervisorState.pendingSummaryDeps) {
2300
+ const deps = supervisorState.pendingSummaryDeps;
2301
+ supervisorState.pendingSummaryDeps = null;
2302
+ if (supervisorState.batchStateRef && supervisorState.stateRoot) {
2303
+ presentBatchSummary(pi, supervisorState.batchStateRef, supervisorState.stateRoot, deps.opId, deps.diagnostics, deps.mergeResults);
2304
+ }
2305
+ deactivateSupervisor(pi, supervisorState);
2306
+ }
1515
2307
  },
1516
2308
  });
1517
2309
 
@@ -1565,6 +2357,20 @@ export default function (pi: ExtensionAPI) {
1565
2357
  orchConfig = execCtx.orchestratorConfig;
1566
2358
  runnerConfig = execCtx.taskRunnerConfig;
1567
2359
 
2360
+ // TP-041: Load supervisor config from unified config.
2361
+ // Uses execCtx.repoRoot (not ctx.cwd) for consistency with the
2362
+ // established pattern — all config loading after buildExecutionContext
2363
+ // uses the resolved execution context paths.
2364
+ try {
2365
+ supervisorConfig = loadSupervisorConfig(
2366
+ execCtx.repoRoot,
2367
+ execCtx.pointer?.configRoot,
2368
+ );
2369
+ } catch {
2370
+ // Non-fatal — use defaults if supervisor config fails to load
2371
+ supervisorConfig = { ...DEFAULT_SUPERVISOR_CONFIG };
2372
+ }
2373
+
1568
2374
  // Set status line
1569
2375
  const areaCount = Object.keys(runnerConfig.task_areas).length;
1570
2376
  const modeLabel = execCtx.mode === "workspace" ? "workspace" : "repo";
@@ -1576,6 +2382,109 @@ export default function (pi: ExtensionAPI) {
1576
2382
  // Register initial dashboard widget (idle state)
1577
2383
  updateOrchWidget();
1578
2384
 
2385
+ // ── TP-041 Step 2: Supervisor startup gate ───────────────────
2386
+ // Check for an active batch with an existing lockfile. This covers
2387
+ // session reconnection scenarios (pi restarted while a batch runs
2388
+ // in tmux lanes) and crashed supervisor recovery.
2389
+ // Uses workspaceRoot so supervisor state root matches engine (R006-1).
2390
+ {
2391
+ const stateRoot = execCtx.workspaceRoot;
2392
+ const lockResult = checkSupervisorLockOnStartup(stateRoot, loadBatchState);
2393
+
2394
+ switch (lockResult.status) {
2395
+ case "no-active-batch":
2396
+ // Nothing to do — normal startup
2397
+ break;
2398
+
2399
+ case "no-lockfile":
2400
+ case "corrupt":
2401
+ case "stale": {
2402
+ // Become the supervisor for the existing batch.
2403
+ // Stale = previous supervisor crashed (pid dead or heartbeat expired).
2404
+ // Corrupt = lockfile malformed (treat as stale per R003).
2405
+ // No lockfile = active batch without a supervisor (e.g., engine running
2406
+ // from a previous /orch that didn't have supervisor support yet).
2407
+ const batchState = lockResult.batchState;
2408
+ const summary = buildTakeoverSummary(stateRoot, batchState);
2409
+ const reason =
2410
+ lockResult.status === "stale"
2411
+ ? (isProcessAlive(lockResult.lock.pid)
2412
+ ? `Previous supervisor (PID ${lockResult.lock.pid}) has a stale heartbeat (last: ${lockResult.lock.heartbeat}). Process may be hung.`
2413
+ : `Previous supervisor (PID ${lockResult.lock.pid}) process is dead.`)
2414
+ : lockResult.status === "corrupt"
2415
+ ? "Found a corrupt supervisor lockfile (treating as stale)."
2416
+ : "No supervisor lockfile found for the active batch.";
2417
+
2418
+ ctx.ui.notify(
2419
+ `🔄 **Active batch detected — ${reason}**\n\n` +
2420
+ `Taking over supervisor duties for batch ${batchState.batchId}.\n\n` +
2421
+ summary,
2422
+ "info",
2423
+ );
2424
+
2425
+ // Populate orchBatchState from persisted state for the supervisor
2426
+ // prompt rebuild. We copy the key fields used by the system prompt.
2427
+ orchBatchState.batchId = batchState.batchId;
2428
+ orchBatchState.phase = batchState.phase as typeof orchBatchState.phase;
2429
+ orchBatchState.baseBranch = batchState.baseBranch;
2430
+ orchBatchState.orchBranch = batchState.orchBranch ?? "";
2431
+ orchBatchState.currentWaveIndex = batchState.currentWaveIndex;
2432
+ orchBatchState.totalWaves = batchState.wavePlan?.length ?? batchState.totalWaves ?? 0;
2433
+ orchBatchState.totalTasks = batchState.totalTasks ?? 0;
2434
+ orchBatchState.succeededTasks = batchState.succeededTasks ?? 0;
2435
+ orchBatchState.failedTasks = batchState.failedTasks ?? 0;
2436
+ orchBatchState.skippedTasks = batchState.skippedTasks ?? 0;
2437
+ orchBatchState.blockedTasks = batchState.blockedTasks ?? 0;
2438
+ orchBatchState.startedAt = batchState.startedAt;
2439
+ orchBatchState.endedAt = batchState.endedAt ?? null;
2440
+
2441
+ // Activate supervisor with rehydration context.
2442
+ // activateSupervisor writes the lockfile and starts heartbeat.
2443
+ activateSupervisor(
2444
+ pi,
2445
+ supervisorState,
2446
+ orchBatchState,
2447
+ orchConfig,
2448
+ supervisorConfig,
2449
+ stateRoot,
2450
+ ctx,
2451
+ );
2452
+
2453
+ updateOrchWidget();
2454
+ break;
2455
+ }
2456
+
2457
+ case "live": {
2458
+ // Another supervisor is actively running (pid alive, heartbeat fresh).
2459
+ // Warn the operator and offer force takeover via /orch-takeover.
2460
+ const lock = lockResult.lock;
2461
+ const batchState = lockResult.batchState;
2462
+ ctx.ui.notify(
2463
+ `⚠️ **Another supervisor is already monitoring batch ${batchState.batchId}.**\n\n` +
2464
+ ` PID: ${lock.pid}\n` +
2465
+ ` Session: ${lock.sessionId}\n` +
2466
+ ` Started: ${lock.startedAt}\n` +
2467
+ ` Last heartbeat: ${lock.heartbeat}\n\n` +
2468
+ `To force takeover, run \`/orch-takeover\`.\n` +
2469
+ `The other session will yield on its next heartbeat.\n\n` +
2470
+ `Otherwise, use the other terminal or the dashboard to monitor the batch.`,
2471
+ "warning",
2472
+ );
2473
+
2474
+ // Store the live lock info so the /orch handler can detect it
2475
+ // (preventing a second /orch from starting a concurrent batch).
2476
+ orchBatchState.batchId = batchState.batchId;
2477
+ orchBatchState.phase = batchState.phase as typeof orchBatchState.phase;
2478
+ orchBatchState.baseBranch = batchState.baseBranch;
2479
+ orchBatchState.orchBranch = batchState.orchBranch ?? "";
2480
+ orchBatchState.startedAt = batchState.startedAt;
2481
+
2482
+ updateOrchWidget();
2483
+ break;
2484
+ }
2485
+ }
2486
+ }
2487
+
1579
2488
  // Notify user of available commands
1580
2489
  ctx.ui.notify(
1581
2490
  "Task Orchestrator ready\n\n" +
@@ -1588,6 +2497,7 @@ export default function (pi: ExtensionAPI) {
1588
2497
  "/orch-plan <areas|all> Preview execution plan\n" +
1589
2498
  "/orch-deps <areas|all> Show dependency graph\n" +
1590
2499
  "/orch-sessions List TMUX sessions\n" +
2500
+ "/orch-takeover Force supervisor takeover\n" +
1591
2501
  "/orch-integrate Integrate orch branch into working branch",
1592
2502
  "info",
1593
2503
  );
@@ -1595,6 +2505,17 @@ export default function (pi: ExtensionAPI) {
1595
2505
  // Check for taskplane updates (non-blocking)
1596
2506
  checkForUpdate(ctx);
1597
2507
  });
2508
+
2509
+ // ── Session shutdown cleanup ─────────────────────────────────────
2510
+ // Ensure supervisor lockfile/heartbeat are cleaned up on normal session exit.
2511
+ // This avoids leaving a live-looking lock when the process exits cleanly.
2512
+ pi.on("session_end", async () => {
2513
+ try {
2514
+ await deactivateSupervisor(pi, supervisorState);
2515
+ } catch {
2516
+ // Best effort only — session is already ending.
2517
+ }
2518
+ });
1598
2519
  }
1599
2520
 
1600
2521
  // ── Update Check ─────────────────────────────────────────────────────