@wpro-eng/opencode-config 1.0.2 → 1.1.2

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.
@@ -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