@wpro-eng/opencode-config 1.4.0 → 1.4.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.
package/README.md CHANGED
@@ -67,10 +67,19 @@ Create `~/.config/opencode/wpromote.json` or `~/.config/opencode/wpromote.jsonc`
67
67
 
68
68
  ### Orchestration Runtime Configuration
69
69
 
70
+ All fields below show their **defaults**. You only need to include fields you want to override.
71
+
70
72
  ```json
71
73
  {
72
74
  "orchestration": {
73
75
  "providerMode": "copilot",
76
+ "features": {
77
+ "delegation": true,
78
+ "diagnostics": true,
79
+ "lookAt": true,
80
+ "continuousLoop": true,
81
+ "selfHealing": true
82
+ },
74
83
  "limits": {
75
84
  "maxConcurrent": 3,
76
85
  "maxRetries": 2,
@@ -88,7 +97,7 @@ Create `~/.config/opencode/wpromote.json` or `~/.config/opencode/wpromote.jsonc`
88
97
  "providerOrder": ["copilot", "native"]
89
98
  },
90
99
  "hooks": {
91
- "disabled": ["activity-feed"],
100
+ "disabled": [],
92
101
  "telemetry": true
93
102
  },
94
103
  "notifications": {
@@ -96,31 +105,25 @@ Create `~/.config/opencode/wpromote.json` or `~/.config/opencode/wpromote.jsonc`
96
105
  "maxItems": 100
97
106
  },
98
107
  "categories": {
99
- "routing": {
100
- "quick": "native"
101
- },
102
- "maxConcurrent": {
103
- "quick": 1
104
- }
108
+ "routing": {},
109
+ "maxConcurrent": {}
105
110
  },
106
111
  "recovery": {
107
112
  "autoResumeOnStart": true
108
113
  },
109
114
  "tmux": {
110
115
  "enabled": false,
111
- "layout": "main-vertical",
112
- "mainPaneSize": 60,
113
- "mainPaneMinWidth": 120,
114
- "agentPaneMinWidth": 40,
115
116
  "sessionPrefix": "wpo"
116
117
  }
117
118
  }
118
119
  }
119
120
  ```
120
121
 
121
- Tmux support is optional and only active when `orchestration.tmux.enabled` is `true` and OpenCode is running inside a tmux session. When enabled, task launches can attach a live pane, queue attachment when pane capacity is full, and auto-clean up panes when tasks complete, fail, stop, or parent sessions end.
122
+ Tmux support is optional and only active when `orchestration.tmux.enabled` is `true` and OpenCode is running inside a tmux session. When enabled, a dedicated tmux session (`{sessionPrefix}-{opencode-session-id}`) is created for each OpenCode session, and each subagent task gets its own window inside that session. Windows are automatically cleaned up when tasks complete, fail, stop, or parent sessions end. Use `tmux switch-client -t wpo-*` to peek at agent work.
123
+
124
+ Feature flags (`orchestration.features`) toggle individual orchestration capabilities. Set any to `false` to disable that feature while keeping the rest active.
122
125
 
123
- Category policies are optional. Use `orchestration.categories.routing` to override provider mode per task category (for example, `quick` -> `native`) and `orchestration.categories.maxConcurrent` to cap active tasks per category.
126
+ Category policies are optional. Use `orchestration.categories.routing` to override provider mode per task category (for example, `"quick": "native"`) and `orchestration.categories.maxConcurrent` to cap active tasks per category (for example, `"quick": 1`).
124
127
 
125
128
  Recovery policies are optional. `orchestration.recovery.autoResumeOnStart` controls whether queued/running/retrying task records from prior sessions are automatically restored into `queued` state on startup.
126
129
 
@@ -246,28 +249,86 @@ EOF
246
249
 
247
250
  ## What's Included
248
251
 
249
- ### Skills (`skill/`)
250
- Reusable skill files that agents can load. Each skill is a directory containing `SKILL.md` with YAML frontmatter.
251
-
252
252
  ### Agents (`agent/`)
253
- Pre-configured agent definitions with model, temperature, and system prompts.
253
+
254
+ Pre-configured agent definitions with model, temperature, and system prompts. There are two **primary** agents (you talk to them directly) and seven **subagents** (they run in the background, delegated by the primary agent).
255
+
256
+ #### Primary Agents
257
+
258
+ | Agent | Model | What it does |
259
+ |-------|-------|--------------|
260
+ | `gandalf` | Claude Opus 4 | The default orchestrator. Classifies your request, breaks it into tasks, delegates to the right subagents in parallel, and assembles the final result. Handles everything from quick fixes to multi-step projects. |
261
+ | `samwise` | Claude Opus 4 | A higher-rigor variant of Gandalf for long-running complex work. Runs a sustained execution loop with tighter milestone verification, aggressive parallelism, and continuous health monitoring. Use when a task needs hours of autonomous work without hand-holding. |
262
+
263
+ #### Subagents
264
+
265
+ | Agent | Model | When Gandalf calls it | What it does |
266
+ |-------|-------|-----------------------|--------------|
267
+ | `legolas` | Claude Sonnet 4.6 | "Where is X in the codebase?" | Searches the repo fast. Uses LSP, grep, glob, and git to find files, symbols, call paths, and code patterns. Returns structured results with absolute paths and a direct answer, not just a file list. Read-only by default. |
268
+ | `radagast` | GPT-5.2 | "How does library X work?" | Researches external docs, OSS repos, and the web. Classifies the question (conceptual, implementation, history, or comprehensive), fetches official sources, and returns evidence-backed recommendations with links. |
269
+ | `treebeard` | GPT-5.2 | "Is this plan solid?" | Reviews plans for ambiguity, risk, and executability before implementation starts. Surfaces hidden assumptions, produces concrete acceptance criteria, and gives a clear OKAY or REJECT verdict with at most 3 blocking issues. |
270
+ | `celebrimbor` | GPT-5.2 Codex | "Build this end-to-end." | The workhorse implementation agent. Operates like a senior staff engineer: explores context, plans edits, executes changes, runs tests, and iterates until done. Does not stop at partial progress or ask permission for normal engineering work. |
271
+ | `elrond` | GPT-5.2 | "What's the right architecture?" | Read-only strategic advisor. Analyzes codebases, evaluates tradeoffs, and returns a bottom-line recommendation with an action plan and effort estimate. Biases toward simplicity and existing patterns over speculative complexity. |
272
+ | `galadriel` | Gemini 3.1 Pro | "What's in this image/PDF?" | Multimodal analysis specialist. Handles PDFs, images, diagrams, and charts that other agents cannot process as plain text. Extracts targeted information and returns concise findings with confidence notes. |
273
+ | `aragorn` | GPT-5.2 Codex | "Write tests for this." | Strict TDD specialist following Red-Green-Refactor. Writes a failing test first, makes it pass with minimal code, then refactors under green. Produces a test plan, TDD log, execution results, and a quality scorecard. Never writes production code without a failing test. |
254
274
 
255
275
  ### Commands (`command/`)
276
+
256
277
  Slash commands available to all team members.
257
278
 
279
+ | Command | Description |
280
+ |---------|-------------|
281
+ | `/continue` | Enable continuous deepwork loop for current session |
282
+ | `/stop` | Stop continuous loop and halt queued orchestration work |
283
+ | `/tasks` | List active and recent subagent orchestration tasks |
284
+ | `/task` | Show details for one subagent orchestration task |
285
+ | `/diagnostics` | Run orchestration diagnostics with live delegation probes |
286
+ | `/doctor` | Run deep orchestration doctor checks with actionable remediation |
287
+ | `/look-at` | Normalize multimodal request and execute look_at analysis |
288
+ | `/test-orchestration` | Run a parallel subagent delegation torture test audit |
289
+ | `/wpromote-status` | Show status for wpromote team config |
290
+ | `/wpromote-list` | List all team assets from wpromote config |
291
+ | `/wpromote-cleanup` | Remove stale cache files, broken symlinks, and unused plugins |
292
+ | `/example` | Example command demonstrating the expected format |
293
+
294
+ ### Skills (`skill/`)
295
+
296
+ Reusable skill files that agents can load. Each skill is a directory containing `SKILL.md` with YAML frontmatter.
297
+
298
+ | Skill | Description |
299
+ |-------|-------------|
300
+ | `orchestration-core` | Continuous loop orchestration, delegation visibility, and diagnostics workflow |
301
+ | `readme-editor` | Craft professional README.md files for GitHub open source projects |
302
+ | `example` | Example skill demonstrating the expected format |
303
+
258
304
  ### Plugins (`plugin/`)
305
+
259
306
  OpenCode hook plugins for team-wide automation.
260
307
 
308
+ | Plugin | Description |
309
+ |--------|-------------|
310
+ | `wpromote-orchestration` | Orchestration runtime: task tracking, tmux sessions, provider fallback, recovery |
311
+ | `wpromote-look-at` | Multimodal analysis wrapper for the look_at workflow |
312
+
261
313
  ### Instructions (`instruction/`)
262
- Markdown files automatically appended to agent context (like AGENTS.md).
314
+
315
+ Markdown files automatically appended to agent context (registered in `manifest.json`).
316
+
317
+ | Instruction | Description |
318
+ |-------------|-------------|
319
+ | `getting-started` | Quick start guide for Wpromote OpenCode |
320
+ | `team-conventions` | Code style, git workflow, and contribution conventions |
321
+ | `orchestration-runtime` | Orchestration controls, delegation discipline, tmux runtime |
263
322
 
264
323
  ### MCPs (`mcp/`)
324
+
265
325
  Model Context Protocol server configurations.
266
326
 
267
- Current team MCP configs include:
268
- - `chrome-devtools` (`https://www.npmjs.com/package/chrome-devtools-mcp` via `npx`)
269
- - `context7` (`https://mcp.context7.com/mcp`)
270
- - `exa` (`https://mcp.exa.ai/mcp`)
327
+ | MCP | Type | Endpoint |
328
+ |-----|------|----------|
329
+ | `chrome-devtools` | local | `npx chrome-devtools-mcp@latest` |
330
+ | `context7` | remote | `https://mcp.context7.com/mcp` |
331
+ | `exa` | remote | `https://mcp.exa.ai/mcp` |
271
332
 
272
333
  ## Development
273
334
 
@@ -1,38 +1,99 @@
1
1
  ---
2
- description: Run orchestration diagnostics with explicit delegation and task visibility checks
2
+ description: Run orchestration diagnostics with live delegation probes and task visibility checks
3
3
  ---
4
- Run full diagnostics for orchestration configuration and runtime health.
4
+ Run full diagnostics for orchestration configuration, runtime health, and live delegation verification.
5
5
 
6
6
  Instructions:
7
+
8
+ ## Phase 1: Tool & Config Health
9
+
7
10
  1. Check tool availability first:
8
11
  - If `wpromote_orchestration` is unavailable, report `FAIL: Tool Unavailable` and stop.
9
12
  - Include short remediation steps (install/sync/restart).
10
13
  2. Call `wpromote_orchestration` with `action="diagnostics"`.
11
- 3. Call `wpromote_orchestration` with `action="tasks"`.
12
- 4. Enumerate enabled assets:
14
+ 3. Call `wpromote_orchestration` with `action="tasks"` to capture pre-probe baseline.
15
+
16
+ ## Phase 2: Live Delegation Probe
17
+
18
+ 4. Spawn 4 subagent probes in parallel using the `task` tool. Each probe must be a trivial, fast, read-only task that proves the delegation pipeline works end-to-end. Do NOT run destructive or write operations.
19
+
20
+ **Probe A — legolas (codebase exploration)**:
21
+ - Task: "List the top-level directories and count of files in this repo. Return the directory listing."
22
+ - Expected: completes with a directory listing.
23
+
24
+ **Probe B — radagast (external research)**:
25
+ - Task: "What license does the tmux terminal multiplexer use? Search the web and return the license name (e.g. MIT, BSD, ISC, GPL)."
26
+ - Expected: completes with a license name (ISC).
27
+
28
+ **Probe C — treebeard (planning)**:
29
+ - Task: "Review this single-sentence plan and identify one risk: 'We will migrate all configs to YAML next sprint.' Return your risk assessment in 2-3 sentences."
30
+ - Expected: completes with a short risk assessment.
31
+
32
+ **Probe D — general (general-purpose)**:
33
+ - Task: "Read the file manifest.json in the repo root and return its contents."
34
+ - Expected: completes with JSON content.
35
+
36
+ 5. Wait for all 4 probes to complete (or timeout after 120 seconds each).
37
+
38
+ 6. For each probe, record:
39
+ - task_id
40
+ - subagent type
41
+ - status (completed / failed / timed out)
42
+ - round-trip wall time (note when dispatched vs when result returned)
43
+ - whether the result was non-empty and plausible
44
+
45
+ ## Phase 3: Post-Probe Verification
46
+
47
+ 7. Call `wpromote_orchestration` with `action="tasks"` again to verify:
48
+ - All 4 probe tasks appear in the task list.
49
+ - Task statuses are correctly tracked.
50
+ - If tmux is enabled, note whether windows were attached/detached during probes.
51
+
52
+ ## Phase 4: Asset Enumeration
53
+
54
+ 8. Enumerate enabled assets:
13
55
  - `tools`: list enabled tools, each with a short description.
14
56
  - `commands`: use `glob("command/*.md")`; report `/name` + frontmatter description.
15
57
  - `agents`: use `glob("agent/*.md")`; report name + frontmatter description.
16
58
  - `subagents`: report available Task subagent types + short purpose.
17
59
  - `skills`: report available skills + each skill description.
18
60
  - `instructions`: read `manifest.json`; include entries and currently loaded AGENTS/instructions visible in session context.
19
- 5. Present diagnostics PASS/FAIL first.
20
- 6. Add **Delegation & Task Health** with:
21
- - subagent delegation availability
22
- - total tracked tasks (active + recent)
23
- - active task count
24
- - recent completion/failure/cancel counts (if available)
25
- - whether task visibility looks healthy
26
- - tmux pane health (attached/queued/missing counts if tmux is enabled)
27
- 7. If tasks exist, list up to 5 recent tasks (id, status, short title).
28
- 8. Always include these sections in this exact order (even when empty):
29
- - PASS/FAIL Findings
30
- - Runtime Health
31
- - Delegation & Task Health
32
- - Enabled Tools
33
- - Enabled Commands
34
- - Enabled Agents
35
- - Enabled Subagents
36
- - Enabled Skills
37
- - Enabled Instructions
38
- 9. If any checks fail, include short remediation steps.
61
+
62
+ ## Phase 5: Report
63
+
64
+ 9. Present the report with these sections in this exact order (even when empty):
65
+
66
+ **PASS/FAIL Findings**
67
+ - All config/runtime checks from Phase 1.
68
+ - Each probe result as its own PASS/FAIL line.
69
+ - Overall verdict.
70
+
71
+ **Runtime Health**
72
+ - Provider mode, fallback chain, loop mode, max concurrent, hooks, recovery state.
73
+
74
+ **Live Delegation Probe Results**
75
+ - Table with columns: Probe, Subagent, Status, Round-Trip, Result Summary.
76
+ - Overall delegation verdict (all passed / partial / all failed).
77
+ - If any probe failed, include the error or timeout details.
78
+
79
+ **Delegation & Task Health**
80
+ - Subagent delegation availability (proven by probes, not just config).
81
+ - Total tracked tasks (active + recent), including the 4 probes.
82
+ - Active task count.
83
+ - Recent completion/failure/cancel counts.
84
+ - Whether task visibility looks healthy (do the 4 probes show up in `/tasks`?).
85
+ - Tmux window health (attached/missing counts, agent session status if tmux is enabled).
86
+
87
+ **Enabled Tools**
88
+
89
+ **Enabled Commands**
90
+
91
+ **Enabled Agents**
92
+
93
+ **Enabled Subagents**
94
+
95
+ **Enabled Skills**
96
+
97
+ **Enabled Instructions**
98
+
99
+ 10. If any checks or probes fail, include short remediation steps at the end of each relevant section.
package/dist/index.js CHANGED
@@ -8389,16 +8389,6 @@ var UserConfigSchema = exports_external.object({
8389
8389
  }).default({}),
8390
8390
  tmux: exports_external.object({
8391
8391
  enabled: exports_external.boolean().default(false),
8392
- layout: exports_external.enum([
8393
- "main-horizontal",
8394
- "main-vertical",
8395
- "tiled",
8396
- "even-horizontal",
8397
- "even-vertical"
8398
- ]).default("main-vertical"),
8399
- mainPaneSize: exports_external.number().int().min(20).max(80).default(60),
8400
- mainPaneMinWidth: exports_external.number().int().min(40).max(400).default(120),
8401
- agentPaneMinWidth: exports_external.number().int().min(20).max(200).default(40),
8402
8392
  sessionPrefix: exports_external.string().min(2).max(20).regex(/^[a-zA-Z0-9_-]+$/).default("wpo")
8403
8393
  }).default({})
8404
8394
  }).default({})
@@ -8449,10 +8439,6 @@ var DEFAULT_CONFIG = {
8449
8439
  },
8450
8440
  tmux: {
8451
8441
  enabled: false,
8452
- layout: "main-vertical",
8453
- mainPaneSize: 60,
8454
- mainPaneMinWidth: 120,
8455
- agentPaneMinWidth: 40,
8456
8442
  sessionPrefix: "wpo"
8457
8443
  }
8458
8444
  }
@@ -57,23 +57,21 @@ When using delegated execution:
57
57
 
58
58
  ## Tmux Runtime
59
59
 
60
- - Enable tmux pane management with `orchestration.tmux.enabled`.
61
- - Recommended defaults:
60
+ - Enable tmux session management with `orchestration.tmux.enabled`.
61
+ - When enabled, a dedicated tmux session (`{sessionPrefix}-{opencode-session-id}`) is created for each OpenCode session.
62
+ - Each subagent task gets its own window inside that session, keeping your working window clean.
63
+ - Use `tmux switch-client -t wpo-*` to peek at agent work.
62
64
 
63
65
  ```json
64
66
  {
65
67
  "orchestration": {
66
68
  "tmux": {
67
69
  "enabled": true,
68
- "layout": "main-vertical",
69
- "mainPaneSize": 60,
70
- "mainPaneMinWidth": 120,
71
- "agentPaneMinWidth": 40,
72
70
  "sessionPrefix": "wpo"
73
71
  }
74
72
  }
75
73
  }
76
74
  ```
77
75
 
78
- - `/tasks` reports tmux pane attachment state and deferred queue health.
79
- - `/diagnostics` checks tmux wiring, binary availability, queue health, and pane reconciliation.
76
+ - `/tasks` reports the agent session name and active window count.
77
+ - `/diagnostics` checks tmux wiring, binary availability, and window reconciliation.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wpro-eng/opencode-config",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "Wpromote OpenCode plugin to sync team config assets on startup",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
  }