pantheon-opencode 1.4.2 → 1.4.3
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 +27 -115
- package/package.json +1 -1
- package/src/agents/zeus.md +14 -25
- package/src/pantheon/cost-command.ts +7 -7
- package/src/pantheon/delegation-enforce.ts +12 -13
- package/src/pantheon/goal-loop.ts +2 -2
- package/src/pantheon/goal-store.ts +1 -1
- package/src/pantheon/hashline/tool.ts +2 -2
- package/src/pantheon/model-command.ts +87 -34
- package/src/pantheon/tool-context.ts +41 -0
- package/src/plugin.ts +41 -193
- package/src/plugins/tui/package-lock.json +2 -2
- package/src/plugins/tui/package.json +1 -1
- package/src/plugins/tui/src/index.tsx +8 -6
- package/src/pantheon/delegation-classifier.ts +0 -307
- package/src/pantheon/delegation-finalize.ts +0 -374
- package/src/pantheon/delegation-notify.ts +0 -197
- package/src/pantheon/delegation-toast.ts +0 -73
- package/src/pantheon/delegation.ts +0 -1120
- package/src/pantheon/permission-eval.ts +0 -284
- package/src/pantheon/step-cap.ts +0 -110
- package/src/pantheon/zeus-delegate-with-retry.ts +0 -406
|
@@ -23,8 +23,6 @@ import { lstat, mkdir, open, rename, unlink } from 'node:fs/promises'
|
|
|
23
23
|
import { homedir } from 'node:os'
|
|
24
24
|
import { basename, dirname, join, resolve } from 'node:path'
|
|
25
25
|
import { z } from 'zod'
|
|
26
|
-
|
|
27
|
-
import type { ToolContextLike } from './delegation.ts'
|
|
28
26
|
import {
|
|
29
27
|
capabilityEntry,
|
|
30
28
|
hasVision,
|
|
@@ -32,6 +30,7 @@ import {
|
|
|
32
30
|
normalizeCapability,
|
|
33
31
|
resolveActivePreset,
|
|
34
32
|
} from './presets.mjs'
|
|
33
|
+
import type { ToolContextLike } from './tool-context.ts'
|
|
35
34
|
|
|
36
35
|
export type ModelScope = 'project' | 'global'
|
|
37
36
|
export type ModelAction = 'status' | 'show' | 'set' | 'reset'
|
|
@@ -65,10 +64,7 @@ const modelArgs = {
|
|
|
65
64
|
.optional()
|
|
66
65
|
.describe('Operation: status/show, set, or reset. No args runs wizard.'),
|
|
67
66
|
agent: z.string().optional().describe('Agent name (one of 14 canonical agents).'),
|
|
68
|
-
model: z
|
|
69
|
-
.string()
|
|
70
|
-
.optional()
|
|
71
|
-
.describe('Model in provider/model-id format; used by set.'),
|
|
67
|
+
model: z.string().optional().describe('Model in provider/model-id format; used by set.'),
|
|
72
68
|
effort: z
|
|
73
69
|
.enum(['low', 'medium', 'high'])
|
|
74
70
|
.optional()
|
|
@@ -357,10 +353,7 @@ function presetPathForScope(options: ModelCommandOptions, scope: ModelScope): st
|
|
|
357
353
|
return globalCandidates[0] ?? candidates[0]!
|
|
358
354
|
}
|
|
359
355
|
|
|
360
|
-
async function readActivePresetRaw(
|
|
361
|
-
path: string,
|
|
362
|
-
label: string,
|
|
363
|
-
): Promise<ReadFileResult> {
|
|
356
|
+
async function readActivePresetRaw(path: string, label: string): Promise<ReadFileResult> {
|
|
364
357
|
const file = await readRegularFile(path, label)
|
|
365
358
|
if (file === undefined) return { exists: false, valid: true, data: null }
|
|
366
359
|
try {
|
|
@@ -368,7 +361,13 @@ async function readActivePresetRaw(
|
|
|
368
361
|
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
369
362
|
return { exists: true, valid: false, data: null, content: file.content, mode: file.mode }
|
|
370
363
|
}
|
|
371
|
-
return {
|
|
364
|
+
return {
|
|
365
|
+
exists: true,
|
|
366
|
+
valid: true,
|
|
367
|
+
data: parsed as Record<string, unknown>,
|
|
368
|
+
content: file.content,
|
|
369
|
+
mode: file.mode,
|
|
370
|
+
}
|
|
372
371
|
} catch {
|
|
373
372
|
return { exists: true, valid: false, data: null, content: file.content, mode: file.mode }
|
|
374
373
|
}
|
|
@@ -444,9 +443,21 @@ async function writeActivePresetAtomically(
|
|
|
444
443
|
if (current.content === undefined) {
|
|
445
444
|
throw new ModelCommandError('configuration content could not be read; file preserved')
|
|
446
445
|
}
|
|
447
|
-
await atomicReplace(
|
|
446
|
+
await atomicReplace(
|
|
447
|
+
backupPath,
|
|
448
|
+
current.content,
|
|
449
|
+
backupMode,
|
|
450
|
+
'configuration backup',
|
|
451
|
+
backup !== undefined,
|
|
452
|
+
)
|
|
448
453
|
}
|
|
449
|
-
await atomicReplace(
|
|
454
|
+
await atomicReplace(
|
|
455
|
+
path,
|
|
456
|
+
`${JSON.stringify(data, null, 2)}\n`,
|
|
457
|
+
originalMode,
|
|
458
|
+
'configuration file',
|
|
459
|
+
current.exists,
|
|
460
|
+
)
|
|
450
461
|
}
|
|
451
462
|
|
|
452
463
|
function ensureActivePresetShape(
|
|
@@ -460,11 +471,21 @@ function ensureActivePresetShape(
|
|
|
460
471
|
}
|
|
461
472
|
if (typeof out.source !== 'string') out.source = 'cli'
|
|
462
473
|
out.updated_at = new Date().toISOString()
|
|
463
|
-
if (
|
|
474
|
+
if (
|
|
475
|
+
out.overrides === undefined ||
|
|
476
|
+
out.overrides === null ||
|
|
477
|
+
typeof out.overrides !== 'object' ||
|
|
478
|
+
Array.isArray(out.overrides)
|
|
479
|
+
) {
|
|
464
480
|
out.overrides = {}
|
|
465
481
|
}
|
|
466
482
|
const overrides = out.overrides as Record<string, unknown>
|
|
467
|
-
if (
|
|
483
|
+
if (
|
|
484
|
+
overrides.agents === undefined ||
|
|
485
|
+
overrides.agents === null ||
|
|
486
|
+
typeof overrides.agents !== 'object' ||
|
|
487
|
+
Array.isArray(overrides.agents)
|
|
488
|
+
) {
|
|
468
489
|
overrides.agents = {}
|
|
469
490
|
}
|
|
470
491
|
return out
|
|
@@ -509,14 +530,25 @@ async function updateActivePreset(
|
|
|
509
530
|
} catch {
|
|
510
531
|
// ignore
|
|
511
532
|
}
|
|
512
|
-
} else if (
|
|
533
|
+
} else if (
|
|
534
|
+
current.data &&
|
|
535
|
+
typeof current.data === 'object' &&
|
|
536
|
+
(current.data as Record<string, unknown>).preset
|
|
537
|
+
) {
|
|
513
538
|
const existing = (current.data as Record<string, unknown>).preset as string
|
|
514
|
-
if (
|
|
539
|
+
if (
|
|
540
|
+
typeof existing === 'string' &&
|
|
541
|
+
existing.trim() !== '' &&
|
|
542
|
+
SAFE_PRESET_NAME.test(existing.trim())
|
|
543
|
+
) {
|
|
515
544
|
fallbackPreset = existing.trim()
|
|
516
545
|
}
|
|
517
546
|
}
|
|
518
547
|
|
|
519
|
-
const shaped = ensureActivePresetShape(
|
|
548
|
+
const shaped = ensureActivePresetShape(
|
|
549
|
+
current.data as Record<string, unknown> | null,
|
|
550
|
+
fallbackPreset,
|
|
551
|
+
)
|
|
520
552
|
const overrides = shaped.overrides as Record<string, unknown>
|
|
521
553
|
const agents = overrides.agents as Record<string, unknown>
|
|
522
554
|
|
|
@@ -551,7 +583,10 @@ async function updateActivePreset(
|
|
|
551
583
|
|
|
552
584
|
// If clamped, append warning
|
|
553
585
|
if (variantInfo.clamped) {
|
|
554
|
-
visionWarning = [
|
|
586
|
+
visionWarning = [
|
|
587
|
+
visionWarning,
|
|
588
|
+
`effort clamped to ${variantInfo.variant} for model ${model}`,
|
|
589
|
+
]
|
|
555
590
|
.filter(Boolean)
|
|
556
591
|
.join('; ')
|
|
557
592
|
}
|
|
@@ -584,10 +619,6 @@ async function updateActivePreset(
|
|
|
584
619
|
|
|
585
620
|
// ─── Status ─────────────────────────────────────────────────────────────────
|
|
586
621
|
|
|
587
|
-
function silentLogger(): ModelCommandLogger {
|
|
588
|
-
return { warn: () => {}, log: () => {} }
|
|
589
|
-
}
|
|
590
|
-
|
|
591
622
|
async function resolvePerAgentStatus(options: ModelCommandOptions): Promise<{
|
|
592
623
|
presetName: string | null
|
|
593
624
|
presetSource: string | null
|
|
@@ -624,12 +655,20 @@ async function resolvePerAgentStatus(options: ModelCommandOptions): Promise<{
|
|
|
624
655
|
let vision: boolean | undefined
|
|
625
656
|
|
|
626
657
|
if (resolved) {
|
|
627
|
-
const merged = (
|
|
658
|
+
const merged = (
|
|
659
|
+
resolved.agents as Record<
|
|
660
|
+
string,
|
|
661
|
+
{ model?: string; reasoning_effort?: string; variant?: string }
|
|
662
|
+
>
|
|
663
|
+
)?.[key]
|
|
628
664
|
if (merged?.model) {
|
|
629
665
|
model = merged.model
|
|
630
|
-
effort =
|
|
666
|
+
effort =
|
|
667
|
+
(merged as { reasoning_effort?: string }).reasoning_effort ??
|
|
668
|
+
(merged as { variant?: string }).variant
|
|
631
669
|
// Determine origin: file overrides > file preset > env
|
|
632
|
-
const fileOverrides = (resolved.overrides as { agents?: Record<string, unknown> } | null)
|
|
670
|
+
const fileOverrides = (resolved.overrides as { agents?: Record<string, unknown> } | null)
|
|
671
|
+
?.agents
|
|
633
672
|
if (resolved.source === 'file' && fileOverrides && Object.hasOwn(fileOverrides, key)) {
|
|
634
673
|
origin = 'override'
|
|
635
674
|
} else if (resolved.source === 'env') {
|
|
@@ -665,7 +704,8 @@ async function renderStatus(options: ModelCommandOptions): Promise<string> {
|
|
|
665
704
|
const lines: string[] = []
|
|
666
705
|
lines.push('Pantheon model configuration per-agent (restart OpenCode after changes):')
|
|
667
706
|
if (presetName) {
|
|
668
|
-
const originLabel =
|
|
707
|
+
const originLabel =
|
|
708
|
+
presetSource === 'env' ? 'env' : presetSource === 'file' ? 'preset' : presetSource
|
|
669
709
|
lines.push(`active preset: ${presetName} (${presetSource ?? 'unknown'})`)
|
|
670
710
|
// For test compatibility, also show origin mapping explicitly
|
|
671
711
|
void originLabel
|
|
@@ -700,7 +740,8 @@ export function buildWizardQuestions(): unknown[] {
|
|
|
700
740
|
},
|
|
701
741
|
{
|
|
702
742
|
header: 'Modelo',
|
|
703
|
-
question:
|
|
743
|
+
question:
|
|
744
|
+
'Informe o modelo em provider/model-id (ex: openai/gpt-5.6 ou opencode-go/kimi-k2.7-code)',
|
|
704
745
|
kind: 'text',
|
|
705
746
|
},
|
|
706
747
|
{
|
|
@@ -717,8 +758,14 @@ export function buildWizardQuestions(): unknown[] {
|
|
|
717
758
|
header: 'Escopo',
|
|
718
759
|
question: 'Onde salvar o override?',
|
|
719
760
|
options: [
|
|
720
|
-
{
|
|
721
|
-
|
|
761
|
+
{
|
|
762
|
+
label: 'project',
|
|
763
|
+
description: './.pantheon/active-preset.json (escopo do projeto, seguro)',
|
|
764
|
+
},
|
|
765
|
+
{
|
|
766
|
+
label: 'global',
|
|
767
|
+
description: '~/.config/opencode/.pantheon/active-preset.json (global)',
|
|
768
|
+
},
|
|
722
769
|
],
|
|
723
770
|
multiSelect: false,
|
|
724
771
|
},
|
|
@@ -741,11 +788,15 @@ async function promptViaReadline(
|
|
|
741
788
|
answers.model = modelAns
|
|
742
789
|
|
|
743
790
|
const effortQ = questions[2]!
|
|
744
|
-
const effortAns = (await rl.question(`${effortQ.question} [low/medium/high] : `))
|
|
791
|
+
const effortAns = (await rl.question(`${effortQ.question} [low/medium/high] : `))
|
|
792
|
+
.trim()
|
|
793
|
+
.toLowerCase()
|
|
745
794
|
answers.effort = effortAns || 'medium'
|
|
746
795
|
|
|
747
796
|
const scopeQ = questions[3]!
|
|
748
|
-
const scopeAns = (await rl.question(`${scopeQ.question} [project/global] : `))
|
|
797
|
+
const scopeAns = (await rl.question(`${scopeQ.question} [project/global] : `))
|
|
798
|
+
.trim()
|
|
799
|
+
.toLowerCase()
|
|
749
800
|
answers.scope = scopeAns || 'project'
|
|
750
801
|
|
|
751
802
|
return answers
|
|
@@ -779,7 +830,8 @@ async function runWizard(options: ModelCommandOptions): Promise<string> {
|
|
|
779
830
|
const agent = typeof answers.agent === 'string' ? answers.agent.trim() : ''
|
|
780
831
|
const model = typeof answers.model === 'string' ? answers.model.trim() : ''
|
|
781
832
|
const effort = typeof answers.effort === 'string' ? answers.effort.trim().toLowerCase() : ''
|
|
782
|
-
const scopeRaw =
|
|
833
|
+
const scopeRaw =
|
|
834
|
+
typeof answers.scope === 'string' ? answers.scope.trim().toLowerCase() : 'project'
|
|
783
835
|
const scope: ModelScope = scopeRaw === 'global' ? 'global' : 'project'
|
|
784
836
|
|
|
785
837
|
if (!agent) return 'pantheon_model wizard failed: agent is required'
|
|
@@ -830,7 +882,8 @@ export function createModelCommand(options: ModelCommandOptions = {}): ModelTool
|
|
|
830
882
|
return await runWizard(options)
|
|
831
883
|
}
|
|
832
884
|
|
|
833
|
-
const action: ModelAction = (args.action ??
|
|
885
|
+
const action: ModelAction = (args.action ??
|
|
886
|
+
(hasAgentModelEffort ? 'set' : 'status')) as ModelAction
|
|
834
887
|
|
|
835
888
|
if (action === 'status' || action === 'show') return await renderStatus(options)
|
|
836
889
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared tool-context types and path guards used by the preserved tool
|
|
3
|
+
* modules (cost-command, goal-loop, model-command, goal-store).
|
|
4
|
+
*
|
|
5
|
+
* These were previously exported from the removed delegation modules; they
|
|
6
|
+
* are hoisted here so the surviving subsystems keep a single source of truth
|
|
7
|
+
* without depending on the deleted delegate machinery.
|
|
8
|
+
*
|
|
9
|
+
* @module tool-context
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The structural tool-execution context a tool's `execute` receives. Mirrors
|
|
14
|
+
* the OpenCode SDK's ToolContext but keeps every field optional so the
|
|
15
|
+
* structural test/embedding surface can be supplied by older hosts.
|
|
16
|
+
*/
|
|
17
|
+
export interface ToolContextLike {
|
|
18
|
+
sessionID: string
|
|
19
|
+
directory?: string
|
|
20
|
+
worktree?: string
|
|
21
|
+
/**
|
|
22
|
+
* The OpenCode SDK's ToolContext declares this as required. It remains
|
|
23
|
+
* optional here because the structural test/embedding surface can be
|
|
24
|
+
* supplied by older hosts; enforcement must skip when it is absent.
|
|
25
|
+
*/
|
|
26
|
+
agent?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Path-traversal guard for session IDs embedded in file paths. Throws on
|
|
31
|
+
* `..`, `/`, `\`. Used by goal-store to keep goal files inside their dir.
|
|
32
|
+
*/
|
|
33
|
+
export function assertSafeParentSessionID(parentSessionID: string): void {
|
|
34
|
+
if (
|
|
35
|
+
parentSessionID.includes('..') ||
|
|
36
|
+
parentSessionID.includes('/') ||
|
|
37
|
+
parentSessionID.includes('\\')
|
|
38
|
+
) {
|
|
39
|
+
throw new Error(`Invalid parentSessionID: ${parentSessionID}`)
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/plugin.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs'
|
|
2
|
-
import { join } from 'node:path'
|
|
3
1
|
import type { Plugin, PluginInput } from '@opencode-ai/plugin'
|
|
4
2
|
import type { PluginConfig } from 'opencode'
|
|
5
3
|
import { BackgroundJobBoard } from './pantheon/background-job-board.ts'
|
|
@@ -12,14 +10,6 @@ import {
|
|
|
12
10
|
DEFAULT_LIMITS as SANDBOX_DEFAULT_LIMITS,
|
|
13
11
|
} from './pantheon/context-sandbox.ts'
|
|
14
12
|
import { createCostCommand } from './pantheon/cost-command.ts'
|
|
15
|
-
import {
|
|
16
|
-
collectRootSessionIDs,
|
|
17
|
-
createDelegationTools,
|
|
18
|
-
type DelegationClient,
|
|
19
|
-
resolveDelegationBudgets,
|
|
20
|
-
resolveDelegationTimeoutMs,
|
|
21
|
-
startIdleChildScan,
|
|
22
|
-
} from './pantheon/delegation.ts'
|
|
23
13
|
import { buildCompactionContext } from './pantheon/delegation-compaction.ts'
|
|
24
14
|
import {
|
|
25
15
|
createEnforcementGuard,
|
|
@@ -27,8 +17,6 @@ import {
|
|
|
27
17
|
SessionHierarchyRegistry,
|
|
28
18
|
zeusReadGuard,
|
|
29
19
|
} from './pantheon/delegation-enforce.ts'
|
|
30
|
-
import { handleDelegationEvent } from './pantheon/delegation-notify.ts'
|
|
31
|
-
import { showDelegationTerminalToast, type ToastClient } from './pantheon/delegation-toast.ts'
|
|
32
20
|
import { FilePersistenceAdapter } from './pantheon/file-persistence.ts'
|
|
33
21
|
import { GOAL_LOOP_DEFAULTS, GoalLoop, GoalStore } from './pantheon/goal-loop.ts'
|
|
34
22
|
import { createReadEnhancer } from './pantheon/hashline/read-enhancer.ts'
|
|
@@ -37,14 +25,8 @@ import { createIdleDispatcher } from './pantheon/idle-continuation.ts'
|
|
|
37
25
|
import { createPantheonLogger } from './pantheon/logger.ts'
|
|
38
26
|
import { createModelCommand } from './pantheon/model-command.ts'
|
|
39
27
|
import { pantheonPluginOnce } from './pantheon/plugin-once.ts'
|
|
40
|
-
import {
|
|
41
|
-
applyActivePresetToConfig,
|
|
42
|
-
loadRoutingAgentModels,
|
|
43
|
-
loadRoutingMaxSteps,
|
|
44
|
-
loadRoutingPermissionTask,
|
|
45
|
-
} from './pantheon/presets.mjs'
|
|
28
|
+
import { applyActivePresetToConfig } from './pantheon/presets.mjs'
|
|
46
29
|
import { safeSessionPath } from './pantheon/session-guard.ts'
|
|
47
|
-
import { StepCapTracker } from './pantheon/step-cap.ts'
|
|
48
30
|
import {
|
|
49
31
|
TODO_ENFORCER_DEFAULTS,
|
|
50
32
|
TodoEnforcer,
|
|
@@ -63,24 +45,12 @@ import { activePresetCandidates, createVisionHandler } from './pantheon/vision.t
|
|
|
63
45
|
// .pantheon/logs/hooks.log; the console echo is opt-in via PANTHEON_HOOKS_LOG=1.
|
|
64
46
|
const log = createPantheonLogger({ module: 'pantheon-plugin' })
|
|
65
47
|
|
|
66
|
-
// R4: per-agent step caps from routing.yml `agents.<name>.max_steps`
|
|
67
|
-
// (fail-open → {} = no agent capped). R1 retry_policy/cooldown are NOT
|
|
68
|
-
// wired here — the plugin has no retry path (opencode cannot intercept task
|
|
69
|
-
// completion); zeus waves load them via presets.mjs loaders and pass them
|
|
70
|
-
// to zeusDelegateWithRetry explicitly.
|
|
71
|
-
const stepCapTracker = new StepCapTracker(loadRoutingMaxSteps({ logger: log }))
|
|
72
|
-
|
|
73
|
-
// O5: permission.task glob rules from routing.yml `permission.task`
|
|
74
|
-
// (fail-open → undefined = existing runtime matrix only).
|
|
75
|
-
const routingPermissionTask = loadRoutingPermissionTask({ logger: log })
|
|
76
|
-
|
|
77
48
|
const board = new BackgroundJobBoard({
|
|
78
49
|
maxConcurrentPerAgent: 3,
|
|
79
50
|
signalDir: '.pantheon/deepwork/board-signals',
|
|
80
51
|
})
|
|
81
52
|
const persistence = new FilePersistenceAdapter('.pantheon/board/state.json')
|
|
82
53
|
board.setPersistence(persistence)
|
|
83
|
-
let terminalToastClient: ToastClient = {}
|
|
84
54
|
board
|
|
85
55
|
.recoverRunningJobs()
|
|
86
56
|
.catch((err) => log.error('[Pantheon Plugin] Failed to recover running jobs:', err))
|
|
@@ -90,17 +60,13 @@ board
|
|
|
90
60
|
// file-only log line (console echo opt-in via PANTHEON_HOOKS_LOG). There is
|
|
91
61
|
// deliberately NO chat delivery — the user policy is zero delegation
|
|
92
62
|
// notifications in the transcript. Completion visibility lives in the board
|
|
93
|
-
// `[unread]` marker
|
|
94
|
-
// TUI toasts (pantheon-hooks, PANTHEON_TOASTS gate) and compaction
|
|
95
|
-
// carry-forward.
|
|
63
|
+
// `[unread]` marker and compaction carry-forward.
|
|
96
64
|
board.onTerminal((taskID: string) => {
|
|
97
65
|
const job = board.get(taskID)
|
|
98
66
|
if (!job) return
|
|
99
67
|
log.info(
|
|
100
68
|
`[Pantheon Plugin] Board terminal: [${job.alias}] ${job.description} → ${job.state}${job.resultSummary ? ` — ${job.resultSummary}` : ''}`,
|
|
101
69
|
)
|
|
102
|
-
// Deliberately direct-to-TUI: onTerminal must not inject chat/transcript text.
|
|
103
|
-
void showDelegationTerminalToast(terminalToastClient, job)
|
|
104
70
|
})
|
|
105
71
|
|
|
106
72
|
// Periodically prune terminal/reconciled jobs (24h TTL, every 30 min).
|
|
@@ -118,10 +84,10 @@ setInterval(
|
|
|
118
84
|
|
|
119
85
|
// ─── Phase 4: Read-Only Enforcement + Compaction Context ───────────────
|
|
120
86
|
|
|
121
|
-
// The read-only registry is populated
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
87
|
+
// The read-only registry is populated when a child session is launched
|
|
88
|
+
// read-only (explicit flag or agent ∈ readOnlyAgents). The guard denies
|
|
89
|
+
// edit/write/bash/task inside those sessions by throwing from
|
|
90
|
+
// tool.execute.before; unknown sessions are allowed (safe default).
|
|
125
91
|
const enforcementGuard = createEnforcementGuard({
|
|
126
92
|
getReadOnlySessions: () => readOnlyRegistry.sessionIDs(),
|
|
127
93
|
options: {
|
|
@@ -142,7 +108,7 @@ const commandNormalizer = createCommandNormalizer({ logger: log })
|
|
|
142
108
|
// Mirrors routing.yml background_delegation.max_compaction_items.
|
|
143
109
|
const COMPACTION_MAX_ITEMS = 10
|
|
144
110
|
|
|
145
|
-
// Root-session registry for the
|
|
111
|
+
// Root-session registry for the child-session depth guard: sessions created
|
|
146
112
|
// without a parentID are roots. Populated from TWO sources:
|
|
147
113
|
// 1. the `session.created` event hook (live roots — fires for sessions
|
|
148
114
|
// created while the plugin runs);
|
|
@@ -150,9 +116,9 @@ const COMPACTION_MAX_ITEMS = 10
|
|
|
150
116
|
// hook (compaction-134) — opencode does NOT replay session.created
|
|
151
117
|
// events for sessions that exist before plugin load, so without the
|
|
152
118
|
// seed a resumed root would never enter this set after a restart.
|
|
153
|
-
//
|
|
154
|
-
// created is a sub-session; unknown sessions are roots") so the
|
|
155
|
-
// works even when this set is incomplete.
|
|
119
|
+
// The hierarchy registry additionally falls back to known children ("any
|
|
120
|
+
// session WE created is a sub-session; unknown sessions are roots") so the
|
|
121
|
+
// depth guard works even when this set is incomplete.
|
|
156
122
|
const rootSessions = new Set<string>()
|
|
157
123
|
const sessionHierarchy = new SessionHierarchyRegistry()
|
|
158
124
|
const sessionAgents = new Map<string, string>()
|
|
@@ -161,14 +127,14 @@ const sessionAgents = new Map<string, string>()
|
|
|
161
127
|
* Seed resumed root sessions without making OpenCode startup depend on the
|
|
162
128
|
* session API responding. Some OpenCode environments do not have the API
|
|
163
129
|
* ready while plugin config hooks run; awaiting session.list() there leaves
|
|
164
|
-
* the TUI stuck before it can open. The
|
|
130
|
+
* the TUI stuck before it can open. The hierarchy registry already treats an
|
|
165
131
|
* unknown session as a root, so this enrichment is deliberately best-effort.
|
|
166
132
|
*/
|
|
167
133
|
function seedRootSessionsInBackground(input: PluginInput): void {
|
|
168
134
|
// Fail-open: environments without the session API (client.session absent or
|
|
169
135
|
// not yet wired while config hooks run) must NOT break startup. `session`
|
|
170
136
|
// is captured and the method invoked on it so `this` stays bound; the
|
|
171
|
-
//
|
|
137
|
+
// hierarchy registry already treats unknown sessions as roots, so skipping
|
|
172
138
|
// the seed is always safe.
|
|
173
139
|
const session = input.client.session
|
|
174
140
|
if (typeof session?.list !== 'function') return
|
|
@@ -178,10 +144,15 @@ function seedRootSessionsInBackground(input: PluginInput): void {
|
|
|
178
144
|
.then((result) => {
|
|
179
145
|
if (!result.error && Array.isArray(result.data)) {
|
|
180
146
|
sessionHierarchy.completeSeed(result.data)
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
147
|
+
let seeded = 0
|
|
148
|
+
for (const info of result.data) {
|
|
149
|
+
if (info.parentID === undefined) {
|
|
150
|
+
rootSessions.add(info.id)
|
|
151
|
+
seeded += 1
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (seeded > 0) {
|
|
155
|
+
log.info(`[Pantheon Plugin] Seeded ${seeded} root session(s) from session.list`)
|
|
185
156
|
}
|
|
186
157
|
} else {
|
|
187
158
|
sessionHierarchy.failSeed()
|
|
@@ -199,62 +170,6 @@ function sdkErrorMessage(error: { data?: { message?: string } } | null | undefin
|
|
|
199
170
|
return typeof message === 'string' && message.trim() !== '' ? message : 'request failed'
|
|
200
171
|
}
|
|
201
172
|
|
|
202
|
-
/**
|
|
203
|
-
* Adapt the opencode SDK client to the structural DelegationClient the
|
|
204
|
-
* delegation toolset expects: the SDK wraps every call in a
|
|
205
|
-
* `{data, error, request, response}` result while delegation.ts uses direct
|
|
206
|
-
* returns. Errors surface as throws so the toolset's own error handling
|
|
207
|
-
* (tool errors returned as TEXT) keeps working.
|
|
208
|
-
*/
|
|
209
|
-
function adaptDelegationClient(client: PluginInput['client']): DelegationClient {
|
|
210
|
-
return {
|
|
211
|
-
session: {
|
|
212
|
-
create: async (input) => {
|
|
213
|
-
const body: {
|
|
214
|
-
parentID?: string
|
|
215
|
-
title?: string
|
|
216
|
-
model?: { id: string; providerID: string }
|
|
217
|
-
} = { parentID: input.body.parentID }
|
|
218
|
-
if (input.body.title !== undefined) body.title = input.body.title
|
|
219
|
-
// Forward a model only when the active routing profile supplied one.
|
|
220
|
-
if (input.body.model !== undefined) body.model = input.body.model
|
|
221
|
-
const result = await client.session.create({ body })
|
|
222
|
-
if (result.error) throw new Error(sdkErrorMessage(result.error))
|
|
223
|
-
return { id: result.data.id }
|
|
224
|
-
},
|
|
225
|
-
promptAsync: async (input) => {
|
|
226
|
-
const path = safeSessionPath(input.path.id)
|
|
227
|
-
if (!path) {
|
|
228
|
-
log.warn(`[Pantheon Plugin] promptAsync skipped: invalid sessionID "${input.path.id}"`)
|
|
229
|
-
throw new Error(`invalid sessionID: ${input.path.id}`)
|
|
230
|
-
}
|
|
231
|
-
const result = await client.session.promptAsync({
|
|
232
|
-
path: path.path,
|
|
233
|
-
body: {
|
|
234
|
-
agent: input.body.agent,
|
|
235
|
-
...(input.body.model !== undefined
|
|
236
|
-
? { model: { providerID: input.body.model.providerID, modelID: input.body.model.id } }
|
|
237
|
-
: {}),
|
|
238
|
-
parts: input.body.parts,
|
|
239
|
-
},
|
|
240
|
-
})
|
|
241
|
-
if (result.error) throw new Error(sdkErrorMessage(result.error))
|
|
242
|
-
return result.data
|
|
243
|
-
},
|
|
244
|
-
messages: async (input) => {
|
|
245
|
-
const path = safeSessionPath(input.path.id)
|
|
246
|
-
if (!path) {
|
|
247
|
-
log.warn(`[Pantheon Plugin] messages skipped: invalid sessionID "${input.path.id}"`)
|
|
248
|
-
return []
|
|
249
|
-
}
|
|
250
|
-
const result = await client.session.messages({ path: path.path })
|
|
251
|
-
if (result.error) throw new Error(sdkErrorMessage(result.error))
|
|
252
|
-
return result.data
|
|
253
|
-
},
|
|
254
|
-
},
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
|
|
258
173
|
/**
|
|
259
174
|
* Adapt the opencode SDK client to the structural TodoEnforcerClient the
|
|
260
175
|
* todo enforcer expects: unwraps the SDK `{data, error, request, response}`
|
|
@@ -335,7 +250,6 @@ function adaptTodoEnforcerClient(client: PluginInput['client']): TodoEnforcerCli
|
|
|
335
250
|
* Helpers live in src/pantheon/vision.ts and are imported from there directly.
|
|
336
251
|
*/
|
|
337
252
|
const plugin: Plugin = async (input: PluginInput) => {
|
|
338
|
-
terminalToastClient = input.client as unknown as ToastClient
|
|
339
253
|
// release-134: runtime guard against DOUBLE registration (duplicate toasts +
|
|
340
254
|
// duplicate task_ids in one process — 2026-08-12 live logs). opencode
|
|
341
255
|
// merges the global config (npm-package plugin paths) with the project
|
|
@@ -350,67 +264,12 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
350
264
|
// is older than the installed package. Runs once, fail-open, no auto-sync.
|
|
351
265
|
checkTuiVersionStaleness((msg) => log.warn(msg))
|
|
352
266
|
const vision = createVisionHandler(input)
|
|
353
|
-
// Phase 2/3: background delegation toolset + the bound finalize lifecycle
|
|
354
|
-
// hook. Completion is observed through the event hook below
|
|
355
|
-
// (session.idle / session.error on a child) → finalizeDelegation.
|
|
356
|
-
// Fase B3: runtime controls for the two read-only exceptions and the real
|
|
357
|
-
// per-child wall-clock timer. No task_budget field is injected into the
|
|
358
|
-
// plugin/frontmatter; these are enforced by delegation.ts itself.
|
|
359
|
-
const delegationBudgets = resolveDelegationBudgets()
|
|
360
|
-
const wallClockTimeoutMs = resolveDelegationTimeoutMs()
|
|
361
|
-
|
|
362
|
-
const delegation = createDelegationTools({
|
|
363
|
-
board,
|
|
364
|
-
client: adaptDelegationClient(input.client),
|
|
365
|
-
options: {
|
|
366
|
-
rootSessions,
|
|
367
|
-
isChildSession: (sessionID) => sessionHierarchy.isChild(sessionID),
|
|
368
|
-
registerChildSession: (sessionID, parentID) => {
|
|
369
|
-
sessionHierarchy.register({ id: sessionID, parentID })
|
|
370
|
-
},
|
|
371
|
-
enforceRuntimeMatrix: true,
|
|
372
|
-
readOnlyAgents: new Set(['apollo', 'gaia']),
|
|
373
|
-
// Only an explicitly active routing profile may provide child models.
|
|
374
|
-
// An empty map deliberately lets OpenCode inherit the parent model.
|
|
375
|
-
agentModels: loadRoutingAgentModels({
|
|
376
|
-
candidates: activePresetCandidates(),
|
|
377
|
-
logger: log,
|
|
378
|
-
}),
|
|
379
|
-
delegationBudgets,
|
|
380
|
-
stepCapTracker,
|
|
381
|
-
...(routingPermissionTask != null ? { permissionTask: routingPermissionTask } : {}),
|
|
382
|
-
...(wallClockTimeoutMs !== undefined ? { wallClockTimeoutMs } : {}),
|
|
383
|
-
},
|
|
384
|
-
})
|
|
385
|
-
|
|
386
|
-
// Proactive idle-child scan (Fix 2): finalize children that are idle on the
|
|
387
|
-
// board but have no MD report on disk — eliminates the phantom "running"
|
|
388
|
-
// window. Runs every 30s (configurable via PANTHEON_IDLE_SCAN_INTERVAL_MS),
|
|
389
|
-
// unref'd so it never holds the process open. Fail-open by design.
|
|
390
|
-
const idleScanIntervalMs = Number(process.env.PANTHEON_IDLE_SCAN_INTERVAL_MS) || undefined
|
|
391
|
-
startIdleChildScan(
|
|
392
|
-
{
|
|
393
|
-
board,
|
|
394
|
-
finalize: (childSessionID, opts) => delegation.finalizeDelegation(childSessionID, opts),
|
|
395
|
-
hasReport: (job) => {
|
|
396
|
-
try {
|
|
397
|
-
const filePath = join('.pantheon/delegations', job.parentSessionID, `${job.alias}.md`)
|
|
398
|
-
return existsSync(filePath)
|
|
399
|
-
} catch {
|
|
400
|
-
return false
|
|
401
|
-
}
|
|
402
|
-
},
|
|
403
|
-
logger: log,
|
|
404
|
-
},
|
|
405
|
-
idleScanIntervalMs,
|
|
406
|
-
)
|
|
407
|
-
|
|
408
267
|
// Wave 1 (PR #46): TODO continuation enforcer for root/non-board sessions.
|
|
409
268
|
// Mirrors routing.yml `todo_enforcer` (plugin scope has no routing.yml
|
|
410
|
-
// access — COMPACTION_MAX_ITEMS pattern). The event hook routes
|
|
411
|
-
// session.idle events here
|
|
412
|
-
//
|
|
413
|
-
//
|
|
269
|
+
// access — COMPACTION_MAX_ITEMS pattern). The event hook routes all
|
|
270
|
+
// session.idle events here. Runtime kill-switch: PANTHEON_TODO_ENFORCER=off
|
|
271
|
+
// (routing.yml is a doc mirror only — the env var is the real switch,
|
|
272
|
+
// PANTHEON_TOASTS pattern).
|
|
414
273
|
const todoEnforcer = new TodoEnforcer({
|
|
415
274
|
client: adaptTodoEnforcerClient(input.client),
|
|
416
275
|
board,
|
|
@@ -467,7 +326,7 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
467
326
|
await readEnhancer(input, output)
|
|
468
327
|
}
|
|
469
328
|
|
|
470
|
-
// Wave 4 (PR #46): /cost —
|
|
329
|
+
// Wave 4 (PR #46): /cost — cost + token visibility by agent. Reads
|
|
471
330
|
// opencode.db read-only (node:sqlite, falls back to scripts/cost.mjs).
|
|
472
331
|
// Fully wired (unlike dispatch-guard, which is manual-orchestration-only
|
|
473
332
|
// because opencode 1.18.x cannot intercept task completion via hooks).
|
|
@@ -479,11 +338,11 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
479
338
|
// compaction-134 seed: opencode does NOT replay `session.created`
|
|
480
339
|
// events for sessions that exist before plugin load, so after a
|
|
481
340
|
// restart the resumed ROOT session never enters rootSessions and the
|
|
482
|
-
// depth guard would
|
|
483
|
-
//
|
|
484
|
-
//
|
|
485
|
-
//
|
|
486
|
-
//
|
|
341
|
+
// depth guard would treat it as a child. Seed the registry from
|
|
342
|
+
// session.list() in the background — every session WITHOUT a parentID
|
|
343
|
+
// is a root. Fail-open: a failed/throttled list leaves the set
|
|
344
|
+
// untouched (the hierarchy registry treats unknown sessions as roots),
|
|
345
|
+
// and config setup must never wait for the session API.
|
|
487
346
|
// Context sandbox config — opencode.json → context_sandbox: {enabled, limits}
|
|
488
347
|
try {
|
|
489
348
|
const raw = (config as unknown as Record<string, unknown>).context_sandbox
|
|
@@ -528,18 +387,16 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
528
387
|
log.warn('[plugin] preset application failed (see logs for details)')
|
|
529
388
|
}
|
|
530
389
|
},
|
|
531
|
-
// Phase 2:
|
|
532
|
-
//
|
|
390
|
+
// Phase 2: structural tool registrations (matches the `tool` hook field
|
|
391
|
+
// shape). Native task() owns orchestration; the plugin registers the
|
|
392
|
+
// deterministic /pantheon-model, /cost and full-auto goal tools.
|
|
533
393
|
tool: {
|
|
534
|
-
pantheon_delegate: delegation.pantheon_delegate,
|
|
535
|
-
pantheon_delegation_read: delegation.pantheon_delegation_read,
|
|
536
|
-
pantheon_delegation_list: delegation.pantheon_delegation_list,
|
|
537
394
|
hashline_edit: hashlineEdit,
|
|
538
395
|
// Wave 3 (PR #46): full-auto goal tools (single active goal/session).
|
|
539
396
|
pantheon_goal_create: goalTools.pantheon_goal_create,
|
|
540
397
|
pantheon_goal_update: goalTools.pantheon_goal_update,
|
|
541
398
|
pantheon_goal_get: goalTools.pantheon_goal_get,
|
|
542
|
-
// Wave 4 (PR #46): /cost —
|
|
399
|
+
// Wave 4 (PR #46): /cost — cost report by agent from opencode.db.
|
|
543
400
|
pantheon_cost: costCommand.pantheon_cost,
|
|
544
401
|
// Deterministic /pantheon-model management of top-level model fields.
|
|
545
402
|
pantheon_model: modelCommand.pantheon_model,
|
|
@@ -573,22 +430,13 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
573
430
|
if (ev.type === 'session.compacted') {
|
|
574
431
|
await todoPreserver.onCompacted(ev.properties.sessionID)
|
|
575
432
|
}
|
|
576
|
-
//
|
|
577
|
-
//
|
|
578
|
-
//
|
|
579
|
-
// are
|
|
433
|
+
// Native task() children are owned by OpenCode. Idle routing for
|
|
434
|
+
// generic root/non-board sessions remains priority ordered: an active
|
|
435
|
+
// goal owns the idle (goal loop), otherwise the todo enforcer
|
|
436
|
+
// re-injects. Neither onIdle ever throws (internal failures are logged
|
|
437
|
+
// + swallowed), so the session is safe.
|
|
580
438
|
try {
|
|
581
|
-
|
|
582
|
-
board,
|
|
583
|
-
finalize: (childSessionID, opts) => delegation.finalizeDelegation(childSessionID, opts),
|
|
584
|
-
})
|
|
585
|
-
// Wave 1/3: non-board idle → idle dispatcher. Board children are
|
|
586
|
-
// handled by handleDelegationEvent above; everything else (roots,
|
|
587
|
-
// non-board sessions) is routed by priority: an active goal owns the
|
|
588
|
-
// idle (goal loop), otherwise the todo enforcer re-injects. Neither
|
|
589
|
-
// onIdle ever throws (internal failures are logged + swallowed), so
|
|
590
|
-
// the session is safe.
|
|
591
|
-
if (!delegated && ev.type === 'session.idle') {
|
|
439
|
+
if (ev.type === 'session.idle') {
|
|
592
440
|
await idleDispatcher.onIdle(ev.properties.sessionID)
|
|
593
441
|
}
|
|
594
442
|
} catch (err) {
|