@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.
- package/README.md +83 -22
- package/command/diagnostics.md +85 -24
- package/dist/index.js +0 -14
- package/instruction/orchestration-runtime.md +6 -8
- package/package.json +1 -1
- package/plugin/wpromote-orchestration.ts +187 -182
- package/skill/skill-creator/LICENSE.txt +202 -0
- package/skill/skill-creator/SKILL.md +485 -0
- package/skill/skill-creator/agents/analyzer.md +274 -0
- package/skill/skill-creator/agents/comparator.md +202 -0
- package/skill/skill-creator/agents/grader.md +223 -0
- package/skill/skill-creator/assets/eval_review.html +146 -0
- package/skill/skill-creator/eval-viewer/generate_review.py +471 -0
- package/skill/skill-creator/eval-viewer/viewer.html +1325 -0
- package/skill/skill-creator/references/schemas.md +430 -0
- package/skill/skill-creator/scripts/__init__.py +0 -0
- package/skill/skill-creator/scripts/aggregate_benchmark.py +401 -0
- package/skill/skill-creator/scripts/generate_report.py +326 -0
- package/skill/skill-creator/scripts/improve_description.py +247 -0
- package/skill/skill-creator/scripts/package_skill.py +136 -0
- package/skill/skill-creator/scripts/quick_validate.py +103 -0
- package/skill/skill-creator/scripts/run_eval.py +310 -0
- package/skill/skill-creator/scripts/run_loop.py +328 -0
- package/skill/skill-creator/scripts/utils.py +47 -0
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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 === "
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
793
|
-
return value.toLowerCase().replace(/[^a-z0-9_-]/g, "-")
|
|
816
|
+
function normalizeTmuxToken(value: string): string {
|
|
817
|
+
return value.toLowerCase().replace(/[^a-z0-9_-]/g, "-")
|
|
794
818
|
}
|
|
795
819
|
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
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
|
-
|
|
803
|
-
|
|
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
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
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
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
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
|
-
|
|
828
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
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
|
|
869
|
-
const
|
|
870
|
-
if (!
|
|
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:
|
|
891
|
+
return { attached: false, reason: newWindow.stderr.trim() || "new-window failed" }
|
|
873
892
|
}
|
|
874
893
|
|
|
875
|
-
const
|
|
876
|
-
if (!
|
|
894
|
+
const windowID = newWindow.stdout.trim().split("\n")[0]?.trim()
|
|
895
|
+
if (!windowID) {
|
|
877
896
|
record.tmuxState = "failed"
|
|
878
|
-
return { attached: false, reason: "
|
|
897
|
+
return { attached: false, reason: "new-window returned empty window id" }
|
|
879
898
|
}
|
|
880
899
|
|
|
881
|
-
|
|
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.
|
|
889
|
-
record.tmuxSessionName = sessionName
|
|
890
|
-
return { attached: true, reason:
|
|
903
|
+
record.tmuxWindowID = windowID
|
|
904
|
+
record.tmuxSessionName = session.sessionName
|
|
905
|
+
return { attached: true, reason: windowID }
|
|
891
906
|
}
|
|
892
907
|
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
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.
|
|
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.
|
|
911
|
-
runTmux(["kill-
|
|
912
|
-
record.
|
|
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
|
|
919
|
-
|
|
920
|
-
|
|
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
|
|
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
|
|
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.
|
|
968
|
-
|
|
950
|
+
if (terminal && record.tmuxWindowID) {
|
|
951
|
+
closeTaskWindow(record, config)
|
|
969
952
|
staleClosed += 1
|
|
970
953
|
continue
|
|
971
954
|
}
|
|
972
|
-
if (record.tmuxState === "attached" && record.
|
|
955
|
+
if (record.tmuxState === "attached" && record.tmuxWindowID && !verifyTaskWindow(record)) {
|
|
973
956
|
record.tmuxState = "failed"
|
|
974
|
-
record.
|
|
957
|
+
record.tmuxWindowID = null
|
|
975
958
|
record.tmuxSessionName = null
|
|
976
959
|
missing += 1
|
|
977
960
|
persistSessionTasks(sessionID)
|
|
978
|
-
pushNotification(sessionID, "warn", `Tmux
|
|
961
|
+
pushNotification(sessionID, "warn", `Tmux window missing for ${record.title}`, record.id)
|
|
979
962
|
}
|
|
980
963
|
}
|
|
981
|
-
|
|
982
|
-
|
|
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
|
|
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}${
|
|
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
|
|
1106
|
-
`-
|
|
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
|
|
1110
|
-
`- Tmux 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
|
|
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.
|
|
1199
|
-
|
|
1203
|
+
if (record.tmuxWindowID) {
|
|
1204
|
+
closeTaskWindow(record, config)
|
|
1200
1205
|
}
|
|
1201
1206
|
}
|
|
1202
|
-
|
|
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
|
-
`-
|
|
1222
|
-
`-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
1384
|
+
destroyAgentSession(config, sessionID)
|
|
1380
1385
|
persistSessionTasks(sessionID)
|
|
1381
1386
|
},
|
|
1382
1387
|
}
|