@wpro-eng/opencode-config 1.0.2 → 1.2.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 +9 -49
- package/agent/aragorn.md +304 -0
- package/agent/celebrimbor.md +52 -0
- package/agent/elrond.md +88 -0
- package/agent/galadriel.md +28 -0
- package/agent/gandalf.md +51 -0
- package/agent/legolas.md +64 -0
- package/agent/radagast.md +51 -0
- package/agent/samwise.md +42 -0
- package/agent/treebeard.md +39 -0
- package/command/continue.md +9 -0
- package/command/diagnostics.md +38 -0
- package/command/doctor.md +9 -0
- package/command/example.md +9 -0
- package/command/look-at.md +11 -0
- package/command/stop.md +9 -0
- package/command/task.md +11 -0
- package/command/tasks.md +9 -0
- package/command/test-orchestration.md +42 -0
- package/command/wpromote-list.md +49 -0
- package/command/wpromote-status.md +23 -0
- package/dist/index.js +72 -408
- package/instruction/getting-started.md +24 -0
- package/instruction/orchestration-runtime.md +79 -0
- package/instruction/team-conventions.md +17 -0
- package/manifest.json +8 -0
- package/mcp/chrome-devtools/mcp.json +4 -0
- package/mcp/context7/mcp.json +4 -0
- package/mcp/exa/mcp.json +4 -0
- package/package.json +10 -5
- package/plugin/wpromote-look-at.ts +33 -0
- package/plugin/wpromote-orchestration.ts +1385 -0
- package/skill/example/SKILL.md +18 -0
- package/skill/orchestration-core/SKILL.md +29 -0
- package/skill/readme-editor/SKILL.md +529 -0
|
@@ -0,0 +1,1385 @@
|
|
|
1
|
+
import { tool, type Plugin } from "@opencode-ai/plugin"
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { spawnSync } from "node:child_process"
|
|
4
|
+
import { join } from "node:path"
|
|
5
|
+
import { homedir } from "node:os"
|
|
6
|
+
import { parse as parseJsonc } from "jsonc-parser"
|
|
7
|
+
|
|
8
|
+
type ProviderMode = "copilot" | "native"
|
|
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"
|
|
12
|
+
|
|
13
|
+
interface OrchestrationConfig {
|
|
14
|
+
providerMode: ProviderMode
|
|
15
|
+
features: {
|
|
16
|
+
delegation: boolean
|
|
17
|
+
diagnostics: boolean
|
|
18
|
+
lookAt: boolean
|
|
19
|
+
continuousLoop: boolean
|
|
20
|
+
selfHealing: boolean
|
|
21
|
+
}
|
|
22
|
+
limits: {
|
|
23
|
+
maxConcurrent: number
|
|
24
|
+
maxRetries: number
|
|
25
|
+
maxIterations: number
|
|
26
|
+
retryBackoffMs: number
|
|
27
|
+
taskTimeoutMs: number
|
|
28
|
+
}
|
|
29
|
+
runtimeFallback: {
|
|
30
|
+
enabled: boolean
|
|
31
|
+
retryOnErrors: number[]
|
|
32
|
+
maxFallbackAttempts: number
|
|
33
|
+
cooldownSeconds: number
|
|
34
|
+
timeoutSeconds: number
|
|
35
|
+
notifyOnFallback: boolean
|
|
36
|
+
providerOrder: ProviderMode[]
|
|
37
|
+
}
|
|
38
|
+
hooks: {
|
|
39
|
+
disabled: string[]
|
|
40
|
+
telemetry: boolean
|
|
41
|
+
}
|
|
42
|
+
notifications: {
|
|
43
|
+
enabled: boolean
|
|
44
|
+
maxItems: number
|
|
45
|
+
}
|
|
46
|
+
categories: {
|
|
47
|
+
routing: Record<string, ProviderMode>
|
|
48
|
+
maxConcurrent: Record<string, number>
|
|
49
|
+
}
|
|
50
|
+
recovery: {
|
|
51
|
+
autoResumeOnStart: boolean
|
|
52
|
+
}
|
|
53
|
+
tmux: {
|
|
54
|
+
enabled: boolean
|
|
55
|
+
layout: TmuxLayout
|
|
56
|
+
mainPaneSize: number
|
|
57
|
+
mainPaneMinWidth: number
|
|
58
|
+
agentPaneMinWidth: number
|
|
59
|
+
sessionPrefix: string
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface ConfigLoadResult {
|
|
64
|
+
source: string
|
|
65
|
+
config: OrchestrationConfig
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface TaskRecord {
|
|
69
|
+
id: string
|
|
70
|
+
sessionID: string
|
|
71
|
+
title: string
|
|
72
|
+
agent: string
|
|
73
|
+
category: string | null
|
|
74
|
+
status: TaskStatus
|
|
75
|
+
createdAt: string
|
|
76
|
+
updatedAt: string
|
|
77
|
+
backgroundTaskID: string | null
|
|
78
|
+
retries: number
|
|
79
|
+
fallbackAttempts: number
|
|
80
|
+
details: string
|
|
81
|
+
fallbackReason: string | null
|
|
82
|
+
blockedBy: string[]
|
|
83
|
+
blocks: string[]
|
|
84
|
+
tmuxPaneID: string | null
|
|
85
|
+
tmuxSessionName: string | null
|
|
86
|
+
tmuxState: TmuxState
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface SessionState {
|
|
90
|
+
loopEnabled: boolean
|
|
91
|
+
tmuxMissingWarned: boolean
|
|
92
|
+
telemetry: {
|
|
93
|
+
created: number
|
|
94
|
+
resumed: number
|
|
95
|
+
completed: number
|
|
96
|
+
failed: number
|
|
97
|
+
retrying: number
|
|
98
|
+
stopped: number
|
|
99
|
+
timedOut: number
|
|
100
|
+
}
|
|
101
|
+
notifications: {
|
|
102
|
+
ts: string
|
|
103
|
+
level: "info" | "warn" | "error"
|
|
104
|
+
message: string
|
|
105
|
+
taskID: string
|
|
106
|
+
}[]
|
|
107
|
+
deferredTmuxQueue: string[]
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const USER_HOME = process.env.HOME && process.env.HOME.trim().length > 0 ? process.env.HOME : homedir()
|
|
111
|
+
const TASK_STORAGE_DIR = join(USER_HOME, ".cache", "opencode", "wpromote-config", "orchestration-tasks")
|
|
112
|
+
|
|
113
|
+
const DEFAULT_CONFIG: OrchestrationConfig = {
|
|
114
|
+
providerMode: "copilot",
|
|
115
|
+
features: {
|
|
116
|
+
delegation: true,
|
|
117
|
+
diagnostics: true,
|
|
118
|
+
lookAt: true,
|
|
119
|
+
continuousLoop: true,
|
|
120
|
+
selfHealing: true,
|
|
121
|
+
},
|
|
122
|
+
limits: {
|
|
123
|
+
maxConcurrent: 3,
|
|
124
|
+
maxRetries: 2,
|
|
125
|
+
maxIterations: 25,
|
|
126
|
+
retryBackoffMs: 1500,
|
|
127
|
+
taskTimeoutMs: 120000,
|
|
128
|
+
},
|
|
129
|
+
runtimeFallback: {
|
|
130
|
+
enabled: true,
|
|
131
|
+
retryOnErrors: [400, 401, 403, 408, 429, 500, 502, 503, 504, 529],
|
|
132
|
+
maxFallbackAttempts: 2,
|
|
133
|
+
cooldownSeconds: 30,
|
|
134
|
+
timeoutSeconds: 30,
|
|
135
|
+
notifyOnFallback: true,
|
|
136
|
+
providerOrder: ["copilot", "native"],
|
|
137
|
+
},
|
|
138
|
+
hooks: {
|
|
139
|
+
disabled: [],
|
|
140
|
+
telemetry: true,
|
|
141
|
+
},
|
|
142
|
+
notifications: {
|
|
143
|
+
enabled: true,
|
|
144
|
+
maxItems: 100,
|
|
145
|
+
},
|
|
146
|
+
categories: {
|
|
147
|
+
routing: {},
|
|
148
|
+
maxConcurrent: {},
|
|
149
|
+
},
|
|
150
|
+
recovery: {
|
|
151
|
+
autoResumeOnStart: true,
|
|
152
|
+
},
|
|
153
|
+
tmux: {
|
|
154
|
+
enabled: false,
|
|
155
|
+
layout: "main-vertical",
|
|
156
|
+
mainPaneSize: 60,
|
|
157
|
+
mainPaneMinWidth: 120,
|
|
158
|
+
agentPaneMinWidth: 40,
|
|
159
|
+
sessionPrefix: "wpo",
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const sessionStates = new Map<string, SessionState>()
|
|
164
|
+
const taskRecords = new Map<string, TaskRecord>()
|
|
165
|
+
const taskByCallID = new Map<string, string>()
|
|
166
|
+
|
|
167
|
+
function getSessionState(sessionID: string): SessionState {
|
|
168
|
+
const existing = sessionStates.get(sessionID)
|
|
169
|
+
if (existing) return existing
|
|
170
|
+
|
|
171
|
+
const state: SessionState = {
|
|
172
|
+
loopEnabled: false,
|
|
173
|
+
tmuxMissingWarned: false,
|
|
174
|
+
telemetry: {
|
|
175
|
+
created: 0,
|
|
176
|
+
resumed: 0,
|
|
177
|
+
completed: 0,
|
|
178
|
+
failed: 0,
|
|
179
|
+
retrying: 0,
|
|
180
|
+
stopped: 0,
|
|
181
|
+
timedOut: 0,
|
|
182
|
+
},
|
|
183
|
+
notifications: [],
|
|
184
|
+
deferredTmuxQueue: [],
|
|
185
|
+
}
|
|
186
|
+
sessionStates.set(sessionID, state)
|
|
187
|
+
return state
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function nowIso(): string {
|
|
191
|
+
return new Date().toISOString()
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function isoToMs(value: string): number {
|
|
195
|
+
const stamp = Date.parse(value)
|
|
196
|
+
return Number.isNaN(stamp) ? 0 : stamp
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
200
|
+
return typeof value === "object" && value !== null
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function asString(value: unknown): string | undefined {
|
|
204
|
+
if (typeof value !== "string") return undefined
|
|
205
|
+
const trimmed = value.trim()
|
|
206
|
+
return trimmed.length > 0 ? trimmed : undefined
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function parseProviderMode(value: unknown): ProviderMode {
|
|
210
|
+
return value === "native" ? "native" : "copilot"
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function parseProviderOrder(value: unknown): ProviderMode[] {
|
|
214
|
+
if (!Array.isArray(value)) return DEFAULT_CONFIG.runtimeFallback.providerOrder
|
|
215
|
+
const parsed: ProviderMode[] = []
|
|
216
|
+
for (const item of value) {
|
|
217
|
+
if (item === "copilot" || item === "native") {
|
|
218
|
+
if (!parsed.includes(item)) parsed.push(item)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return parsed.length > 0 ? parsed : DEFAULT_CONFIG.runtimeFallback.providerOrder
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function parseRetryOnErrors(value: unknown): number[] {
|
|
225
|
+
if (!Array.isArray(value)) return DEFAULT_CONFIG.runtimeFallback.retryOnErrors
|
|
226
|
+
const parsed = value
|
|
227
|
+
.filter((item): item is number => typeof item === "number" && Number.isInteger(item) && item >= 100 && item <= 599)
|
|
228
|
+
.sort((a, b) => a - b)
|
|
229
|
+
return parsed.length > 0 ? parsed : DEFAULT_CONFIG.runtimeFallback.retryOnErrors
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function parseDisabledHooks(value: unknown): string[] {
|
|
233
|
+
if (!Array.isArray(value)) return []
|
|
234
|
+
return value.filter((item): item is string => typeof item === "string" && item.trim().length > 0)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function normalizeCategoryName(value: string): string {
|
|
238
|
+
return value.trim().toLowerCase()
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function parseCategoryRouting(value: unknown): Record<string, ProviderMode> {
|
|
242
|
+
if (!isRecord(value)) return {}
|
|
243
|
+
const parsed: Record<string, ProviderMode> = {}
|
|
244
|
+
for (const [rawKey, rawValue] of Object.entries(value)) {
|
|
245
|
+
const key = normalizeCategoryName(rawKey)
|
|
246
|
+
if (!key) continue
|
|
247
|
+
if (rawValue === "copilot" || rawValue === "native") {
|
|
248
|
+
parsed[key] = rawValue
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return parsed
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function parseCategoryConcurrency(value: unknown): Record<string, number> {
|
|
255
|
+
if (!isRecord(value)) return {}
|
|
256
|
+
const parsed: Record<string, number> = {}
|
|
257
|
+
for (const [rawKey, rawValue] of Object.entries(value)) {
|
|
258
|
+
const key = normalizeCategoryName(rawKey)
|
|
259
|
+
if (!key) continue
|
|
260
|
+
if (typeof rawValue !== "number" || !Number.isInteger(rawValue)) continue
|
|
261
|
+
if (rawValue < 1 || rawValue > 10) continue
|
|
262
|
+
parsed[key] = rawValue
|
|
263
|
+
}
|
|
264
|
+
return parsed
|
|
265
|
+
}
|
|
266
|
+
|
|
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
|
+
function parseSessionPrefix(value: unknown): string {
|
|
281
|
+
if (typeof value !== "string") return DEFAULT_CONFIG.tmux.sessionPrefix
|
|
282
|
+
const trimmed = value.trim()
|
|
283
|
+
if (!/^[a-zA-Z0-9_-]{2,20}$/.test(trimmed)) return DEFAULT_CONFIG.tmux.sessionPrefix
|
|
284
|
+
return trimmed
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function parseBoundedInt(value: unknown, fallback: number, min: number, max: number): number {
|
|
288
|
+
if (typeof value !== "number" || !Number.isInteger(value)) return fallback
|
|
289
|
+
return Math.max(min, Math.min(max, value))
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function isHookEnabled(config: OrchestrationConfig, hookName: string): boolean {
|
|
293
|
+
return !config.hooks.disabled.includes(hookName)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function getConfigPaths(): string[] {
|
|
297
|
+
const filenames = ["wpromote.json", "wpromote.jsonc"]
|
|
298
|
+
return [
|
|
299
|
+
...filenames.map((filename) => join(process.cwd(), ".opencode", filename)),
|
|
300
|
+
...filenames.map((filename) => join(USER_HOME, ".config", "opencode", filename)),
|
|
301
|
+
]
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function parseConfigFromFile(): ConfigLoadResult {
|
|
305
|
+
const configPaths = getConfigPaths()
|
|
306
|
+
|
|
307
|
+
for (const configPath of configPaths) {
|
|
308
|
+
if (!existsSync(configPath)) continue
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
const parseErrors: Parameters<typeof parseJsonc>[1] = []
|
|
312
|
+
const raw = parseJsonc(readFileSync(configPath, "utf-8"), parseErrors, {
|
|
313
|
+
disallowComments: false,
|
|
314
|
+
allowTrailingComma: true,
|
|
315
|
+
}) as unknown
|
|
316
|
+
if (parseErrors.length > 0) continue
|
|
317
|
+
if (!isRecord(raw)) continue
|
|
318
|
+
const orchestration = raw.orchestration
|
|
319
|
+
if (!isRecord(orchestration)) continue
|
|
320
|
+
|
|
321
|
+
const features = isRecord(orchestration.features) ? orchestration.features : {}
|
|
322
|
+
const limits = isRecord(orchestration.limits) ? orchestration.limits : {}
|
|
323
|
+
const runtimeFallback = isRecord(orchestration.runtimeFallback) ? orchestration.runtimeFallback : {}
|
|
324
|
+
const hooks = isRecord(orchestration.hooks) ? orchestration.hooks : {}
|
|
325
|
+
const notifications = isRecord(orchestration.notifications) ? orchestration.notifications : {}
|
|
326
|
+
const categories = isRecord(orchestration.categories) ? orchestration.categories : {}
|
|
327
|
+
const recovery = isRecord(orchestration.recovery) ? orchestration.recovery : {}
|
|
328
|
+
const tmux = isRecord(orchestration.tmux) ? orchestration.tmux : {}
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
source: configPath,
|
|
332
|
+
config: {
|
|
333
|
+
providerMode: parseProviderMode(orchestration.providerMode),
|
|
334
|
+
features: {
|
|
335
|
+
delegation: features.delegation !== false,
|
|
336
|
+
diagnostics: features.diagnostics !== false,
|
|
337
|
+
lookAt: features.lookAt !== false,
|
|
338
|
+
continuousLoop: features.continuousLoop !== false,
|
|
339
|
+
selfHealing: features.selfHealing !== false,
|
|
340
|
+
},
|
|
341
|
+
limits: {
|
|
342
|
+
maxConcurrent: typeof limits.maxConcurrent === "number" ? limits.maxConcurrent : DEFAULT_CONFIG.limits.maxConcurrent,
|
|
343
|
+
maxRetries: typeof limits.maxRetries === "number" ? limits.maxRetries : DEFAULT_CONFIG.limits.maxRetries,
|
|
344
|
+
maxIterations: typeof limits.maxIterations === "number" ? limits.maxIterations : DEFAULT_CONFIG.limits.maxIterations,
|
|
345
|
+
retryBackoffMs: typeof limits.retryBackoffMs === "number" ? limits.retryBackoffMs : DEFAULT_CONFIG.limits.retryBackoffMs,
|
|
346
|
+
taskTimeoutMs: typeof limits.taskTimeoutMs === "number" ? limits.taskTimeoutMs : DEFAULT_CONFIG.limits.taskTimeoutMs,
|
|
347
|
+
},
|
|
348
|
+
runtimeFallback: {
|
|
349
|
+
enabled: runtimeFallback.enabled !== false,
|
|
350
|
+
retryOnErrors: parseRetryOnErrors(runtimeFallback.retryOnErrors),
|
|
351
|
+
maxFallbackAttempts:
|
|
352
|
+
typeof runtimeFallback.maxFallbackAttempts === "number"
|
|
353
|
+
? runtimeFallback.maxFallbackAttempts
|
|
354
|
+
: DEFAULT_CONFIG.runtimeFallback.maxFallbackAttempts,
|
|
355
|
+
cooldownSeconds:
|
|
356
|
+
typeof runtimeFallback.cooldownSeconds === "number"
|
|
357
|
+
? runtimeFallback.cooldownSeconds
|
|
358
|
+
: DEFAULT_CONFIG.runtimeFallback.cooldownSeconds,
|
|
359
|
+
timeoutSeconds:
|
|
360
|
+
typeof runtimeFallback.timeoutSeconds === "number"
|
|
361
|
+
? runtimeFallback.timeoutSeconds
|
|
362
|
+
: DEFAULT_CONFIG.runtimeFallback.timeoutSeconds,
|
|
363
|
+
notifyOnFallback: runtimeFallback.notifyOnFallback !== false,
|
|
364
|
+
providerOrder: parseProviderOrder(runtimeFallback.providerOrder),
|
|
365
|
+
},
|
|
366
|
+
hooks: {
|
|
367
|
+
disabled: parseDisabledHooks(hooks.disabled),
|
|
368
|
+
telemetry: hooks.telemetry !== false,
|
|
369
|
+
},
|
|
370
|
+
notifications: {
|
|
371
|
+
enabled: notifications.enabled !== false,
|
|
372
|
+
maxItems:
|
|
373
|
+
typeof notifications.maxItems === "number"
|
|
374
|
+
? notifications.maxItems
|
|
375
|
+
: DEFAULT_CONFIG.notifications.maxItems,
|
|
376
|
+
},
|
|
377
|
+
categories: {
|
|
378
|
+
routing: parseCategoryRouting(categories.routing),
|
|
379
|
+
maxConcurrent: parseCategoryConcurrency(categories.maxConcurrent),
|
|
380
|
+
},
|
|
381
|
+
recovery: {
|
|
382
|
+
autoResumeOnStart: recovery.autoResumeOnStart !== false,
|
|
383
|
+
},
|
|
384
|
+
tmux: {
|
|
385
|
+
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
|
+
sessionPrefix: parseSessionPrefix(tmux.sessionPrefix),
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
}
|
|
399
|
+
} catch {
|
|
400
|
+
continue
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return {
|
|
405
|
+
source: "defaults",
|
|
406
|
+
config: DEFAULT_CONFIG,
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function parseTaskInfo(args: unknown): { title: string; agent: string; category: string | null; details: string } {
|
|
411
|
+
if (!isRecord(args)) {
|
|
412
|
+
return {
|
|
413
|
+
title: "untitled-task",
|
|
414
|
+
agent: "category:unspecified",
|
|
415
|
+
category: null,
|
|
416
|
+
details: "No task arguments available",
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const description = asString(args.description) ?? "untitled-task"
|
|
421
|
+
const category = asString(args.category)
|
|
422
|
+
const normalizedCategory = category ? normalizeCategoryName(category) : null
|
|
423
|
+
const subagentType = asString(args.subagent_type)
|
|
424
|
+
const agent = normalizedCategory ? `category:${normalizedCategory}` : subagentType ? `agent:${subagentType}` : "category:unspecified"
|
|
425
|
+
|
|
426
|
+
const details = [
|
|
427
|
+
`description=${description}`,
|
|
428
|
+
normalizedCategory ? `category=${normalizedCategory}` : null,
|
|
429
|
+
subagentType ? `subagent_type=${subagentType}` : null,
|
|
430
|
+
]
|
|
431
|
+
.filter((value): value is string => value !== null)
|
|
432
|
+
.join(" ")
|
|
433
|
+
|
|
434
|
+
return {
|
|
435
|
+
title: description.length > 48 ? `${description.slice(0, 45)}...` : description,
|
|
436
|
+
agent,
|
|
437
|
+
category: normalizedCategory,
|
|
438
|
+
details,
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function parseTaskDependencies(args: unknown): { blockedBy: string[]; blocks: string[] } {
|
|
443
|
+
if (!isRecord(args)) return { blockedBy: [], blocks: [] }
|
|
444
|
+
const blockedBy = Array.isArray(args.blockedBy)
|
|
445
|
+
? args.blockedBy.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
|
446
|
+
: []
|
|
447
|
+
const blocks = Array.isArray(args.blocks)
|
|
448
|
+
? args.blocks.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
|
449
|
+
: []
|
|
450
|
+
return { blockedBy, blocks }
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function getTasksForSession(sessionID: string): TaskRecord[] {
|
|
454
|
+
hydrateSessionTasks(sessionID)
|
|
455
|
+
const result: TaskRecord[] = []
|
|
456
|
+
for (const record of taskRecords.values()) {
|
|
457
|
+
if (record.sessionID === sessionID) result.push(record)
|
|
458
|
+
}
|
|
459
|
+
return result.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function getSessionTaskFile(sessionID: string): string {
|
|
463
|
+
return join(TASK_STORAGE_DIR, `${sessionID}.json`)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function extractCategoryFromAgent(agent: string): string | null {
|
|
467
|
+
if (!agent.startsWith("category:")) return null
|
|
468
|
+
const raw = agent.slice("category:".length).trim()
|
|
469
|
+
if (!raw || raw === "unspecified") return null
|
|
470
|
+
return normalizeCategoryName(raw)
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function resolveTaskProvider(config: OrchestrationConfig, category: string | null): ProviderMode {
|
|
474
|
+
if (!category) return config.providerMode
|
|
475
|
+
return config.categories.routing[category] ?? config.providerMode
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function getCategoryConcurrencyLimit(config: OrchestrationConfig, category: string | null): number {
|
|
479
|
+
if (!category) return config.limits.maxConcurrent
|
|
480
|
+
return config.categories.maxConcurrent[category] ?? config.limits.maxConcurrent
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function isActiveTaskStatus(status: TaskStatus): boolean {
|
|
484
|
+
return status === "running" || status === "retrying" || status === "queued"
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function countActiveTasks(sessionID: string): number {
|
|
488
|
+
return getTasksForSession(sessionID).filter((record) => isActiveTaskStatus(record.status)).length
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function countActiveTasksByCategory(sessionID: string, category: string | null): number {
|
|
492
|
+
if (!category) return countActiveTasks(sessionID)
|
|
493
|
+
return getTasksForSession(sessionID).filter((record) => record.category === category && isActiveTaskStatus(record.status)).length
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function safeTaskRecord(value: unknown): TaskRecord | null {
|
|
497
|
+
if (!isRecord(value)) return null
|
|
498
|
+
const id = asString(value.id)
|
|
499
|
+
const sessionID = asString(value.sessionID)
|
|
500
|
+
const title = asString(value.title)
|
|
501
|
+
const agent = asString(value.agent)
|
|
502
|
+
const categoryRaw = value.category
|
|
503
|
+
const categoryFromField = typeof categoryRaw === "string" ? normalizeCategoryName(categoryRaw) : null
|
|
504
|
+
const status = asString(value.status)
|
|
505
|
+
const createdAt = asString(value.createdAt)
|
|
506
|
+
const updatedAt = asString(value.updatedAt)
|
|
507
|
+
const backgroundTaskID = value.backgroundTaskID === null ? null : asString(value.backgroundTaskID) ?? null
|
|
508
|
+
const retries = typeof value.retries === "number" ? value.retries : 0
|
|
509
|
+
const fallbackAttempts = typeof value.fallbackAttempts === "number" ? value.fallbackAttempts : 0
|
|
510
|
+
const details = asString(value.details)
|
|
511
|
+
const fallbackReason = value.fallbackReason === null ? null : asString(value.fallbackReason) ?? null
|
|
512
|
+
const blockedBy = Array.isArray(value.blockedBy)
|
|
513
|
+
? value.blockedBy.filter((item): item is string => typeof item === "string")
|
|
514
|
+
: []
|
|
515
|
+
const blocks = Array.isArray(value.blocks)
|
|
516
|
+
? value.blocks.filter((item): item is string => typeof item === "string")
|
|
517
|
+
: []
|
|
518
|
+
const tmuxPaneID = value.tmuxPaneID === null ? null : asString(value.tmuxPaneID) ?? null
|
|
519
|
+
const tmuxSessionName = value.tmuxSessionName === null ? null : asString(value.tmuxSessionName) ?? null
|
|
520
|
+
const tmuxState = asString(value.tmuxState)
|
|
521
|
+
|
|
522
|
+
const validTmuxState =
|
|
523
|
+
tmuxState === "disabled" || tmuxState === "queued" || tmuxState === "attached" || tmuxState === "failed"
|
|
524
|
+
|
|
525
|
+
const validStatus =
|
|
526
|
+
status === "queued" ||
|
|
527
|
+
status === "running" ||
|
|
528
|
+
status === "retrying" ||
|
|
529
|
+
status === "completed" ||
|
|
530
|
+
status === "failed" ||
|
|
531
|
+
status === "stopped"
|
|
532
|
+
|
|
533
|
+
if (!id || !sessionID || !title || !agent || !validStatus || !createdAt || !updatedAt || !details) {
|
|
534
|
+
return null
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
return {
|
|
538
|
+
id,
|
|
539
|
+
sessionID,
|
|
540
|
+
title,
|
|
541
|
+
agent,
|
|
542
|
+
category: categoryFromField ?? extractCategoryFromAgent(agent),
|
|
543
|
+
status,
|
|
544
|
+
createdAt,
|
|
545
|
+
updatedAt,
|
|
546
|
+
backgroundTaskID,
|
|
547
|
+
retries,
|
|
548
|
+
fallbackAttempts,
|
|
549
|
+
details,
|
|
550
|
+
fallbackReason,
|
|
551
|
+
blockedBy,
|
|
552
|
+
blocks,
|
|
553
|
+
tmuxPaneID,
|
|
554
|
+
tmuxSessionName,
|
|
555
|
+
tmuxState: validTmuxState ? tmuxState : "disabled",
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function hydrateSessionTasks(sessionID: string): void {
|
|
560
|
+
const filePath = getSessionTaskFile(sessionID)
|
|
561
|
+
if (!existsSync(filePath)) return
|
|
562
|
+
|
|
563
|
+
try {
|
|
564
|
+
const parsed = JSON.parse(readFileSync(filePath, "utf-8")) as unknown
|
|
565
|
+
if (!Array.isArray(parsed)) return
|
|
566
|
+
for (const entry of parsed) {
|
|
567
|
+
const record = safeTaskRecord(entry)
|
|
568
|
+
if (!record) continue
|
|
569
|
+
if (!taskRecords.has(record.id)) {
|
|
570
|
+
taskRecords.set(record.id, record)
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
} catch {
|
|
574
|
+
return
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function persistSessionTasks(sessionID: string): void {
|
|
579
|
+
const tasks = [...taskRecords.values()]
|
|
580
|
+
.filter((record) => record.sessionID === sessionID)
|
|
581
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
|
582
|
+
|
|
583
|
+
mkdirSync(TASK_STORAGE_DIR, { recursive: true })
|
|
584
|
+
writeFileSync(getSessionTaskFile(sessionID), JSON.stringify(tasks, null, 2))
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function pushNotification(
|
|
588
|
+
sessionID: string,
|
|
589
|
+
level: "info" | "warn" | "error",
|
|
590
|
+
message: string,
|
|
591
|
+
taskID: string
|
|
592
|
+
): void {
|
|
593
|
+
const config = parseConfigFromFile().config
|
|
594
|
+
if (!config.notifications.enabled) return
|
|
595
|
+
|
|
596
|
+
const state = getSessionState(sessionID)
|
|
597
|
+
state.notifications.unshift({
|
|
598
|
+
ts: nowIso(),
|
|
599
|
+
level,
|
|
600
|
+
message,
|
|
601
|
+
taskID,
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
if (state.notifications.length > config.notifications.maxItems) {
|
|
605
|
+
state.notifications = state.notifications.slice(0, config.notifications.maxItems)
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function notifyTmuxMissingOnce(sessionID: string): void {
|
|
610
|
+
const state = getSessionState(sessionID)
|
|
611
|
+
if (state.tmuxMissingWarned) return
|
|
612
|
+
state.tmuxMissingWarned = true
|
|
613
|
+
pushNotification(sessionID, "warn", "Tmux integration disabled for this session because tmux is not installed or not on PATH", "tmux")
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function getPersistedTaskCount(): number {
|
|
617
|
+
if (!existsSync(TASK_STORAGE_DIR)) return 0
|
|
618
|
+
let total = 0
|
|
619
|
+
for (const file of readdirSync(TASK_STORAGE_DIR)) {
|
|
620
|
+
if (!file.endsWith(".json")) continue
|
|
621
|
+
const sessionID = file.replace(/\.json$/, "")
|
|
622
|
+
hydrateSessionTasks(sessionID)
|
|
623
|
+
}
|
|
624
|
+
for (const _ of taskRecords.values()) {
|
|
625
|
+
total += 1
|
|
626
|
+
}
|
|
627
|
+
return total
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function recoverPersistedTasks(config: OrchestrationConfig): void {
|
|
631
|
+
if (!config.recovery.autoResumeOnStart) return
|
|
632
|
+
if (!existsSync(TASK_STORAGE_DIR)) return
|
|
633
|
+
|
|
634
|
+
for (const file of readdirSync(TASK_STORAGE_DIR)) {
|
|
635
|
+
if (!file.endsWith(".json")) continue
|
|
636
|
+
const sessionID = file.replace(/\.json$/, "")
|
|
637
|
+
const state = getSessionState(sessionID)
|
|
638
|
+
const tasks = getTasksForSession(sessionID)
|
|
639
|
+
let recovered = 0
|
|
640
|
+
|
|
641
|
+
for (const record of tasks) {
|
|
642
|
+
if (!isActiveTaskStatus(record.status)) continue
|
|
643
|
+
record.status = "queued"
|
|
644
|
+
record.updatedAt = nowIso()
|
|
645
|
+
record.details = "Recovered after restart; pending re-dispatch"
|
|
646
|
+
record.tmuxPaneID = null
|
|
647
|
+
record.tmuxSessionName = null
|
|
648
|
+
record.tmuxState = "disabled"
|
|
649
|
+
recovered += 1
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
if (recovered === 0) continue
|
|
653
|
+
state.telemetry.resumed += recovered
|
|
654
|
+
persistSessionTasks(sessionID)
|
|
655
|
+
pushNotification(sessionID, "info", `Recovered ${recovered} task(s) after restart`, "resume")
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function createTaskRecord(sessionID: string, title: string, agent: string, category: string | null, details: string): TaskRecord {
|
|
660
|
+
const id = `tsk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
|
|
661
|
+
const stamp = nowIso()
|
|
662
|
+
const record: TaskRecord = {
|
|
663
|
+
id,
|
|
664
|
+
sessionID,
|
|
665
|
+
title,
|
|
666
|
+
agent,
|
|
667
|
+
category,
|
|
668
|
+
status: "running",
|
|
669
|
+
createdAt: stamp,
|
|
670
|
+
updatedAt: stamp,
|
|
671
|
+
backgroundTaskID: null,
|
|
672
|
+
retries: 0,
|
|
673
|
+
fallbackAttempts: 0,
|
|
674
|
+
details,
|
|
675
|
+
fallbackReason: null,
|
|
676
|
+
blockedBy: [],
|
|
677
|
+
blocks: [],
|
|
678
|
+
tmuxPaneID: null,
|
|
679
|
+
tmuxSessionName: null,
|
|
680
|
+
tmuxState: "disabled",
|
|
681
|
+
}
|
|
682
|
+
taskRecords.set(id, record)
|
|
683
|
+
persistSessionTasks(sessionID)
|
|
684
|
+
const config = parseConfigFromFile().config
|
|
685
|
+
if (config.hooks.telemetry) {
|
|
686
|
+
const state = getSessionState(sessionID)
|
|
687
|
+
state.telemetry.created += 1
|
|
688
|
+
}
|
|
689
|
+
return record
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function enforceTaskTimeouts(sessionID: string, config: OrchestrationConfig): number {
|
|
693
|
+
const now = Date.now()
|
|
694
|
+
let timedOut = 0
|
|
695
|
+
for (const record of getTasksForSession(sessionID)) {
|
|
696
|
+
const isActive = record.status === "running" || record.status === "retrying" || record.status === "queued"
|
|
697
|
+
if (!isActive) continue
|
|
698
|
+
const ageMs = now - isoToMs(record.updatedAt)
|
|
699
|
+
if (ageMs > config.limits.taskTimeoutMs) {
|
|
700
|
+
setTaskStatus(record, "failed", `Timed out after ${config.limits.taskTimeoutMs}ms without progress`)
|
|
701
|
+
timedOut += 1
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
return timedOut
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function setTaskStatus(record: TaskRecord, status: TaskStatus, details?: string): void {
|
|
708
|
+
const previousStatus = record.status
|
|
709
|
+
record.status = status
|
|
710
|
+
record.updatedAt = nowIso()
|
|
711
|
+
if (details) record.details = details
|
|
712
|
+
persistSessionTasks(record.sessionID)
|
|
713
|
+
|
|
714
|
+
const config = parseConfigFromFile().config
|
|
715
|
+
if (previousStatus === status) return
|
|
716
|
+
|
|
717
|
+
if (config.hooks.telemetry) {
|
|
718
|
+
const telemetry = getSessionState(record.sessionID).telemetry
|
|
719
|
+
if (status === "completed") telemetry.completed += 1
|
|
720
|
+
if (status === "failed") {
|
|
721
|
+
telemetry.failed += 1
|
|
722
|
+
if ((details ?? "").toLowerCase().includes("timed out")) telemetry.timedOut += 1
|
|
723
|
+
}
|
|
724
|
+
if (status === "retrying") telemetry.retrying += 1
|
|
725
|
+
if (status === "stopped") telemetry.stopped += 1
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
if (status === "completed") {
|
|
729
|
+
pushNotification(record.sessionID, "info", `Task completed: ${record.title}`, record.id)
|
|
730
|
+
}
|
|
731
|
+
if (status === "retrying") {
|
|
732
|
+
if (config.runtimeFallback.notifyOnFallback) {
|
|
733
|
+
pushNotification(record.sessionID, "warn", `Task retrying: ${record.title}`, record.id)
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
if (status === "failed") {
|
|
737
|
+
pushNotification(record.sessionID, "error", `Task failed: ${record.title}`, record.id)
|
|
738
|
+
}
|
|
739
|
+
if (status === "stopped") {
|
|
740
|
+
pushNotification(record.sessionID, "warn", `Task stopped: ${record.title}`, record.id)
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function extractBackgroundTaskID(output: string): string | null {
|
|
745
|
+
const match = output.match(/Task ID:\s*([a-zA-Z0-9_-]+)/)
|
|
746
|
+
return match ? match[1] : null
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function extractHttpStatus(output: string): number | null {
|
|
750
|
+
const match = output.match(/\b(4\d\d|5\d\d)\b/)
|
|
751
|
+
if (!match) return null
|
|
752
|
+
const code = Number.parseInt(match[1], 10)
|
|
753
|
+
return Number.isNaN(code) ? null : code
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function classifyFailure(output: string): { code: number | null; reason: string } {
|
|
757
|
+
const lowered = output.toLowerCase()
|
|
758
|
+
const statusCode = extractHttpStatus(output)
|
|
759
|
+
if (statusCode !== null) return { code: statusCode, reason: `http-${statusCode}` }
|
|
760
|
+
if (lowered.includes("timeout") || lowered.includes("timed out")) return { code: 408, reason: "timeout" }
|
|
761
|
+
if (lowered.includes("rate limit") || lowered.includes("too many requests")) return { code: 429, reason: "rate-limit" }
|
|
762
|
+
if (lowered.includes("api key") || lowered.includes("credential")) return { code: 401, reason: "credential" }
|
|
763
|
+
if (lowered.includes("failed")) return { code: 500, reason: "failed" }
|
|
764
|
+
return { code: null, reason: "unknown" }
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function getNextProvider(config: OrchestrationConfig, current: ProviderMode): ProviderMode | null {
|
|
768
|
+
const order = config.runtimeFallback.providerOrder
|
|
769
|
+
const index = order.indexOf(current)
|
|
770
|
+
if (index < 0 || index + 1 >= order.length) return null
|
|
771
|
+
return order[index + 1]
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function hasTmuxBinary(): boolean {
|
|
775
|
+
const probe = spawnSync("tmux", ["-V"], { encoding: "utf-8" })
|
|
776
|
+
return probe.status === 0
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function isInsideTmux(): boolean {
|
|
780
|
+
return typeof process.env.TMUX === "string" && process.env.TMUX.length > 0
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function runTmux(args: string[]): { ok: boolean; stdout: string; stderr: string } {
|
|
784
|
+
const result = spawnSync("tmux", args, { encoding: "utf-8" })
|
|
785
|
+
return {
|
|
786
|
+
ok: result.status === 0,
|
|
787
|
+
stdout: result.stdout ?? "",
|
|
788
|
+
stderr: result.stderr ?? "",
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function normalizeTaskToken(value: string): string {
|
|
793
|
+
return value.toLowerCase().replace(/[^a-z0-9_-]/g, "-").slice(0, 18)
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function getTmuxSessionName(config: OrchestrationConfig, record: TaskRecord): string {
|
|
797
|
+
const prefix = normalizeTaskToken(config.tmux.sessionPrefix)
|
|
798
|
+
const token = normalizeTaskToken(record.id)
|
|
799
|
+
return `${prefix}-${token}`
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function getSplitDirection(layout: TmuxLayout): "-h" | "-v" {
|
|
803
|
+
return layout === "main-horizontal" ? "-v" : "-h"
|
|
804
|
+
}
|
|
805
|
+
|
|
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}%`])
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
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,
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
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
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function attachTaskPane(record: TaskRecord, config: OrchestrationConfig): { attached: boolean; reason: string } {
|
|
841
|
+
if (!config.tmux.enabled) {
|
|
842
|
+
record.tmuxState = "disabled"
|
|
843
|
+
record.tmuxPaneID = null
|
|
844
|
+
record.tmuxSessionName = null
|
|
845
|
+
return { attached: false, reason: "tmux disabled" }
|
|
846
|
+
}
|
|
847
|
+
if (!isInsideTmux()) {
|
|
848
|
+
record.tmuxState = "disabled"
|
|
849
|
+
record.tmuxPaneID = null
|
|
850
|
+
record.tmuxSessionName = null
|
|
851
|
+
return { attached: false, reason: "not inside tmux" }
|
|
852
|
+
}
|
|
853
|
+
if (!hasTmuxBinary()) {
|
|
854
|
+
record.tmuxState = "disabled"
|
|
855
|
+
record.tmuxPaneID = null
|
|
856
|
+
record.tmuxSessionName = null
|
|
857
|
+
notifyTmuxMissingOnce(record.sessionID)
|
|
858
|
+
return { attached: false, reason: "tmux binary unavailable" }
|
|
859
|
+
}
|
|
860
|
+
|
|
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})` }
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const splitDirection = getSplitDirection(config.tmux.layout)
|
|
869
|
+
const split = runTmux(["split-window", splitDirection, "-d", "-P", "-F", "#{pane_id}"])
|
|
870
|
+
if (!split.ok) {
|
|
871
|
+
record.tmuxState = "failed"
|
|
872
|
+
return { attached: false, reason: split.stderr.trim() || "split-window failed" }
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
const paneID = split.stdout.trim().split("\n")[0]?.trim()
|
|
876
|
+
if (!paneID) {
|
|
877
|
+
record.tmuxState = "failed"
|
|
878
|
+
return { attached: false, reason: "split-window returned empty pane id" }
|
|
879
|
+
}
|
|
880
|
+
|
|
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)
|
|
886
|
+
|
|
887
|
+
record.tmuxState = "attached"
|
|
888
|
+
record.tmuxPaneID = paneID
|
|
889
|
+
record.tmuxSessionName = sessionName
|
|
890
|
+
return { attached: true, reason: paneID }
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function closeTaskPane(record: TaskRecord, config: OrchestrationConfig): void {
|
|
894
|
+
if (!record.tmuxPaneID) {
|
|
895
|
+
record.tmuxPaneID = null
|
|
896
|
+
record.tmuxSessionName = null
|
|
897
|
+
record.tmuxState = "disabled"
|
|
898
|
+
persistSessionTasks(record.sessionID)
|
|
899
|
+
return
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
if (!config.tmux.enabled || !isInsideTmux() || !hasTmuxBinary()) {
|
|
903
|
+
record.tmuxPaneID = null
|
|
904
|
+
record.tmuxSessionName = null
|
|
905
|
+
record.tmuxState = "disabled"
|
|
906
|
+
if (!hasTmuxBinary()) notifyTmuxMissingOnce(record.sessionID)
|
|
907
|
+
persistSessionTasks(record.sessionID)
|
|
908
|
+
return
|
|
909
|
+
}
|
|
910
|
+
runTmux(["send-keys", "-t", record.tmuxPaneID, "C-c"])
|
|
911
|
+
runTmux(["kill-pane", "-t", record.tmuxPaneID])
|
|
912
|
+
record.tmuxPaneID = null
|
|
913
|
+
record.tmuxSessionName = null
|
|
914
|
+
record.tmuxState = "disabled"
|
|
915
|
+
persistSessionTasks(record.sessionID)
|
|
916
|
+
}
|
|
917
|
+
|
|
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}"])
|
|
954
|
+
return probe.ok
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function reconcileTmuxState(sessionID: string, config: OrchestrationConfig): { staleClosed: number; missing: number; queuedAttached: number } {
|
|
958
|
+
if (!config.tmux.enabled || !isInsideTmux() || !hasTmuxBinary()) {
|
|
959
|
+
return { staleClosed: 0, missing: 0, queuedAttached: 0 }
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
let staleClosed = 0
|
|
963
|
+
let missing = 0
|
|
964
|
+
const tasks = getTasksForSession(sessionID)
|
|
965
|
+
for (const record of tasks) {
|
|
966
|
+
const terminal = record.status === "completed" || record.status === "failed" || record.status === "stopped"
|
|
967
|
+
if (terminal && record.tmuxPaneID) {
|
|
968
|
+
closeTaskPane(record, config)
|
|
969
|
+
staleClosed += 1
|
|
970
|
+
continue
|
|
971
|
+
}
|
|
972
|
+
if (record.tmuxState === "attached" && record.tmuxPaneID && !verifyTaskPane(record)) {
|
|
973
|
+
record.tmuxState = "failed"
|
|
974
|
+
record.tmuxPaneID = null
|
|
975
|
+
record.tmuxSessionName = null
|
|
976
|
+
missing += 1
|
|
977
|
+
persistSessionTasks(sessionID)
|
|
978
|
+
pushNotification(sessionID, "warn", `Tmux pane missing for ${record.title}`, record.id)
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
const queuedAttached = processDeferredTmuxQueue(sessionID, config)
|
|
982
|
+
return { staleClosed, missing, queuedAttached }
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function formatTaskLine(record: TaskRecord): string {
|
|
986
|
+
const bg = record.backgroundTaskID ? ` bg=${record.backgroundTaskID}` : ""
|
|
987
|
+
const category = record.category ? ` category=${record.category}` : ""
|
|
988
|
+
const deps = record.blockedBy.length > 0 ? ` blockedBy=${record.blockedBy.join(",")}` : ""
|
|
989
|
+
const pane = record.tmuxPaneID ? ` pane=${record.tmuxPaneID}` : ""
|
|
990
|
+
const tmuxState = ` tmux=${record.tmuxState}`
|
|
991
|
+
return `- ${record.id} [${record.status}] ${record.agent} \"${record.title}\"${bg}${category}${deps}${pane}${tmuxState}`
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
interface DiagnosticCheck {
|
|
995
|
+
label: string
|
|
996
|
+
pass: boolean
|
|
997
|
+
remediation: string
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function diagnosticsCheck(label: string, pass: boolean, remediation: string): DiagnosticCheck {
|
|
1001
|
+
return { label, pass, remediation }
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function formatCheck(check: DiagnosticCheck): string {
|
|
1005
|
+
return `- ${check.pass ? "PASS" : "FAIL"} ${check.label}${check.pass ? "" : ` -> ${check.remediation}`}`
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function runDiagnostics(sessionID: string): string {
|
|
1009
|
+
const configResult = parseConfigFromFile()
|
|
1010
|
+
const config = configResult.config
|
|
1011
|
+
const timedOutTasks = isHookEnabled(config, "timeout-enforcement") ? enforceTaskTimeouts(sessionID, config) : 0
|
|
1012
|
+
const tmuxHealth = reconcileTmuxState(sessionID, config)
|
|
1013
|
+
const tasks = getTasksForSession(sessionID)
|
|
1014
|
+
const activeTasks = tasks.filter((task) => task.status === "running" || task.status === "retrying" || task.status === "queued")
|
|
1015
|
+
const activeByCategory = new Map<string, number>()
|
|
1016
|
+
for (const task of activeTasks) {
|
|
1017
|
+
const category = task.category
|
|
1018
|
+
if (!category) continue
|
|
1019
|
+
activeByCategory.set(category, (activeByCategory.get(category) ?? 0) + 1)
|
|
1020
|
+
}
|
|
1021
|
+
const categoryConcurrencyViolations = [...activeByCategory.entries()].filter(([category, active]) => {
|
|
1022
|
+
const limit = getCategoryConcurrencyLimit(config, category)
|
|
1023
|
+
return active > limit
|
|
1024
|
+
})
|
|
1025
|
+
const attachedTmuxTasks = tasks.filter((task) => task.tmuxState === "attached")
|
|
1026
|
+
const queuedTmuxTasks = tasks.filter((task) => task.tmuxState === "queued")
|
|
1027
|
+
const state = getSessionState(sessionID)
|
|
1028
|
+
|
|
1029
|
+
const requiredCommands = ["continue", "stop", "tasks", "task", "diagnostics", "look-at"]
|
|
1030
|
+
const commandChecks: DiagnosticCheck[] = requiredCommands.map((name) => {
|
|
1031
|
+
const exists = existsSync(join(process.cwd(), "command", `${name}.md`))
|
|
1032
|
+
return diagnosticsCheck(`/` + name, exists, `add command/${name}.md`)
|
|
1033
|
+
})
|
|
1034
|
+
|
|
1035
|
+
const lookAtPluginExists = existsSync(join(process.cwd(), "plugin", "wpromote-look-at.ts"))
|
|
1036
|
+
const orchestrationPluginExists = existsSync(join(process.cwd(), "plugin", "wpromote-orchestration.ts"))
|
|
1037
|
+
const configChecks: DiagnosticCheck[] = [
|
|
1038
|
+
diagnosticsCheck("providerMode", config.providerMode === "copilot" || config.providerMode === "native", "set orchestration.providerMode to copilot or native"),
|
|
1039
|
+
diagnosticsCheck("feature:delegation", config.features.delegation, "enable orchestration.features.delegation"),
|
|
1040
|
+
diagnosticsCheck("feature:diagnostics", config.features.diagnostics, "enable orchestration.features.diagnostics"),
|
|
1041
|
+
diagnosticsCheck("feature:continuousLoop", config.features.continuousLoop, "enable orchestration.features.continuousLoop"),
|
|
1042
|
+
diagnosticsCheck("feature:selfHealing", config.features.selfHealing, "enable orchestration.features.selfHealing"),
|
|
1043
|
+
diagnosticsCheck("limits:maxConcurrent", config.limits.maxConcurrent >= 1 && config.limits.maxConcurrent <= 10, "set orchestration.limits.maxConcurrent in range 1..10"),
|
|
1044
|
+
diagnosticsCheck("limits:maxRetries", config.limits.maxRetries >= 0 && config.limits.maxRetries <= 5, "set orchestration.limits.maxRetries in range 0..5"),
|
|
1045
|
+
diagnosticsCheck("limits:maxIterations", config.limits.maxIterations >= 1 && config.limits.maxIterations <= 100, "set orchestration.limits.maxIterations in range 1..100"),
|
|
1046
|
+
diagnosticsCheck("limits:retryBackoffMs", config.limits.retryBackoffMs >= 100 && config.limits.retryBackoffMs <= 60000, "set orchestration.limits.retryBackoffMs in range 100..60000"),
|
|
1047
|
+
diagnosticsCheck("limits:taskTimeoutMs", config.limits.taskTimeoutMs >= 1000 && config.limits.taskTimeoutMs <= 900000, "set orchestration.limits.taskTimeoutMs in range 1000..900000"),
|
|
1048
|
+
diagnosticsCheck("runtime:activeWithinLimit", activeTasks.length <= config.limits.maxConcurrent, "reduce active subagent workload or increase orchestration.limits.maxConcurrent"),
|
|
1049
|
+
diagnosticsCheck("categories:routing", typeof config.categories.routing === "object", "set orchestration.categories.routing as an object map"),
|
|
1050
|
+
diagnosticsCheck("categories:maxConcurrent", typeof config.categories.maxConcurrent === "object", "set orchestration.categories.maxConcurrent as an object map"),
|
|
1051
|
+
diagnosticsCheck(
|
|
1052
|
+
"runtime:categoryConcurrency",
|
|
1053
|
+
categoryConcurrencyViolations.length === 0,
|
|
1054
|
+
"reduce active tasks per category or raise orchestration.categories.maxConcurrent entries"
|
|
1055
|
+
),
|
|
1056
|
+
diagnosticsCheck("runtimeFallback:enabled", typeof config.runtimeFallback.enabled === "boolean", "set orchestration.runtimeFallback.enabled to true/false"),
|
|
1057
|
+
diagnosticsCheck("runtimeFallback:maxFallbackAttempts", config.runtimeFallback.maxFallbackAttempts >= 1 && config.runtimeFallback.maxFallbackAttempts <= 5, "set orchestration.runtimeFallback.maxFallbackAttempts in range 1..5"),
|
|
1058
|
+
diagnosticsCheck("runtimeFallback:cooldownSeconds", config.runtimeFallback.cooldownSeconds >= 0 && config.runtimeFallback.cooldownSeconds <= 600, "set orchestration.runtimeFallback.cooldownSeconds in range 0..600"),
|
|
1059
|
+
diagnosticsCheck("runtimeFallback:timeoutSeconds", config.runtimeFallback.timeoutSeconds >= 0 && config.runtimeFallback.timeoutSeconds <= 300, "set orchestration.runtimeFallback.timeoutSeconds in range 0..300"),
|
|
1060
|
+
diagnosticsCheck("runtimeFallback:providerOrder", config.runtimeFallback.providerOrder.length > 0, "set orchestration.runtimeFallback.providerOrder with at least one provider"),
|
|
1061
|
+
diagnosticsCheck("hooks:disabled", Array.isArray(config.hooks.disabled), "set orchestration.hooks.disabled as a string array"),
|
|
1062
|
+
diagnosticsCheck("hooks:telemetry", typeof config.hooks.telemetry === "boolean", "set orchestration.hooks.telemetry to true/false"),
|
|
1063
|
+
diagnosticsCheck("notifications:enabled", typeof config.notifications.enabled === "boolean", "set orchestration.notifications.enabled to true/false"),
|
|
1064
|
+
diagnosticsCheck("notifications:maxItems", config.notifications.maxItems >= 10 && config.notifications.maxItems <= 500, "set orchestration.notifications.maxItems in range 10..500"),
|
|
1065
|
+
diagnosticsCheck("recovery:autoResumeOnStart", typeof config.recovery.autoResumeOnStart === "boolean", "set orchestration.recovery.autoResumeOnStart to true/false"),
|
|
1066
|
+
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
|
+
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
|
+
diagnosticsCheck("tmux:insideSession", !config.tmux.enabled || isInsideTmux(), "run inside tmux or disable orchestration.tmux.enabled"),
|
|
1073
|
+
diagnosticsCheck("tmux:binary", !config.tmux.enabled || hasTmuxBinary(), "install tmux and ensure it is on PATH"),
|
|
1074
|
+
]
|
|
1075
|
+
const pluginChecks: DiagnosticCheck[] = [
|
|
1076
|
+
diagnosticsCheck("plugin/wpromote-orchestration.ts", orchestrationPluginExists, "add plugin/wpromote-orchestration.ts"),
|
|
1077
|
+
diagnosticsCheck("plugin/wpromote-look-at.ts", lookAtPluginExists, "add plugin/wpromote-look-at.ts"),
|
|
1078
|
+
]
|
|
1079
|
+
const allChecks = [...configChecks, ...pluginChecks, ...commandChecks]
|
|
1080
|
+
const failedChecks = allChecks.filter((check) => !check.pass)
|
|
1081
|
+
const passCount = allChecks.length - failedChecks.length
|
|
1082
|
+
const failCount = failedChecks.length
|
|
1083
|
+
|
|
1084
|
+
const lines = [
|
|
1085
|
+
"# Wpromote Diagnostics (Full)",
|
|
1086
|
+
"",
|
|
1087
|
+
"## Summary",
|
|
1088
|
+
`- Overall: ${failCount === 0 ? "PASS" : "FAIL"}`,
|
|
1089
|
+
`- Checks: ${passCount} passed, ${failCount} failed`,
|
|
1090
|
+
`- Config source: ${configResult.source}`,
|
|
1091
|
+
"",
|
|
1092
|
+
"## Configuration",
|
|
1093
|
+
...configChecks.map(formatCheck),
|
|
1094
|
+
"",
|
|
1095
|
+
"## Plugin Assets",
|
|
1096
|
+
...pluginChecks.map(formatCheck),
|
|
1097
|
+
"",
|
|
1098
|
+
"## Commands",
|
|
1099
|
+
...commandChecks.map(formatCheck),
|
|
1100
|
+
"",
|
|
1101
|
+
"## Runtime State",
|
|
1102
|
+
`- Active task records in this session: ${tasks.length}`,
|
|
1103
|
+
`- Persisted task records across sessions: ${getPersistedTaskCount()}`,
|
|
1104
|
+
`- Active delegated tasks: ${activeTasks.length}/${config.limits.maxConcurrent}`,
|
|
1105
|
+
`- Active tmux panes: ${attachedTmuxTasks.length}`,
|
|
1106
|
+
`- Deferred tmux queue depth: ${state.deferredTmuxQueue.length}`,
|
|
1107
|
+
`- Recovery auto-resume on startup: ${config.recovery.autoResumeOnStart ? "enabled" : "disabled"}`,
|
|
1108
|
+
`- 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}`,
|
|
1112
|
+
`- Loop mode: ${getSessionState(sessionID).loopEnabled ? "enabled" : "disabled"}`,
|
|
1113
|
+
"",
|
|
1114
|
+
"## Hook Health",
|
|
1115
|
+
`- task-tracking: ${isHookEnabled(config, "task-tracking") ? "enabled" : "disabled"}`,
|
|
1116
|
+
`- timeout-enforcement: ${isHookEnabled(config, "timeout-enforcement") ? "enabled" : "disabled"}`,
|
|
1117
|
+
`- fallback-engine: ${isHookEnabled(config, "fallback-engine") ? "enabled" : "disabled"}`,
|
|
1118
|
+
`- activity-feed: ${isHookEnabled(config, "activity-feed") ? "enabled" : "disabled"}`,
|
|
1119
|
+
`- telemetry: ${config.hooks.telemetry ? "enabled" : "disabled"}`,
|
|
1120
|
+
`- 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
|
+
`- 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
|
+
"",
|
|
1124
|
+
"## Provider Routing",
|
|
1125
|
+
`- Current mode: ${config.providerMode}`,
|
|
1126
|
+
`- Category routing overrides: ${Object.entries(config.categories.routing)
|
|
1127
|
+
.map(([category, provider]) => `${category}:${provider}`)
|
|
1128
|
+
.join(", ") || "none"}`,
|
|
1129
|
+
`- Category concurrency overrides: ${Object.entries(config.categories.maxConcurrent)
|
|
1130
|
+
.map(([category, limit]) => `${category}:${limit}`)
|
|
1131
|
+
.join(", ") || "none"}`,
|
|
1132
|
+
`- Runtime fallback: ${config.runtimeFallback.enabled ? "enabled" : "disabled"}`,
|
|
1133
|
+
`- Fallback retries: ${config.runtimeFallback.maxFallbackAttempts}`,
|
|
1134
|
+
`- Retry error codes: ${config.runtimeFallback.retryOnErrors.join(", ")}`,
|
|
1135
|
+
`- Provider fallback chain: ${config.runtimeFallback.providerOrder.join(" -> ")}`,
|
|
1136
|
+
config.providerMode === "copilot"
|
|
1137
|
+
? "- Routing profile: Copilot-first (Codex/Gemini through Copilot where configured)."
|
|
1138
|
+
: "- Routing profile: Native provider mode (direct provider endpoints).",
|
|
1139
|
+
]
|
|
1140
|
+
|
|
1141
|
+
if (failedChecks.length > 0) {
|
|
1142
|
+
lines.push("", "## Remediation")
|
|
1143
|
+
for (const check of failedChecks) {
|
|
1144
|
+
lines.push(`- ${check.label}: ${check.remediation}`)
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
return lines.join("\n")
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
function formatSingleTask(record: TaskRecord): string {
|
|
1152
|
+
return [
|
|
1153
|
+
`# Task ${record.id}`,
|
|
1154
|
+
"",
|
|
1155
|
+
`- Status: ${record.status}`,
|
|
1156
|
+
`- Agent: ${record.agent}`,
|
|
1157
|
+
`- Category: ${record.category ?? "n/a"}`,
|
|
1158
|
+
`- Title: ${record.title}`,
|
|
1159
|
+
`- Retries: ${record.retries}`,
|
|
1160
|
+
`- Created: ${record.createdAt}`,
|
|
1161
|
+
`- Updated: ${record.updatedAt}`,
|
|
1162
|
+
`- Background ID: ${record.backgroundTaskID ?? "n/a"}`,
|
|
1163
|
+
`- Tmux State: ${record.tmuxState}`,
|
|
1164
|
+
`- Tmux Pane ID: ${record.tmuxPaneID ?? "n/a"}`,
|
|
1165
|
+
`- Tmux Session: ${record.tmuxSessionName ?? "n/a"}`,
|
|
1166
|
+
`- Blocked By: ${record.blockedBy.length > 0 ? record.blockedBy.join(", ") : "none"}`,
|
|
1167
|
+
`- Blocks: ${record.blocks.length > 0 ? record.blocks.join(", ") : "none"}`,
|
|
1168
|
+
`- Details: ${record.details}`,
|
|
1169
|
+
].join("\n")
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
const orchestrationTool = tool({
|
|
1173
|
+
description:
|
|
1174
|
+
"Controls wpromote orchestration state. Actions: continue, stop, tasks, task, diagnostics.",
|
|
1175
|
+
args: {
|
|
1176
|
+
action: tool.schema.enum(["continue", "stop", "tasks", "task", "diagnostics"]),
|
|
1177
|
+
task_id: tool.schema.string().optional(),
|
|
1178
|
+
},
|
|
1179
|
+
execute: async (args, context) => {
|
|
1180
|
+
const state = getSessionState(context.sessionID)
|
|
1181
|
+
|
|
1182
|
+
if (args.action === "continue") {
|
|
1183
|
+
state.loopEnabled = true
|
|
1184
|
+
return [
|
|
1185
|
+
"Continuous loop mode enabled for this session.",
|
|
1186
|
+
"- Loop remains active until /stop is invoked.",
|
|
1187
|
+
"- Self-healing policies respect configured retry/timeout bounds.",
|
|
1188
|
+
].join("\n")
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
if (args.action === "stop") {
|
|
1192
|
+
const config = parseConfigFromFile().config
|
|
1193
|
+
state.loopEnabled = false
|
|
1194
|
+
for (const record of getTasksForSession(context.sessionID)) {
|
|
1195
|
+
if (record.status === "queued" || record.status === "running" || record.status === "retrying") {
|
|
1196
|
+
setTaskStatus(record, "stopped", "Stopped by /stop command")
|
|
1197
|
+
}
|
|
1198
|
+
if (record.tmuxPaneID) {
|
|
1199
|
+
closeTaskPane(record, config)
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
state.deferredTmuxQueue = []
|
|
1203
|
+
return "Loop and queue processing halted for this session. Awaiting further instruction."
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
if (args.action === "tasks") {
|
|
1207
|
+
const config = parseConfigFromFile().config
|
|
1208
|
+
if (isHookEnabled(config, "timeout-enforcement")) {
|
|
1209
|
+
enforceTaskTimeouts(context.sessionID, config)
|
|
1210
|
+
}
|
|
1211
|
+
const tmuxHealth = reconcileTmuxState(context.sessionID, config)
|
|
1212
|
+
const tasks = getTasksForSession(context.sessionID)
|
|
1213
|
+
if (tasks.length === 0) return "No tracked subagent tasks for this session yet."
|
|
1214
|
+
|
|
1215
|
+
const lines = ["# Active Subagent Tasks", "", ...tasks.slice(0, 20).map(formatTaskLine)]
|
|
1216
|
+
lines.push(
|
|
1217
|
+
"",
|
|
1218
|
+
"# Tmux Runtime",
|
|
1219
|
+
"",
|
|
1220
|
+
`- enabled=${config.tmux.enabled}`,
|
|
1221
|
+
`- attached=${tasks.filter((task) => task.tmuxState === "attached").length}`,
|
|
1222
|
+
`- queued=${getSessionState(context.sessionID).deferredTmuxQueue.length}`,
|
|
1223
|
+
`- staleClosed=${tmuxHealth.staleClosed}`,
|
|
1224
|
+
`- missingDetected=${tmuxHealth.missing}`,
|
|
1225
|
+
`- queuedAttached=${tmuxHealth.queuedAttached}`,
|
|
1226
|
+
)
|
|
1227
|
+
const notifications = getSessionState(context.sessionID).notifications.slice(0, 5)
|
|
1228
|
+
if (notifications.length > 0) {
|
|
1229
|
+
lines.push("", "# Recent Notifications", "")
|
|
1230
|
+
for (const note of notifications) {
|
|
1231
|
+
lines.push(`- [${note.level.toUpperCase()}] ${note.ts} ${note.message} (${note.taskID})`)
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
return lines.join("\n")
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
if (args.action === "task") {
|
|
1239
|
+
if (!args.task_id) return "Missing task_id. Usage: action=task task_id=<id>"
|
|
1240
|
+
const byID = taskRecords.get(args.task_id)
|
|
1241
|
+
if (byID && byID.sessionID === context.sessionID) {
|
|
1242
|
+
return formatSingleTask(byID)
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
for (const record of getTasksForSession(context.sessionID)) {
|
|
1246
|
+
if (record.backgroundTaskID === args.task_id) {
|
|
1247
|
+
return formatSingleTask(record)
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
return `Task not found for this session: ${args.task_id}`
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
return runDiagnostics(context.sessionID)
|
|
1255
|
+
},
|
|
1256
|
+
})
|
|
1257
|
+
|
|
1258
|
+
const WpromoteOrchestrationPlugin: Plugin = async () => {
|
|
1259
|
+
recoverPersistedTasks(parseConfigFromFile().config)
|
|
1260
|
+
|
|
1261
|
+
return {
|
|
1262
|
+
tool: {
|
|
1263
|
+
wpromote_orchestration: orchestrationTool,
|
|
1264
|
+
},
|
|
1265
|
+
"tool.execute.before": async (input, output) => {
|
|
1266
|
+
if (input.tool !== "task") return
|
|
1267
|
+
|
|
1268
|
+
const config = parseConfigFromFile().config
|
|
1269
|
+
if (!isHookEnabled(config, "task-tracking")) return
|
|
1270
|
+
const taskInfo = parseTaskInfo(output.args)
|
|
1271
|
+
const activeBefore = countActiveTasks(input.sessionID)
|
|
1272
|
+
const categoryActiveBefore = countActiveTasksByCategory(input.sessionID, taskInfo.category)
|
|
1273
|
+
const deps = parseTaskDependencies(output.args)
|
|
1274
|
+
const record = createTaskRecord(input.sessionID, taskInfo.title, taskInfo.agent, taskInfo.category, taskInfo.details)
|
|
1275
|
+
const taskProvider = resolveTaskProvider(config, record.category)
|
|
1276
|
+
record.details = `${taskInfo.details} provider=${taskProvider}`
|
|
1277
|
+
record.blockedBy = deps.blockedBy
|
|
1278
|
+
record.blocks = deps.blocks
|
|
1279
|
+
|
|
1280
|
+
if (config.tmux.enabled) {
|
|
1281
|
+
const attach = attachTaskPane(record, config)
|
|
1282
|
+
if (!attach.attached && record.tmuxState === "queued") {
|
|
1283
|
+
enqueueDeferredTmuxTask(input.sessionID, record.id)
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
persistSessionTasks(input.sessionID)
|
|
1288
|
+
taskByCallID.set(input.callID, record.id)
|
|
1289
|
+
|
|
1290
|
+
if (activeBefore >= config.limits.maxConcurrent) {
|
|
1291
|
+
setTaskStatus(record, "queued", `Waiting for global slot (${activeBefore}/${config.limits.maxConcurrent} active)`)
|
|
1292
|
+
return
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
const categoryLimit = getCategoryConcurrencyLimit(config, record.category)
|
|
1296
|
+
if (categoryActiveBefore >= categoryLimit) {
|
|
1297
|
+
const categoryLabel = record.category ?? "unspecified"
|
|
1298
|
+
setTaskStatus(record, "queued", `Waiting for category ${categoryLabel} slot (${categoryActiveBefore}/${categoryLimit} active)`)
|
|
1299
|
+
}
|
|
1300
|
+
},
|
|
1301
|
+
"tool.execute.after": async (input, output) => {
|
|
1302
|
+
if (input.tool !== "task") return
|
|
1303
|
+
|
|
1304
|
+
const config = parseConfigFromFile().config
|
|
1305
|
+
if (!isHookEnabled(config, "task-tracking")) return
|
|
1306
|
+
|
|
1307
|
+
const taskID = taskByCallID.get(input.callID)
|
|
1308
|
+
if (!taskID) return
|
|
1309
|
+
|
|
1310
|
+
const record = taskRecords.get(taskID)
|
|
1311
|
+
if (!record) return
|
|
1312
|
+
|
|
1313
|
+
const textOutput = typeof output.output === "string" ? output.output : ""
|
|
1314
|
+
const bgID = extractBackgroundTaskID(textOutput)
|
|
1315
|
+
|
|
1316
|
+
if (bgID) {
|
|
1317
|
+
const taskProvider = resolveTaskProvider(config, record.category)
|
|
1318
|
+
record.backgroundTaskID = bgID
|
|
1319
|
+
setTaskStatus(record, "running", `Background task launched with ${bgID}, provider=${taskProvider}`)
|
|
1320
|
+
} else if (textOutput.toLowerCase().includes("failed")) {
|
|
1321
|
+
const failure = classifyFailure(textOutput)
|
|
1322
|
+
const taskProvider = resolveTaskProvider(config, record.category)
|
|
1323
|
+
const shouldFallback =
|
|
1324
|
+
config.features.selfHealing &&
|
|
1325
|
+
isHookEnabled(config, "fallback-engine") &&
|
|
1326
|
+
config.runtimeFallback.enabled &&
|
|
1327
|
+
failure.code !== null &&
|
|
1328
|
+
config.runtimeFallback.retryOnErrors.includes(failure.code) &&
|
|
1329
|
+
record.fallbackAttempts < config.runtimeFallback.maxFallbackAttempts
|
|
1330
|
+
|
|
1331
|
+
if (shouldFallback) {
|
|
1332
|
+
record.fallbackAttempts += 1
|
|
1333
|
+
record.retries += 1
|
|
1334
|
+
record.fallbackReason = failure.reason
|
|
1335
|
+
const nextProvider = getNextProvider(config, taskProvider)
|
|
1336
|
+
const providerNote = nextProvider ? `next provider ${nextProvider}` : "no alternate provider configured"
|
|
1337
|
+
setTaskStatus(
|
|
1338
|
+
record,
|
|
1339
|
+
"retrying",
|
|
1340
|
+
`Fallback attempt ${record.fallbackAttempts}/${config.runtimeFallback.maxFallbackAttempts}, reason=${failure.reason}, ${providerNote}`
|
|
1341
|
+
)
|
|
1342
|
+
} else {
|
|
1343
|
+
setTaskStatus(record, "failed", "Task execution reported failure")
|
|
1344
|
+
}
|
|
1345
|
+
} else {
|
|
1346
|
+
setTaskStatus(record, "completed", "Task execution completed")
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
if (record.status === "completed" || record.status === "failed" || record.status === "stopped") {
|
|
1350
|
+
closeTaskPane(record, config)
|
|
1351
|
+
}
|
|
1352
|
+
processDeferredTmuxQueue(input.sessionID, config)
|
|
1353
|
+
persistSessionTasks(input.sessionID)
|
|
1354
|
+
|
|
1355
|
+
const activityLine = `\n[Subagent activity] ${record.status.toUpperCase()} ${record.agent} \"${record.title}\"`
|
|
1356
|
+
if (isHookEnabled(config, "activity-feed")) {
|
|
1357
|
+
if (typeof output.output === "string") {
|
|
1358
|
+
output.output = `${output.output}${activityLine}`
|
|
1359
|
+
} else {
|
|
1360
|
+
output.output = activityLine.trim()
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
},
|
|
1364
|
+
event: async (input) => {
|
|
1365
|
+
if (input.event.type !== "session.deleted") return
|
|
1366
|
+
const props = isRecord(input.event.properties) ? input.event.properties : null
|
|
1367
|
+
const info = props && isRecord(props.info) ? props.info : null
|
|
1368
|
+
const sessionID = info ? asString(info.id) : undefined
|
|
1369
|
+
if (!sessionID) return
|
|
1370
|
+
|
|
1371
|
+
const config = parseConfigFromFile().config
|
|
1372
|
+
const state = getSessionState(sessionID)
|
|
1373
|
+
for (const record of getTasksForSession(sessionID)) {
|
|
1374
|
+
if (record.tmuxPaneID) closeTaskPane(record, config)
|
|
1375
|
+
if (record.status === "running" || record.status === "retrying" || record.status === "queued") {
|
|
1376
|
+
setTaskStatus(record, "stopped", "Parent session deleted")
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
state.deferredTmuxQueue = []
|
|
1380
|
+
persistSessionTasks(sessionID)
|
|
1381
|
+
},
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
export default WpromoteOrchestrationPlugin
|