@wpro-eng/opencode-config 1.4.0 → 1.5.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.
@@ -7,8 +7,7 @@ import { parse as parseJsonc } from "jsonc-parser"
7
7
 
8
8
  type ProviderMode = "copilot" | "native"
9
9
  type TaskStatus = "queued" | "running" | "retrying" | "completed" | "failed" | "stopped"
10
- type TmuxLayout = "main-horizontal" | "main-vertical" | "tiled" | "even-horizontal" | "even-vertical"
11
- type TmuxState = "disabled" | "queued" | "attached" | "failed"
10
+ type TmuxState = "disabled" | "attached" | "failed"
12
11
 
13
12
  interface OrchestrationConfig {
14
13
  providerMode: ProviderMode
@@ -52,10 +51,6 @@ interface OrchestrationConfig {
52
51
  }
53
52
  tmux: {
54
53
  enabled: boolean
55
- layout: TmuxLayout
56
- mainPaneSize: number
57
- mainPaneMinWidth: number
58
- agentPaneMinWidth: number
59
54
  sessionPrefix: string
60
55
  }
61
56
  }
@@ -81,7 +76,7 @@ interface TaskRecord {
81
76
  fallbackReason: string | null
82
77
  blockedBy: string[]
83
78
  blocks: string[]
84
- tmuxPaneID: string | null
79
+ tmuxWindowID: string | null
85
80
  tmuxSessionName: string | null
86
81
  tmuxState: TmuxState
87
82
  }
@@ -89,6 +84,7 @@ interface TaskRecord {
89
84
  interface SessionState {
90
85
  loopEnabled: boolean
91
86
  tmuxMissingWarned: boolean
87
+ tmuxSessionCreated: boolean
92
88
  telemetry: {
93
89
  created: number
94
90
  resumed: number
@@ -104,7 +100,6 @@ interface SessionState {
104
100
  message: string
105
101
  taskID: string
106
102
  }[]
107
- deferredTmuxQueue: string[]
108
103
  }
109
104
 
110
105
  const USER_HOME = process.env.HOME && process.env.HOME.trim().length > 0 ? process.env.HOME : homedir()
@@ -152,10 +147,6 @@ const DEFAULT_CONFIG: OrchestrationConfig = {
152
147
  },
153
148
  tmux: {
154
149
  enabled: false,
155
- layout: "main-vertical",
156
- mainPaneSize: 60,
157
- mainPaneMinWidth: 120,
158
- agentPaneMinWidth: 40,
159
150
  sessionPrefix: "wpo",
160
151
  },
161
152
  }
@@ -171,6 +162,7 @@ function getSessionState(sessionID: string): SessionState {
171
162
  const state: SessionState = {
172
163
  loopEnabled: false,
173
164
  tmuxMissingWarned: false,
165
+ tmuxSessionCreated: false,
174
166
  telemetry: {
175
167
  created: 0,
176
168
  resumed: 0,
@@ -181,7 +173,6 @@ function getSessionState(sessionID: string): SessionState {
181
173
  timedOut: 0,
182
174
  },
183
175
  notifications: [],
184
- deferredTmuxQueue: [],
185
176
  }
186
177
  sessionStates.set(sessionID, state)
187
178
  return state
@@ -264,19 +255,6 @@ function parseCategoryConcurrency(value: unknown): Record<string, number> {
264
255
  return parsed
265
256
  }
266
257
 
267
- function parseTmuxLayout(value: unknown): TmuxLayout {
268
- if (
269
- value === "main-horizontal" ||
270
- value === "main-vertical" ||
271
- value === "tiled" ||
272
- value === "even-horizontal" ||
273
- value === "even-vertical"
274
- ) {
275
- return value
276
- }
277
- return DEFAULT_CONFIG.tmux.layout
278
- }
279
-
280
258
  function parseSessionPrefix(value: unknown): string {
281
259
  if (typeof value !== "string") return DEFAULT_CONFIG.tmux.sessionPrefix
282
260
  const trimmed = value.trim()
@@ -383,15 +361,6 @@ function parseConfigFromFile(): ConfigLoadResult {
383
361
  },
384
362
  tmux: {
385
363
  enabled: tmux.enabled === true,
386
- layout: parseTmuxLayout(tmux.layout),
387
- mainPaneSize: parseBoundedInt(tmux.mainPaneSize, DEFAULT_CONFIG.tmux.mainPaneSize, 20, 80),
388
- mainPaneMinWidth: parseBoundedInt(tmux.mainPaneMinWidth, DEFAULT_CONFIG.tmux.mainPaneMinWidth, 40, 400),
389
- agentPaneMinWidth: parseBoundedInt(
390
- tmux.agentPaneMinWidth,
391
- DEFAULT_CONFIG.tmux.agentPaneMinWidth,
392
- 20,
393
- 200,
394
- ),
395
364
  sessionPrefix: parseSessionPrefix(tmux.sessionPrefix),
396
365
  },
397
366
  },
@@ -480,6 +449,10 @@ function getCategoryConcurrencyLimit(config: OrchestrationConfig, category: stri
480
449
  return config.categories.maxConcurrent[category] ?? config.limits.maxConcurrent
481
450
  }
482
451
 
452
+ function isTerminalStatus(status: TaskStatus): boolean {
453
+ return status === "completed" || status === "failed" || status === "stopped"
454
+ }
455
+
483
456
  function isActiveTaskStatus(status: TaskStatus): boolean {
484
457
  return status === "running" || status === "retrying" || status === "queued"
485
458
  }
@@ -515,12 +488,14 @@ function safeTaskRecord(value: unknown): TaskRecord | null {
515
488
  const blocks = Array.isArray(value.blocks)
516
489
  ? value.blocks.filter((item): item is string => typeof item === "string")
517
490
  : []
518
- const tmuxPaneID = value.tmuxPaneID === null ? null : asString(value.tmuxPaneID) ?? null
491
+ // Accept both old "tmuxPaneID" and new "tmuxWindowID" for backward compat with persisted data
492
+ const tmuxWindowID = value.tmuxWindowID === null ? null
493
+ : asString(value.tmuxWindowID) ?? (value.tmuxPaneID === null ? null : asString(value.tmuxPaneID) ?? null)
519
494
  const tmuxSessionName = value.tmuxSessionName === null ? null : asString(value.tmuxSessionName) ?? null
520
495
  const tmuxState = asString(value.tmuxState)
521
496
 
522
497
  const validTmuxState =
523
- tmuxState === "disabled" || tmuxState === "queued" || tmuxState === "attached" || tmuxState === "failed"
498
+ tmuxState === "disabled" || tmuxState === "attached" || tmuxState === "failed"
524
499
 
525
500
  const validStatus =
526
501
  status === "queued" ||
@@ -550,7 +525,7 @@ function safeTaskRecord(value: unknown): TaskRecord | null {
550
525
  fallbackReason,
551
526
  blockedBy,
552
527
  blocks,
553
- tmuxPaneID,
528
+ tmuxWindowID,
554
529
  tmuxSessionName,
555
530
  tmuxState: validTmuxState ? tmuxState : "disabled",
556
531
  }
@@ -643,7 +618,7 @@ function recoverPersistedTasks(config: OrchestrationConfig): void {
643
618
  record.status = "queued"
644
619
  record.updatedAt = nowIso()
645
620
  record.details = "Recovered after restart; pending re-dispatch"
646
- record.tmuxPaneID = null
621
+ record.tmuxWindowID = null
647
622
  record.tmuxSessionName = null
648
623
  record.tmuxState = "disabled"
649
624
  recovered += 1
@@ -675,7 +650,7 @@ function createTaskRecord(sessionID: string, title: string, agent: string, categ
675
650
  fallbackReason: null,
676
651
  blockedBy: [],
677
652
  blocks: [],
678
- tmuxPaneID: null,
653
+ tmuxWindowID: null,
679
654
  tmuxSessionName: null,
680
655
  tmuxState: "disabled",
681
656
  }
@@ -706,6 +681,14 @@ function enforceTaskTimeouts(sessionID: string, config: OrchestrationConfig): nu
706
681
 
707
682
  function setTaskStatus(record: TaskRecord, status: TaskStatus, details?: string): void {
708
683
  const previousStatus = record.status
684
+
685
+ // Terminal states are sticky: once a task is completed/failed/stopped,
686
+ // it cannot transition back to any other state. This prevents
687
+ // tool.execute.after from overwriting a /stop command's "stopped" status.
688
+ if (isTerminalStatus(previousStatus) && previousStatus !== status) {
689
+ return
690
+ }
691
+
709
692
  record.status = status
710
693
  record.updatedAt = nowIso()
711
694
  if (details) record.details = details
@@ -764,6 +747,47 @@ function classifyFailure(output: string): { code: number | null; reason: string
764
747
  return { code: null, reason: "unknown" }
765
748
  }
766
749
 
750
+ // Structural failure patterns that indicate the task tool itself failed,
751
+ // not that the agent's analysis merely discusses failures.
752
+ const FAILURE_SIGNAL_PATTERNS = [
753
+ /^error:/im,
754
+ /task failed/i,
755
+ /execution failed/i,
756
+ /provider.*error/i,
757
+ /\b(4\d\d|5\d\d)\s+(error|status|response)\b/i,
758
+ /rate limit/i,
759
+ /too many requests/i,
760
+ /api key/i,
761
+ /credential/i,
762
+ /timed?\s*out/i,
763
+ /connection refused/i,
764
+ /ECONNREFUSED/,
765
+ /ETIMEDOUT/,
766
+ ]
767
+
768
+ // The max length of output we consider "short enough" to be an error message
769
+ // rather than substantive agent analysis. Subagent responses with real work
770
+ // are typically 500+ chars; provider errors are usually under 300.
771
+ const FAILURE_OUTPUT_MAX_LENGTH = 300
772
+
773
+ function isLikelyTaskFailure(output: string): boolean {
774
+ if (output.length === 0) return false
775
+
776
+ // Short outputs that contain "failed" are likely actual errors from the
777
+ // task tool or provider, not agent analysis that happens to mention failure.
778
+ if (output.length <= FAILURE_OUTPUT_MAX_LENGTH && output.toLowerCase().includes("failed")) {
779
+ return true
780
+ }
781
+
782
+ // Check for structural failure patterns regardless of output length.
783
+ // These are specific enough to not false-positive on agent analysis text.
784
+ for (const pattern of FAILURE_SIGNAL_PATTERNS) {
785
+ if (pattern.test(output)) return true
786
+ }
787
+
788
+ return false
789
+ }
790
+
767
791
  function getNextProvider(config: OrchestrationConfig, current: ProviderMode): ProviderMode | null {
768
792
  const order = config.runtimeFallback.providerOrder
769
793
  const index = order.indexOf(current)
@@ -789,110 +813,102 @@ function runTmux(args: string[]): { ok: boolean; stdout: string; stderr: string
789
813
  }
790
814
  }
791
815
 
792
- function normalizeTaskToken(value: string): string {
793
- return value.toLowerCase().replace(/[^a-z0-9_-]/g, "-").slice(0, 18)
816
+ function normalizeTmuxToken(value: string): string {
817
+ return value.toLowerCase().replace(/[^a-z0-9_-]/g, "-")
794
818
  }
795
819
 
796
- function getTmuxSessionName(config: OrchestrationConfig, record: TaskRecord): string {
797
- const prefix = normalizeTaskToken(config.tmux.sessionPrefix)
798
- const token = normalizeTaskToken(record.id)
820
+ // Builds the dedicated tmux session name for an OpenCode session.
821
+ // All task windows for this session live inside this one tmux session.
822
+ function getAgentSessionName(config: OrchestrationConfig, sessionID: string): string {
823
+ const prefix = normalizeTmuxToken(config.tmux.sessionPrefix)
824
+ const token = normalizeTmuxToken(sessionID).slice(0, 24)
799
825
  return `${prefix}-${token}`
800
826
  }
801
827
 
802
- function getSplitDirection(layout: TmuxLayout): "-h" | "-v" {
803
- return layout === "main-horizontal" ? "-v" : "-h"
804
- }
828
+ // Ensures the dedicated tmux session exists for this OpenCode session.
829
+ // Creates it detached on first call, subsequent calls are no-ops.
830
+ function ensureAgentSession(config: OrchestrationConfig, sessionID: string): { ok: boolean; sessionName: string; reason: string } {
831
+ const sessionName = getAgentSessionName(config, sessionID)
832
+ const state = getSessionState(sessionID)
805
833
 
806
- function applyTmuxLayout(config: OrchestrationConfig): void {
807
- runTmux(["select-layout", config.tmux.layout])
808
- if (config.tmux.layout === "main-horizontal") {
809
- runTmux(["set-window-option", "main-pane-height", `${config.tmux.mainPaneSize}%`])
810
- }
811
- if (config.tmux.layout === "main-vertical") {
812
- runTmux(["set-window-option", "main-pane-width", `${config.tmux.mainPaneSize}%`])
834
+ if (state.tmuxSessionCreated) {
835
+ // Verify it still exists (user might have killed it)
836
+ const check = runTmux(["has-session", "-t", sessionName])
837
+ if (check.ok) return { ok: true, sessionName, reason: "exists" }
838
+ // Session was killed externally, recreate it
839
+ state.tmuxSessionCreated = false
813
840
  }
814
- }
815
841
 
816
- function getWindowStats(): { width: number; height: number; paneCount: number } {
817
- const info = runTmux(["display-message", "-p", "#{window_width},#{window_height},#{window_panes}"])
818
- if (!info.ok) return { width: 0, height: 0, paneCount: 0 }
819
- const [widthStr, heightStr, paneCountStr] = info.stdout.trim().split(",")
820
- return {
821
- width: Number.parseInt(widthStr ?? "0", 10) || 0,
822
- height: Number.parseInt(heightStr ?? "0", 10) || 0,
823
- paneCount: Number.parseInt(paneCountStr ?? "0", 10) || 0,
842
+ // Create a new detached session. The initial window is a placeholder
843
+ // that will be replaced by the first task window.
844
+ const create = runTmux(["new-session", "-d", "-s", sessionName, "-n", "agents"])
845
+ if (!create.ok) {
846
+ // Session might already exist from a previous run
847
+ const check = runTmux(["has-session", "-t", sessionName])
848
+ if (check.ok) {
849
+ state.tmuxSessionCreated = true
850
+ return { ok: true, sessionName, reason: "already-exists" }
851
+ }
852
+ return { ok: false, sessionName, reason: create.stderr.trim() || "new-session failed" }
824
853
  }
825
- }
826
854
 
827
- function estimateMaxAgentPanes(config: OrchestrationConfig): number {
828
- const stats = getWindowStats()
829
- if (stats.width <= 0 || stats.height <= 0) return 0
830
- const divider = 1
831
- const desiredMainWidth = Math.floor((stats.width - divider) * (config.tmux.mainPaneSize / 100))
832
- const mainWidth = Math.max(config.tmux.mainPaneMinWidth, desiredMainWidth)
833
- const rightWidth = Math.max(0, stats.width - mainWidth - divider)
834
- if (rightWidth <= 0) return 0
835
- const columns = Math.max(1, Math.floor((rightWidth + divider) / (config.tmux.agentPaneMinWidth + divider)))
836
- const rows = Math.max(1, Math.floor((stats.height + divider) / (11 + divider)))
837
- return columns * rows
855
+ state.tmuxSessionCreated = true
856
+ return { ok: true, sessionName, reason: "created" }
838
857
  }
839
858
 
840
- function attachTaskPane(record: TaskRecord, config: OrchestrationConfig): { attached: boolean; reason: string } {
859
+ // Creates a new tmux window inside the agent session for a task.
860
+ function attachTaskWindow(record: TaskRecord, config: OrchestrationConfig): { attached: boolean; reason: string } {
841
861
  if (!config.tmux.enabled) {
842
862
  record.tmuxState = "disabled"
843
- record.tmuxPaneID = null
863
+ record.tmuxWindowID = null
844
864
  record.tmuxSessionName = null
845
865
  return { attached: false, reason: "tmux disabled" }
846
866
  }
847
867
  if (!isInsideTmux()) {
848
868
  record.tmuxState = "disabled"
849
- record.tmuxPaneID = null
869
+ record.tmuxWindowID = null
850
870
  record.tmuxSessionName = null
851
871
  return { attached: false, reason: "not inside tmux" }
852
872
  }
853
873
  if (!hasTmuxBinary()) {
854
874
  record.tmuxState = "disabled"
855
- record.tmuxPaneID = null
875
+ record.tmuxWindowID = null
856
876
  record.tmuxSessionName = null
857
877
  notifyTmuxMissingOnce(record.sessionID)
858
878
  return { attached: false, reason: "tmux binary unavailable" }
859
879
  }
860
880
 
861
- const currentAttachedCount = getTasksForSession(record.sessionID).filter((task) => task.tmuxState === "attached").length
862
- const maxAgentPanes = estimateMaxAgentPanes(config)
863
- if (maxAgentPanes > 0 && currentAttachedCount >= maxAgentPanes) {
864
- record.tmuxState = "queued"
865
- return { attached: false, reason: `no pane capacity (${currentAttachedCount}/${maxAgentPanes})` }
881
+ const session = ensureAgentSession(config, record.sessionID)
882
+ if (!session.ok) {
883
+ record.tmuxState = "failed"
884
+ return { attached: false, reason: session.reason }
866
885
  }
867
886
 
868
- const splitDirection = getSplitDirection(config.tmux.layout)
869
- const split = runTmux(["split-window", splitDirection, "-d", "-P", "-F", "#{pane_id}"])
870
- if (!split.ok) {
887
+ const windowName = `${normalizeTmuxToken(record.id)}:${record.title.slice(0, 20).replace(/[^a-zA-Z0-9_-]/g, "-")}`
888
+ const newWindow = runTmux(["new-window", "-t", session.sessionName, "-n", windowName, "-d", "-P", "-F", "#{window_id}"])
889
+ if (!newWindow.ok) {
871
890
  record.tmuxState = "failed"
872
- return { attached: false, reason: split.stderr.trim() || "split-window failed" }
891
+ return { attached: false, reason: newWindow.stderr.trim() || "new-window failed" }
873
892
  }
874
893
 
875
- const paneID = split.stdout.trim().split("\n")[0]?.trim()
876
- if (!paneID) {
894
+ const windowID = newWindow.stdout.trim().split("\n")[0]?.trim()
895
+ if (!windowID) {
877
896
  record.tmuxState = "failed"
878
- return { attached: false, reason: "split-window returned empty pane id" }
897
+ return { attached: false, reason: "new-window returned empty window id" }
879
898
  }
880
899
 
881
- const sessionName = getTmuxSessionName(config, record)
882
- const title = `${sessionName}:${record.title.slice(0, 24)}`
883
- runTmux(["select-pane", "-t", paneID, "-T", title])
884
- runTmux(["send-keys", "-t", paneID, `printf \"[${sessionName}] tracking ${record.id}\\n\"`, "Enter"])
885
- applyTmuxLayout(config)
900
+ runTmux(["send-keys", "-t", `${session.sessionName}:${windowName}`, `printf \"[${session.sessionName}] tracking ${record.id}\\n\"`, "Enter"])
886
901
 
887
902
  record.tmuxState = "attached"
888
- record.tmuxPaneID = paneID
889
- record.tmuxSessionName = sessionName
890
- return { attached: true, reason: paneID }
903
+ record.tmuxWindowID = windowID
904
+ record.tmuxSessionName = session.sessionName
905
+ return { attached: true, reason: windowID }
891
906
  }
892
907
 
893
- function closeTaskPane(record: TaskRecord, config: OrchestrationConfig): void {
894
- if (!record.tmuxPaneID) {
895
- record.tmuxPaneID = null
908
+ // Closes a task's tmux window. If it's the last window, the session dies automatically.
909
+ function closeTaskWindow(record: TaskRecord, config: OrchestrationConfig): void {
910
+ if (!record.tmuxWindowID) {
911
+ record.tmuxWindowID = null
896
912
  record.tmuxSessionName = null
897
913
  record.tmuxState = "disabled"
898
914
  persistSessionTasks(record.sessionID)
@@ -900,63 +916,30 @@ function closeTaskPane(record: TaskRecord, config: OrchestrationConfig): void {
900
916
  }
901
917
 
902
918
  if (!config.tmux.enabled || !isInsideTmux() || !hasTmuxBinary()) {
903
- record.tmuxPaneID = null
919
+ record.tmuxWindowID = null
904
920
  record.tmuxSessionName = null
905
921
  record.tmuxState = "disabled"
906
922
  if (!hasTmuxBinary()) notifyTmuxMissingOnce(record.sessionID)
907
923
  persistSessionTasks(record.sessionID)
908
924
  return
909
925
  }
910
- runTmux(["send-keys", "-t", record.tmuxPaneID, "C-c"])
911
- runTmux(["kill-pane", "-t", record.tmuxPaneID])
912
- record.tmuxPaneID = null
926
+ runTmux(["send-keys", "-t", record.tmuxWindowID, "C-c"])
927
+ runTmux(["kill-window", "-t", record.tmuxWindowID])
928
+ record.tmuxWindowID = null
913
929
  record.tmuxSessionName = null
914
930
  record.tmuxState = "disabled"
915
931
  persistSessionTasks(record.sessionID)
916
932
  }
917
933
 
918
- function enqueueDeferredTmuxTask(sessionID: string, taskID: string): void {
919
- const state = getSessionState(sessionID)
920
- if (!state.deferredTmuxQueue.includes(taskID)) state.deferredTmuxQueue.push(taskID)
921
- }
922
-
923
- function processDeferredTmuxQueue(sessionID: string, config: OrchestrationConfig): number {
924
- if (!config.tmux.enabled) return 0
925
- const state = getSessionState(sessionID)
926
- if (state.deferredTmuxQueue.length === 0) return 0
927
-
928
- let attached = 0
929
- const remaining: string[] = []
930
- for (const taskID of state.deferredTmuxQueue) {
931
- const record = taskRecords.get(taskID)
932
- if (!record || record.sessionID !== sessionID) continue
933
- if (record.status === "completed" || record.status === "failed" || record.status === "stopped") continue
934
- const result = attachTaskPane(record, config)
935
- if (result.attached) {
936
- attached += 1
937
- pushNotification(sessionID, "info", `Tmux pane attached for ${record.title}`, record.id)
938
- persistSessionTasks(sessionID)
939
- continue
940
- }
941
- if (record.tmuxState === "queued") {
942
- remaining.push(taskID)
943
- break
944
- }
945
- }
946
-
947
- state.deferredTmuxQueue = remaining
948
- return attached
949
- }
950
-
951
- function verifyTaskPane(record: TaskRecord): boolean {
952
- if (!record.tmuxPaneID) return false
953
- const probe = runTmux(["display-message", "-p", "-t", record.tmuxPaneID, "#{pane_id}"])
934
+ function verifyTaskWindow(record: TaskRecord): boolean {
935
+ if (!record.tmuxWindowID) return false
936
+ const probe = runTmux(["display-message", "-p", "-t", record.tmuxWindowID, "#{window_id}"])
954
937
  return probe.ok
955
938
  }
956
939
 
957
- function reconcileTmuxState(sessionID: string, config: OrchestrationConfig): { staleClosed: number; missing: number; queuedAttached: number } {
940
+ function reconcileTmuxState(sessionID: string, config: OrchestrationConfig): { staleClosed: number; missing: number } {
958
941
  if (!config.tmux.enabled || !isInsideTmux() || !hasTmuxBinary()) {
959
- return { staleClosed: 0, missing: 0, queuedAttached: 0 }
942
+ return { staleClosed: 0, missing: 0 }
960
943
  }
961
944
 
962
945
  let staleClosed = 0
@@ -964,31 +947,55 @@ function reconcileTmuxState(sessionID: string, config: OrchestrationConfig): { s
964
947
  const tasks = getTasksForSession(sessionID)
965
948
  for (const record of tasks) {
966
949
  const terminal = record.status === "completed" || record.status === "failed" || record.status === "stopped"
967
- if (terminal && record.tmuxPaneID) {
968
- closeTaskPane(record, config)
950
+ if (terminal && record.tmuxWindowID) {
951
+ closeTaskWindow(record, config)
969
952
  staleClosed += 1
970
953
  continue
971
954
  }
972
- if (record.tmuxState === "attached" && record.tmuxPaneID && !verifyTaskPane(record)) {
955
+ if (record.tmuxState === "attached" && record.tmuxWindowID && !verifyTaskWindow(record)) {
973
956
  record.tmuxState = "failed"
974
- record.tmuxPaneID = null
957
+ record.tmuxWindowID = null
975
958
  record.tmuxSessionName = null
976
959
  missing += 1
977
960
  persistSessionTasks(sessionID)
978
- pushNotification(sessionID, "warn", `Tmux pane missing for ${record.title}`, record.id)
961
+ pushNotification(sessionID, "warn", `Tmux window missing for ${record.title}`, record.id)
979
962
  }
980
963
  }
981
- const queuedAttached = processDeferredTmuxQueue(sessionID, config)
982
- return { staleClosed, missing, queuedAttached }
964
+ if (staleClosed > 0 || missing > 0) {
965
+ maybeDestroyIdleAgentSession(config, sessionID)
966
+ }
967
+ return { staleClosed, missing }
968
+ }
969
+
970
+ // Kills the entire agent session for this OpenCode session.
971
+ function destroyAgentSession(config: OrchestrationConfig, sessionID: string): void {
972
+ if (!hasTmuxBinary()) return
973
+ const sessionName = getAgentSessionName(config, sessionID)
974
+ runTmux(["kill-session", "-t", sessionName])
975
+ const state = getSessionState(sessionID)
976
+ state.tmuxSessionCreated = false
977
+ }
978
+
979
+ // Destroys the agent session if no tasks have attached windows remaining.
980
+ // Called after a task window is closed to clean up when all work is done.
981
+ function maybeDestroyIdleAgentSession(config: OrchestrationConfig, sessionID: string): void {
982
+ if (!config.tmux.enabled || !isInsideTmux() || !hasTmuxBinary()) return
983
+ const state = getSessionState(sessionID)
984
+ if (!state.tmuxSessionCreated) return
985
+
986
+ const hasAttachedWindows = getTasksForSession(sessionID).some((task) => task.tmuxState === "attached")
987
+ if (!hasAttachedWindows) {
988
+ destroyAgentSession(config, sessionID)
989
+ }
983
990
  }
984
991
 
985
992
  function formatTaskLine(record: TaskRecord): string {
986
993
  const bg = record.backgroundTaskID ? ` bg=${record.backgroundTaskID}` : ""
987
994
  const category = record.category ? ` category=${record.category}` : ""
988
995
  const deps = record.blockedBy.length > 0 ? ` blockedBy=${record.blockedBy.join(",")}` : ""
989
- const pane = record.tmuxPaneID ? ` pane=${record.tmuxPaneID}` : ""
996
+ const window = record.tmuxWindowID ? ` window=${record.tmuxWindowID}` : ""
990
997
  const tmuxState = ` tmux=${record.tmuxState}`
991
- return `- ${record.id} [${record.status}] ${record.agent} \"${record.title}\"${bg}${category}${deps}${pane}${tmuxState}`
998
+ return `- ${record.id} [${record.status}] ${record.agent} \"${record.title}\"${bg}${category}${deps}${window}${tmuxState}`
992
999
  }
993
1000
 
994
1001
  interface DiagnosticCheck {
@@ -1023,7 +1030,6 @@ function runDiagnostics(sessionID: string): string {
1023
1030
  return active > limit
1024
1031
  })
1025
1032
  const attachedTmuxTasks = tasks.filter((task) => task.tmuxState === "attached")
1026
- const queuedTmuxTasks = tasks.filter((task) => task.tmuxState === "queued")
1027
1033
  const state = getSessionState(sessionID)
1028
1034
 
1029
1035
  const requiredCommands = ["continue", "stop", "tasks", "task", "diagnostics", "look-at"]
@@ -1064,10 +1070,6 @@ function runDiagnostics(sessionID: string): string {
1064
1070
  diagnosticsCheck("notifications:maxItems", config.notifications.maxItems >= 10 && config.notifications.maxItems <= 500, "set orchestration.notifications.maxItems in range 10..500"),
1065
1071
  diagnosticsCheck("recovery:autoResumeOnStart", typeof config.recovery.autoResumeOnStart === "boolean", "set orchestration.recovery.autoResumeOnStart to true/false"),
1066
1072
  diagnosticsCheck("tmux:enabled", typeof config.tmux.enabled === "boolean", "set orchestration.tmux.enabled to true/false"),
1067
- diagnosticsCheck("tmux:layout", ["main-horizontal", "main-vertical", "tiled", "even-horizontal", "even-vertical"].includes(config.tmux.layout), "set orchestration.tmux.layout to a supported layout"),
1068
- diagnosticsCheck("tmux:mainPaneSize", config.tmux.mainPaneSize >= 20 && config.tmux.mainPaneSize <= 80, "set orchestration.tmux.mainPaneSize in range 20..80"),
1069
- diagnosticsCheck("tmux:mainPaneMinWidth", config.tmux.mainPaneMinWidth >= 40 && config.tmux.mainPaneMinWidth <= 400, "set orchestration.tmux.mainPaneMinWidth in range 40..400"),
1070
- diagnosticsCheck("tmux:agentPaneMinWidth", config.tmux.agentPaneMinWidth >= 20 && config.tmux.agentPaneMinWidth <= 200, "set orchestration.tmux.agentPaneMinWidth in range 20..200"),
1071
1073
  diagnosticsCheck("tmux:sessionPrefix", /^[a-zA-Z0-9_-]{2,20}$/.test(config.tmux.sessionPrefix), "set orchestration.tmux.sessionPrefix to 2..20 chars [a-zA-Z0-9_-]"),
1072
1074
  diagnosticsCheck("tmux:insideSession", !config.tmux.enabled || isInsideTmux(), "run inside tmux or disable orchestration.tmux.enabled"),
1073
1075
  diagnosticsCheck("tmux:binary", !config.tmux.enabled || hasTmuxBinary(), "install tmux and ensure it is on PATH"),
@@ -1081,6 +1083,11 @@ function runDiagnostics(sessionID: string): string {
1081
1083
  const passCount = allChecks.length - failedChecks.length
1082
1084
  const failCount = failedChecks.length
1083
1085
 
1086
+ const agentSessionName = getAgentSessionName(config, sessionID)
1087
+ const agentSessionExists = config.tmux.enabled && isInsideTmux() && hasTmuxBinary()
1088
+ ? runTmux(["has-session", "-t", agentSessionName]).ok
1089
+ : false
1090
+
1084
1091
  const lines = [
1085
1092
  "# Wpromote Diagnostics (Full)",
1086
1093
  "",
@@ -1102,13 +1109,12 @@ function runDiagnostics(sessionID: string): string {
1102
1109
  `- Active task records in this session: ${tasks.length}`,
1103
1110
  `- Persisted task records across sessions: ${getPersistedTaskCount()}`,
1104
1111
  `- Active delegated tasks: ${activeTasks.length}/${config.limits.maxConcurrent}`,
1105
- `- Active tmux panes: ${attachedTmuxTasks.length}`,
1106
- `- Deferred tmux queue depth: ${state.deferredTmuxQueue.length}`,
1112
+ `- Active tmux windows: ${attachedTmuxTasks.length}`,
1113
+ `- Agent tmux session: ${agentSessionExists ? agentSessionName : "none"}`,
1107
1114
  `- Recovery auto-resume on startup: ${config.recovery.autoResumeOnStart ? "enabled" : "disabled"}`,
1108
1115
  `- Timed out tasks auto-marked failed in this pass: ${timedOutTasks}`,
1109
- `- Tmux stale pane cleanups in this pass: ${tmuxHealth.staleClosed}`,
1110
- `- Tmux missing pane detections in this pass: ${tmuxHealth.missing}`,
1111
- `- Deferred tmux attaches completed in this pass: ${tmuxHealth.queuedAttached}`,
1116
+ `- Tmux stale window cleanups in this pass: ${tmuxHealth.staleClosed}`,
1117
+ `- Tmux missing window detections in this pass: ${tmuxHealth.missing}`,
1112
1118
  `- Loop mode: ${getSessionState(sessionID).loopEnabled ? "enabled" : "disabled"}`,
1113
1119
  "",
1114
1120
  "## Hook Health",
@@ -1119,7 +1125,6 @@ function runDiagnostics(sessionID: string): string {
1119
1125
  `- telemetry: ${config.hooks.telemetry ? "enabled" : "disabled"}`,
1120
1126
  `- telemetry counters: created=${state.telemetry.created}, resumed=${state.telemetry.resumed}, completed=${state.telemetry.completed}, failed=${state.telemetry.failed}, retrying=${state.telemetry.retrying}, stopped=${state.telemetry.stopped}, timedOut=${state.telemetry.timedOut}`,
1121
1127
  `- notifications: ${config.notifications.enabled ? "enabled" : "disabled"} (buffer=${state.notifications.length}/${config.notifications.maxItems})`,
1122
- `- tmux queue: ${config.tmux.enabled ? "enabled" : "disabled"} (queued=${queuedTmuxTasks.length})`,
1123
1128
  "",
1124
1129
  "## Provider Routing",
1125
1130
  `- Current mode: ${config.providerMode}`,
@@ -1161,7 +1166,7 @@ function formatSingleTask(record: TaskRecord): string {
1161
1166
  `- Updated: ${record.updatedAt}`,
1162
1167
  `- Background ID: ${record.backgroundTaskID ?? "n/a"}`,
1163
1168
  `- Tmux State: ${record.tmuxState}`,
1164
- `- Tmux Pane ID: ${record.tmuxPaneID ?? "n/a"}`,
1169
+ `- Tmux Window ID: ${record.tmuxWindowID ?? "n/a"}`,
1165
1170
  `- Tmux Session: ${record.tmuxSessionName ?? "n/a"}`,
1166
1171
  `- Blocked By: ${record.blockedBy.length > 0 ? record.blockedBy.join(", ") : "none"}`,
1167
1172
  `- Blocks: ${record.blocks.length > 0 ? record.blocks.join(", ") : "none"}`,
@@ -1195,11 +1200,11 @@ const orchestrationTool = tool({
1195
1200
  if (record.status === "queued" || record.status === "running" || record.status === "retrying") {
1196
1201
  setTaskStatus(record, "stopped", "Stopped by /stop command")
1197
1202
  }
1198
- if (record.tmuxPaneID) {
1199
- closeTaskPane(record, config)
1203
+ if (record.tmuxWindowID) {
1204
+ closeTaskWindow(record, config)
1200
1205
  }
1201
1206
  }
1202
- state.deferredTmuxQueue = []
1207
+ destroyAgentSession(config, context.sessionID)
1203
1208
  return "Loop and queue processing halted for this session. Awaiting further instruction."
1204
1209
  }
1205
1210
 
@@ -1212,17 +1217,21 @@ const orchestrationTool = tool({
1212
1217
  const tasks = getTasksForSession(context.sessionID)
1213
1218
  if (tasks.length === 0) return "No tracked subagent tasks for this session yet."
1214
1219
 
1220
+ const agentSessionName = getAgentSessionName(config, context.sessionID)
1221
+ const agentSessionExists = config.tmux.enabled && isInsideTmux() && hasTmuxBinary()
1222
+ ? runTmux(["has-session", "-t", agentSessionName]).ok
1223
+ : false
1224
+
1215
1225
  const lines = ["# Active Subagent Tasks", "", ...tasks.slice(0, 20).map(formatTaskLine)]
1216
1226
  lines.push(
1217
1227
  "",
1218
1228
  "# Tmux Runtime",
1219
1229
  "",
1220
1230
  `- enabled=${config.tmux.enabled}`,
1221
- `- attached=${tasks.filter((task) => task.tmuxState === "attached").length}`,
1222
- `- queued=${getSessionState(context.sessionID).deferredTmuxQueue.length}`,
1231
+ `- agentSession=${agentSessionExists ? agentSessionName : "none"}`,
1232
+ `- windows=${tasks.filter((task) => task.tmuxState === "attached").length}`,
1223
1233
  `- staleClosed=${tmuxHealth.staleClosed}`,
1224
1234
  `- missingDetected=${tmuxHealth.missing}`,
1225
- `- queuedAttached=${tmuxHealth.queuedAttached}`,
1226
1235
  )
1227
1236
  const notifications = getSessionState(context.sessionID).notifications.slice(0, 5)
1228
1237
  if (notifications.length > 0) {
@@ -1278,10 +1287,7 @@ const WpromoteOrchestrationPlugin: Plugin = async () => {
1278
1287
  record.blocks = deps.blocks
1279
1288
 
1280
1289
  if (config.tmux.enabled) {
1281
- const attach = attachTaskPane(record, config)
1282
- if (!attach.attached && record.tmuxState === "queued") {
1283
- enqueueDeferredTmuxTask(input.sessionID, record.id)
1284
- }
1290
+ attachTaskWindow(record, config)
1285
1291
  }
1286
1292
 
1287
1293
  persistSessionTasks(input.sessionID)
@@ -1317,7 +1323,7 @@ const WpromoteOrchestrationPlugin: Plugin = async () => {
1317
1323
  const taskProvider = resolveTaskProvider(config, record.category)
1318
1324
  record.backgroundTaskID = bgID
1319
1325
  setTaskStatus(record, "running", `Background task launched with ${bgID}, provider=${taskProvider}`)
1320
- } else if (textOutput.toLowerCase().includes("failed")) {
1326
+ } else if (isLikelyTaskFailure(textOutput)) {
1321
1327
  const failure = classifyFailure(textOutput)
1322
1328
  const taskProvider = resolveTaskProvider(config, record.category)
1323
1329
  const shouldFallback =
@@ -1347,9 +1353,9 @@ const WpromoteOrchestrationPlugin: Plugin = async () => {
1347
1353
  }
1348
1354
 
1349
1355
  if (record.status === "completed" || record.status === "failed" || record.status === "stopped") {
1350
- closeTaskPane(record, config)
1356
+ closeTaskWindow(record, config)
1357
+ maybeDestroyIdleAgentSession(config, input.sessionID)
1351
1358
  }
1352
- processDeferredTmuxQueue(input.sessionID, config)
1353
1359
  persistSessionTasks(input.sessionID)
1354
1360
 
1355
1361
  const activityLine = `\n[Subagent activity] ${record.status.toUpperCase()} ${record.agent} \"${record.title}\"`
@@ -1369,14 +1375,13 @@ const WpromoteOrchestrationPlugin: Plugin = async () => {
1369
1375
  if (!sessionID) return
1370
1376
 
1371
1377
  const config = parseConfigFromFile().config
1372
- const state = getSessionState(sessionID)
1373
1378
  for (const record of getTasksForSession(sessionID)) {
1374
- if (record.tmuxPaneID) closeTaskPane(record, config)
1379
+ if (record.tmuxWindowID) closeTaskWindow(record, config)
1375
1380
  if (record.status === "running" || record.status === "retrying" || record.status === "queued") {
1376
1381
  setTaskStatus(record, "stopped", "Parent session deleted")
1377
1382
  }
1378
1383
  }
1379
- state.deferredTmuxQueue = []
1384
+ destroyAgentSession(config, sessionID)
1380
1385
  persistSessionTasks(sessionID)
1381
1386
  },
1382
1387
  }