pantheon-opencode 1.3.5 → 1.3.7
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 +33 -1
- package/package.json +3 -3
- package/scripts/_pantheon_paths.py +0 -1
- package/scripts/build-agents-md.mjs +4 -2
- package/scripts/code_mode_server.py +307 -21
- package/scripts/doctor.mjs +26 -13
- package/scripts/hooks/format-multi-language.sh +6 -3
- package/scripts/hooks/log-session-start.sh +6 -0
- package/scripts/hooks/on-subagent-delegation-start.sh +22 -6
- package/scripts/hooks/validate-post-conditions.sh +1 -1
- package/scripts/hooks/validate-tool-safety.sh +5 -2
- package/scripts/install/agents-md.mjs +3 -1
- package/scripts/install/cli-ui.mjs +24 -4
- package/scripts/install/interactive.mjs +18 -7
- package/scripts/install/migrate-config.mjs +13 -19
- package/scripts/install/migrate.mjs +8 -5
- package/scripts/install/model-picker.mjs +4 -1
- package/scripts/install/opencode.mjs +2 -0
- package/scripts/install/shared.mjs +6 -4
- package/scripts/install/state.mjs +9 -6
- package/scripts/mcp_persistence_server.py +23 -22
- package/scripts/mcp_resources_server.py +60 -1
- package/scripts/memory_mcp_server.py +80 -4
- package/scripts/postinstall.mjs +27 -25
- package/scripts/prune.mjs +1 -1
- package/scripts/redaction-gate.mjs +3 -1
- package/scripts/sync-tui.mjs +148 -0
- package/scripts/uninstall.mjs +0 -2
- package/scripts/validation-policy.mjs +6 -1
- package/scripts/version-check.mjs +9 -4
- package/src/agents/aphrodite.md +1 -1
- package/src/agents/demeter.md +1 -1
- package/src/agents/hephaestus.md +1 -1
- package/src/agents/hermes.md +1 -1
- package/src/agents/themis.md +4 -0
- package/src/mcp/code_mode_server.py +304 -18
- package/src/mcp/eval_store.py +233 -0
- package/src/mcp/mcp_persistence_server.py +23 -22
- package/src/mcp/mcp_resources_server.py +58 -0
- package/src/mcp/memory_mcp_server.py +80 -3
- package/src/pantheon/auto-wake.ts +2 -4
- package/src/pantheon/background-job-board.ts +17 -4
- package/src/pantheon/comment-checker.ts +48 -0
- package/src/pantheon/delegation-classifier.ts +307 -0
- package/src/pantheon/delegation-enforce.ts +21 -21
- package/src/pantheon/delegation-finalize.ts +44 -4
- package/src/pantheon/delegation-notify.ts +97 -0
- package/src/pantheon/delegation.ts +413 -23
- package/src/pantheon/dispatch-guard.ts +2 -0
- package/src/pantheon/file-persistence.ts +7 -3
- package/src/pantheon/permission-eval.ts +284 -0
- package/src/pantheon/permission-globs.ts +86 -0
- package/src/pantheon/presets.d.mts +41 -0
- package/src/pantheon/presets.mjs +134 -0
- package/src/pantheon/retry-policy.ts +212 -0
- package/src/pantheon/session-guard.ts +1 -3
- package/src/pantheon/step-cap.ts +110 -0
- package/src/pantheon/tui-version-check.ts +72 -0
- package/src/pantheon/vision.ts +7 -5
- package/src/pantheon/zeus-delegate-with-retry.ts +81 -12
- package/src/plugin.ts +57 -3
- package/src/plugins/hook-runner.ts +1 -1
- package/src/plugins/pantheon-hooks.ts +1 -1
- package/src/plugins/tui/dist/tui.d.ts +1 -1
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +19 -14
- package/src/plugins/tui/dist/tui.tsx +52 -18
- package/src/plugins/tui/package.json +1 -1
- package/src/plugins/tui/src/index.tsx +106 -24
- package/src/plugins/tui/tsdown.config.ts +1 -1
- package/src/routing.yml +42 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delegation Classifier — stuck-agent detection for delegation results.
|
|
3
|
+
*
|
|
4
|
+
* Classifies delegation results into actionable statuses:
|
|
5
|
+
* - `content` / `success` — agent returned usable output
|
|
6
|
+
* - `empty` (empty-mode1 / empty-mode2) — no or near-no output; retryable
|
|
7
|
+
* - `budget-exhausted` — agent hit max_steps; partial result available
|
|
8
|
+
* - `timeout` (timeout-with-partial / timeout-empty / timeout-exhausted)
|
|
9
|
+
* - `error` — provider or infrastructure failure
|
|
10
|
+
*
|
|
11
|
+
* Integrates with the existing `dispatch-guard.ts` classification for
|
|
12
|
+
* empty-response detection and provides a structured `DelegationResult`
|
|
13
|
+
* that replaces raw-text returns.
|
|
14
|
+
*
|
|
15
|
+
* @module delegation-classifier
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// ─── Types ─────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/** Empty-response mode classification (matches dispatch-guard). */
|
|
21
|
+
export type EmptyMode = 'empty-mode1' | 'empty-mode2'
|
|
22
|
+
|
|
23
|
+
/** Timeout sub-type based on partial output and retry history. */
|
|
24
|
+
export type TimeoutSubType = 'timeout-with-partial' | 'timeout-empty' | 'timeout-exhausted'
|
|
25
|
+
|
|
26
|
+
/** Structured delegation result replacing raw-text returns. */
|
|
27
|
+
export interface DelegationResult {
|
|
28
|
+
status:
|
|
29
|
+
| 'success'
|
|
30
|
+
| 'empty'
|
|
31
|
+
| 'budget-exhausted'
|
|
32
|
+
| 'timeout'
|
|
33
|
+
| 'error'
|
|
34
|
+
| 'startup_failed'
|
|
35
|
+
| 'bootstrap_unknown'
|
|
36
|
+
content: string
|
|
37
|
+
retryCount: number
|
|
38
|
+
/** When status is 'empty', which empty mode was detected. */
|
|
39
|
+
classification?: EmptyMode | undefined
|
|
40
|
+
/** When status is 'timeout', the timeout sub-type. */
|
|
41
|
+
subType?: TimeoutSubType | undefined
|
|
42
|
+
/** Available when budget-exhausted or timeout-with-partial. */
|
|
43
|
+
partialResult?: string | undefined
|
|
44
|
+
/** Actionable recommendation for the orchestrator. */
|
|
45
|
+
recommendation?: string | undefined
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Result of empty-response classification. */
|
|
49
|
+
export interface EmptyClassification {
|
|
50
|
+
mode: EmptyMode | 'content'
|
|
51
|
+
hasContent: boolean
|
|
52
|
+
hasTokens: boolean
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Timeout classification input. */
|
|
56
|
+
export interface TimeoutClassifyInput {
|
|
57
|
+
/** Report markdown (undefined if no report was written). */
|
|
58
|
+
report: string | undefined
|
|
59
|
+
/** Whether the child session had any messages at all. */
|
|
60
|
+
hasMessages: boolean
|
|
61
|
+
/** How many retries have already been attempted (0 or 1). */
|
|
62
|
+
retryCount: number
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Result of stuck-agent classification. */
|
|
66
|
+
export interface StuckClassification {
|
|
67
|
+
status: 'success' | 'budget-exhausted' | 'empty'
|
|
68
|
+
partialResult?: string | undefined
|
|
69
|
+
recommendation?: string | undefined
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── Patterns ──────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/** Matches budget-exhausted messages from the agent or step-cap system. */
|
|
75
|
+
const BUDGET_EXHAUSTED_RE =
|
|
76
|
+
/maximum\s+(?:steps?|step\s*budget)\s+(?:for\s+this\s+agent\s+)?(?:has|have)\s+been\s+reached|step\s*budget\s+(?:was\s+)?exhausted/i
|
|
77
|
+
|
|
78
|
+
/** Output-captured marker in delegation reports. */
|
|
79
|
+
const NO_OUTPUT_RE = /_No output captured\._/i
|
|
80
|
+
|
|
81
|
+
// ─── Empty-Response Classification ────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Classify whether a response is empty, empty-with-tokens, or has content.
|
|
85
|
+
* Matches the dispatch-guard classification (empty-mode1, empty-mode2).
|
|
86
|
+
*
|
|
87
|
+
* @param text The response text (may be null/undefined).
|
|
88
|
+
* @param tokens Optional token counts for mode2 detection.
|
|
89
|
+
*/
|
|
90
|
+
export function classifyEmptyResponse(
|
|
91
|
+
text: string | null | undefined,
|
|
92
|
+
tokens?: { tokensInput?: number; tokensOutput?: number },
|
|
93
|
+
): EmptyClassification {
|
|
94
|
+
const hasContent = typeof text === 'string' && text.trim() !== ''
|
|
95
|
+
if (hasContent) {
|
|
96
|
+
return { mode: 'content', hasContent: true, hasTokens: hasTokens(tokens) }
|
|
97
|
+
}
|
|
98
|
+
const hasT = hasTokens(tokens)
|
|
99
|
+
return {
|
|
100
|
+
mode: hasT ? 'empty-mode2' : 'empty-mode1',
|
|
101
|
+
hasContent: false,
|
|
102
|
+
hasTokens: hasT,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Whether token counts indicate the model actually ran. */
|
|
107
|
+
function hasTokens(tokens?: { tokensInput?: number; tokensOutput?: number }): boolean {
|
|
108
|
+
if (tokens === undefined) return false
|
|
109
|
+
return (tokens.tokensInput ?? 0) > 0 || (tokens.tokensOutput ?? 0) > 0
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ─── Stuck-Agent Detection ────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Classify a report as success, budget-exhausted, or empty.
|
|
116
|
+
* Detects max-steps patterns in the report text.
|
|
117
|
+
*
|
|
118
|
+
* @param report The delegation report markdown.
|
|
119
|
+
*/
|
|
120
|
+
export function classifyStuckAgent(report: string): StuckClassification {
|
|
121
|
+
const trimmed = report.trim()
|
|
122
|
+
|
|
123
|
+
// Empty output
|
|
124
|
+
if (NO_OUTPUT_RE.test(trimmed) || trimmed === '') {
|
|
125
|
+
return { status: 'empty' }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Budget-exhausted: extract content before the marker as partialResult
|
|
129
|
+
if (BUDGET_EXHAUSTED_RE.test(trimmed)) {
|
|
130
|
+
const outputSection = extractOutputSection(trimmed)
|
|
131
|
+
const partialResult =
|
|
132
|
+
outputSection !== undefined && !NO_OUTPUT_RE.test(outputSection) ? outputSection : undefined
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
status: 'budget-exhausted',
|
|
136
|
+
partialResult,
|
|
137
|
+
recommendation:
|
|
138
|
+
'Agent hit step budget; partial result available. ' +
|
|
139
|
+
'Consider resuming with task_id or dispatching with larger budget.',
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { status: 'success' }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Extract the text after "## Output" in a delegation report, or undefined. */
|
|
147
|
+
function extractOutputSection(report: string): string | undefined {
|
|
148
|
+
const marker = '## Output'
|
|
149
|
+
const idx = report.indexOf(marker)
|
|
150
|
+
if (idx === -1) return undefined
|
|
151
|
+
const afterMarker = report.slice(idx + marker.length)
|
|
152
|
+
// Skip the newline after "## Output"
|
|
153
|
+
const content = afterMarker.replace(/^\n+/, '')
|
|
154
|
+
// Stop at the next section (## or **Error**)
|
|
155
|
+
const endIdx = content.search(/\n(?:##|\*\*Error\*\*|\[TIMEOUT)/)
|
|
156
|
+
return endIdx === -1 ? content.trim() : content.slice(0, endIdx).trim()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ─── Timeout Classification ───────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Classify a timeout based on available output and retry history.
|
|
163
|
+
*
|
|
164
|
+
* - timeout-empty: no report, no messages, not yet retried → retryable
|
|
165
|
+
* - timeout-with-partial: has report or messages → return partial
|
|
166
|
+
* - timeout-exhausted: already retried → escalate
|
|
167
|
+
*
|
|
168
|
+
* @param input Report availability, message presence, and retry count.
|
|
169
|
+
*/
|
|
170
|
+
export function classifyTimeout(input: TimeoutClassifyInput): DelegationResult {
|
|
171
|
+
const { report, hasMessages, retryCount } = input
|
|
172
|
+
const hasPartial =
|
|
173
|
+
(report !== undefined && report.trim() !== '' && !NO_OUTPUT_RE.test(report)) || hasMessages
|
|
174
|
+
|
|
175
|
+
// Already retried → exhausted, no more retries
|
|
176
|
+
if (retryCount >= 1) {
|
|
177
|
+
return {
|
|
178
|
+
status: 'timeout',
|
|
179
|
+
content: report ?? '',
|
|
180
|
+
retryCount,
|
|
181
|
+
subType: 'timeout-exhausted',
|
|
182
|
+
partialResult: hasPartial ? (report ?? '') : undefined,
|
|
183
|
+
recommendation:
|
|
184
|
+
'Agent timed out after retry. Options: ' +
|
|
185
|
+
'(a) try different agent, (b) simplify scope, (c) manual intervention.',
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Has partial output → return it
|
|
190
|
+
if (hasPartial) {
|
|
191
|
+
return {
|
|
192
|
+
status: 'timeout',
|
|
193
|
+
content: report ?? '',
|
|
194
|
+
retryCount,
|
|
195
|
+
subType: 'timeout-with-partial',
|
|
196
|
+
partialResult: report ?? '',
|
|
197
|
+
recommendation:
|
|
198
|
+
'Agent timed out but partial work exists. ' + 'Partial result is available above.',
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// No output at all → retryable
|
|
203
|
+
return {
|
|
204
|
+
status: 'timeout',
|
|
205
|
+
content: report ?? '',
|
|
206
|
+
retryCount,
|
|
207
|
+
subType: 'timeout-empty',
|
|
208
|
+
recommendation: 'Agent timed out with no output. Will retry once with rephrased prompt.',
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ─── Result Helpers ───────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Whether a delegation result is retryable (empty + retryCount < 1,
|
|
216
|
+
* or timeout-empty + retryCount < 1).
|
|
217
|
+
*/
|
|
218
|
+
export function isRetryableResult(result: DelegationResult): boolean {
|
|
219
|
+
if (result.retryCount >= 1) return false
|
|
220
|
+
if (result.status === 'empty') return true
|
|
221
|
+
if (result.status === 'timeout' && result.subType === 'timeout-empty') return true
|
|
222
|
+
return false
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Format a DelegationResult into a human-readable string for the orchestrator.
|
|
227
|
+
* Success results are returned as-is (the raw content). Non-success results
|
|
228
|
+
* get a structured status message.
|
|
229
|
+
*/
|
|
230
|
+
export function formatDelegationResult(result: DelegationResult): string {
|
|
231
|
+
if (result.status === 'success') {
|
|
232
|
+
return result.content
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const parts: string[] = []
|
|
236
|
+
|
|
237
|
+
// Status header
|
|
238
|
+
switch (result.status) {
|
|
239
|
+
case 'empty': {
|
|
240
|
+
const modeLabel =
|
|
241
|
+
result.classification === 'empty-mode2'
|
|
242
|
+
? 'empty-mode2 (reasoning tokens present but no text output)'
|
|
243
|
+
: 'empty-mode1 (no tokens generated)'
|
|
244
|
+
parts.push(`[EMPTY RESULT — ${modeLabel}]`)
|
|
245
|
+
if (result.retryCount === 0) {
|
|
246
|
+
parts.push(
|
|
247
|
+
'The agent returned an empty response. ' +
|
|
248
|
+
'A retry with a rephrased prompt has been triggered automatically.',
|
|
249
|
+
)
|
|
250
|
+
} else {
|
|
251
|
+
parts.push(
|
|
252
|
+
'The agent returned an empty response even after retry. ' +
|
|
253
|
+
'Options: (a) try different agent, (b) simplify scope, (c) manual intervention.',
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
break
|
|
257
|
+
}
|
|
258
|
+
case 'budget-exhausted':
|
|
259
|
+
parts.push('[BUDGET EXHAUSTED]')
|
|
260
|
+
if (result.partialResult) {
|
|
261
|
+
parts.push('Partial result:')
|
|
262
|
+
parts.push(result.partialResult)
|
|
263
|
+
}
|
|
264
|
+
parts.push(result.recommendation ?? '')
|
|
265
|
+
break
|
|
266
|
+
case 'timeout': {
|
|
267
|
+
const subLabel =
|
|
268
|
+
result.subType === 'timeout-with-partial'
|
|
269
|
+
? 'with partial output'
|
|
270
|
+
: result.subType === 'timeout-exhausted'
|
|
271
|
+
? 'exhausted after retry'
|
|
272
|
+
: 'no output'
|
|
273
|
+
parts.push(`[TIMEOUT — ${subLabel}]`)
|
|
274
|
+
if (result.partialResult) {
|
|
275
|
+
parts.push('Partial result:')
|
|
276
|
+
parts.push(result.partialResult)
|
|
277
|
+
}
|
|
278
|
+
parts.push(result.recommendation ?? '')
|
|
279
|
+
break
|
|
280
|
+
}
|
|
281
|
+
case 'error':
|
|
282
|
+
parts.push('[ERROR]')
|
|
283
|
+
parts.push(result.content)
|
|
284
|
+
break
|
|
285
|
+
case 'startup_failed':
|
|
286
|
+
parts.push('[STARTUP FAILED]')
|
|
287
|
+
parts.push(result.content)
|
|
288
|
+
parts.push(
|
|
289
|
+
result.recommendation ??
|
|
290
|
+
'Retry once or inspect the child session before dispatching again.',
|
|
291
|
+
)
|
|
292
|
+
break
|
|
293
|
+
case 'bootstrap_unknown':
|
|
294
|
+
parts.push('[BOOTSTRAP UNKNOWN]')
|
|
295
|
+
parts.push(result.content)
|
|
296
|
+
parts.push(
|
|
297
|
+
result.recommendation ?? 'The host did not expose enough session state to verify startup.',
|
|
298
|
+
)
|
|
299
|
+
break
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Structured fields for non-success results
|
|
303
|
+
parts.push(`status: ${result.status}`)
|
|
304
|
+
parts.push(`retryCount: ${result.retryCount}`)
|
|
305
|
+
|
|
306
|
+
return parts.filter((p) => p !== '').join('\n')
|
|
307
|
+
}
|
|
@@ -30,6 +30,8 @@
|
|
|
30
30
|
|
|
31
31
|
// ─── Types ─────────────────────────────────────────────────────────────
|
|
32
32
|
|
|
33
|
+
import { isAgentAllowed, type PermissionTaskConfig } from './permission-globs.ts'
|
|
34
|
+
|
|
33
35
|
/** Why a session is read-only. */
|
|
34
36
|
export interface ReadOnlyEntry {
|
|
35
37
|
/** Agent name the session was delegated to (e.g. "apollo"). */
|
|
@@ -123,11 +125,21 @@ export function normalizeDelegationAgent(agent: unknown): string | undefined {
|
|
|
123
125
|
return normalized === '' ? undefined : normalized
|
|
124
126
|
}
|
|
125
127
|
|
|
126
|
-
/**
|
|
127
|
-
|
|
128
|
+
/**
|
|
129
|
+
* Runtime delegation matrix from Fase B2, gated by the O5 permission.task
|
|
130
|
+
* glob rules when provided. The glob rules run FIRST (last-match-wins; a
|
|
131
|
+
* deny blocks the target regardless of the matrix), then the static matrix
|
|
132
|
+
* (zeus → anyone; athena/hermes → apollo only).
|
|
133
|
+
*/
|
|
134
|
+
export function isDelegationAllowed(
|
|
135
|
+
callerAgent: unknown,
|
|
136
|
+
targetAgent: unknown,
|
|
137
|
+
permissionTask?: PermissionTaskConfig,
|
|
138
|
+
): boolean {
|
|
128
139
|
const caller = normalizeDelegationAgent(callerAgent)
|
|
129
140
|
const target = normalizeDelegationAgent(targetAgent)
|
|
130
141
|
if (caller === undefined || target === undefined) return false
|
|
142
|
+
if (permissionTask !== undefined && !isAgentAllowed(permissionTask, target)) return false
|
|
131
143
|
if (caller === 'zeus') return true
|
|
132
144
|
return (caller === 'athena' || caller === 'hermes') && target === 'apollo'
|
|
133
145
|
}
|
|
@@ -154,6 +166,8 @@ export interface EnforcementGuardOptions {
|
|
|
154
166
|
isRootSession?: (sessionID: string) => boolean
|
|
155
167
|
/** SDK-backed child check used to prevent native task() depth escalation. */
|
|
156
168
|
isChildSession?: (sessionID: string) => boolean
|
|
169
|
+
/** O5 permission.task glob rules — deny removes the target entirely. */
|
|
170
|
+
permissionTask?: PermissionTaskConfig
|
|
157
171
|
/** Audit sink for denied runtime delegation attempts. */
|
|
158
172
|
logger?: { warn?: (message: string) => void }
|
|
159
173
|
}
|
|
@@ -176,22 +190,14 @@ export const DEFAULT_BLOCKED_TOOLS: ReadonlySet<string> = new Set([
|
|
|
176
190
|
* codebase reads to @apollo to avoid context bloat and enforce separation
|
|
177
191
|
* of concerns.
|
|
178
192
|
*/
|
|
179
|
-
export const ZEUS_READ_DENY_PATTERNS: ReadonlyArray<RegExp> = [
|
|
180
|
-
/^src\//,
|
|
181
|
-
/^tests?\//,
|
|
182
|
-
/^scripts?\//,
|
|
183
|
-
]
|
|
193
|
+
export const ZEUS_READ_DENY_PATTERNS: ReadonlyArray<RegExp> = [/^src\//, /^tests?\//, /^scripts?\//]
|
|
184
194
|
|
|
185
195
|
/**
|
|
186
196
|
* Path exceptions that Zeus MAY read even if they match a deny pattern.
|
|
187
197
|
* Markdown files, .pantheon/ configs, and memories/ are operational context
|
|
188
198
|
* that Zeus legitimately needs.
|
|
189
199
|
*/
|
|
190
|
-
export const ALLOWED_PATHS: ReadonlyArray<RegExp> = [
|
|
191
|
-
/\.md$/,
|
|
192
|
-
/\.pantheon\//,
|
|
193
|
-
/memories\//,
|
|
194
|
-
]
|
|
200
|
+
export const ALLOWED_PATHS: ReadonlyArray<RegExp> = [/\.md$/, /\.pantheon\//, /memories\//]
|
|
195
201
|
|
|
196
202
|
/**
|
|
197
203
|
* Zeus read guard. When the active agent is Zeus, deny read/glob/grep calls
|
|
@@ -202,11 +208,7 @@ export const ALLOWED_PATHS: ReadonlyArray<RegExp> = [
|
|
|
202
208
|
*
|
|
203
209
|
* @throws {Error} when Zeus attempts a denied read
|
|
204
210
|
*/
|
|
205
|
-
export function zeusReadGuard(
|
|
206
|
-
tool: string,
|
|
207
|
-
args: unknown,
|
|
208
|
-
agent: string | undefined,
|
|
209
|
-
): void {
|
|
211
|
+
export function zeusReadGuard(tool: string, args: unknown, agent: string | undefined): void {
|
|
210
212
|
if (agent !== 'zeus') return
|
|
211
213
|
if (tool !== 'read' && tool !== 'glob' && tool !== 'grep') return
|
|
212
214
|
|
|
@@ -226,9 +228,7 @@ export function zeusReadGuard(
|
|
|
226
228
|
for (const allowed of ALLOWED_PATHS) {
|
|
227
229
|
if (allowed.test(filePath)) return
|
|
228
230
|
}
|
|
229
|
-
throw new Error(
|
|
230
|
-
`Zeus cannot read ${filePath} — delegate to @apollo`,
|
|
231
|
-
)
|
|
231
|
+
throw new Error(`Zeus cannot read ${filePath} — delegate to @apollo`)
|
|
232
232
|
}
|
|
233
233
|
}
|
|
234
234
|
}
|
|
@@ -317,7 +317,7 @@ export function createEnforcementGuard(input: {
|
|
|
317
317
|
)
|
|
318
318
|
throw new Error(`${prefix}: session ${hook.sessionID} is not an authorized root session`)
|
|
319
319
|
}
|
|
320
|
-
if (!isDelegationAllowed(caller, target)) {
|
|
320
|
+
if (!isDelegationAllowed(caller, target, input.options?.permissionTask)) {
|
|
321
321
|
const reason =
|
|
322
322
|
caller === undefined ? 'caller agent is unavailable' : 'agent/target is not allowed'
|
|
323
323
|
input.options.logger?.warn?.(
|
|
@@ -20,6 +20,8 @@ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
|
20
20
|
import { join } from 'node:path'
|
|
21
21
|
|
|
22
22
|
import type { BackgroundJobBoard, BackgroundJobRecord } from './background-job-board.ts'
|
|
23
|
+
import type { PermissionTaskConfig } from './permission-globs.ts'
|
|
24
|
+
import type { StepCapTracker } from './step-cap.ts'
|
|
23
25
|
|
|
24
26
|
// ─── Types ─────────────────────────────────────────────────────────────
|
|
25
27
|
|
|
@@ -43,9 +45,17 @@ export interface DelegationClientSession {
|
|
|
43
45
|
}): Promise<{ id: string }>
|
|
44
46
|
promptAsync(input: {
|
|
45
47
|
path: { id: string }
|
|
46
|
-
body: {
|
|
48
|
+
body: {
|
|
49
|
+
agent: string
|
|
50
|
+
model?: { id: string; providerID: string }
|
|
51
|
+
parts: Array<{ type: 'text'; text: string }>
|
|
52
|
+
}
|
|
53
|
+
/** Request cancellation for hosts that support AbortSignal. */
|
|
54
|
+
signal?: AbortSignal
|
|
47
55
|
}): Promise<unknown>
|
|
48
|
-
messages(input: { path: { id: string } }): Promise<Array<DelegationMessageBundle>>
|
|
56
|
+
messages?(input: { path: { id: string } }): Promise<Array<DelegationMessageBundle>>
|
|
57
|
+
/** Optional session metadata endpoint (older hosts do not expose it). */
|
|
58
|
+
get?(input: { path: { id: string } }): Promise<unknown>
|
|
49
59
|
}
|
|
50
60
|
|
|
51
61
|
/** The client surface used by the delegation core. */
|
|
@@ -109,6 +119,34 @@ export interface DelegationOptions {
|
|
|
109
119
|
presetEnv?: Record<string, string | undefined>
|
|
110
120
|
/** Warning sink for the no-model fallback (defaults to console). */
|
|
111
121
|
logger?: { warn?: (msg: string) => void }
|
|
122
|
+
/**
|
|
123
|
+
* R4: per-agent step-cap tracker (routing.yml `agents.<name>.max_steps`).
|
|
124
|
+
* When an agent reaches max_steps the delegation is forced to
|
|
125
|
+
* summarize-and-stop: skipped with a capped summary when already at the
|
|
126
|
+
* cap, or the prompt is appended with a stop instruction when the cap is
|
|
127
|
+
* hit by this dispatch.
|
|
128
|
+
*/
|
|
129
|
+
stepCapTracker?: StepCapTracker
|
|
130
|
+
/**
|
|
131
|
+
* O5: permission.task glob rules controlling which subagents may be
|
|
132
|
+
* invoked. Denied agents are removed from the delegate tool description
|
|
133
|
+
* entirely (not just blocked at call time).
|
|
134
|
+
*/
|
|
135
|
+
permissionTask?: PermissionTaskConfig
|
|
136
|
+
/**
|
|
137
|
+
* O5: candidate agent names for the delegate tool description. Defaults
|
|
138
|
+
* to the canonical 14-agent roster.
|
|
139
|
+
*/
|
|
140
|
+
agentNames?: readonly string[]
|
|
141
|
+
/** Bounded post-prompt bootstrap watchdog window. */
|
|
142
|
+
bootstrapTimeoutMs?: number
|
|
143
|
+
/** Timeout for the promptAsync request itself (including AbortSignal). */
|
|
144
|
+
promptTimeoutMs?: number
|
|
145
|
+
/** Poll interval used by the bootstrap watchdog. */
|
|
146
|
+
bootstrapPollIntervalMs?: number
|
|
147
|
+
/** Injectable clock/sleeper for deterministic watchdog tests. */
|
|
148
|
+
now?: () => number
|
|
149
|
+
sleep?: (ms: number) => Promise<void>
|
|
112
150
|
}
|
|
113
151
|
|
|
114
152
|
/** Dependencies threaded to the finalize path. */
|
|
@@ -120,7 +158,7 @@ export interface DelegationDeps {
|
|
|
120
158
|
|
|
121
159
|
/** Terminal transition requested by the finalize path. */
|
|
122
160
|
export interface FinalizeInput {
|
|
123
|
-
state: 'completed' | 'error' | 'cancelled'
|
|
161
|
+
state: 'completed' | 'error' | 'startup_failed' | 'startup_unknown' | 'cancelled'
|
|
124
162
|
error?: string
|
|
125
163
|
timedOut?: boolean
|
|
126
164
|
}
|
|
@@ -136,6 +174,8 @@ export const DELEGATION_DEFAULTS = {
|
|
|
136
174
|
|
|
137
175
|
/** Concatenate every non-empty text part across the child's messages. */
|
|
138
176
|
async function pullOutput(client: DelegationClient, childSessionID: string): Promise<string> {
|
|
177
|
+
if (typeof client.session.messages !== 'function')
|
|
178
|
+
return '(child session messages API unavailable)'
|
|
139
179
|
try {
|
|
140
180
|
const bundles = await client.session.messages({ path: { id: childSessionID } })
|
|
141
181
|
const lines: string[] = []
|
|
@@ -277,7 +317,7 @@ export async function finalizeDelegation(
|
|
|
277
317
|
|
|
278
318
|
const status: {
|
|
279
319
|
taskID: string
|
|
280
|
-
state: 'completed' | 'error' | 'cancelled'
|
|
320
|
+
state: 'completed' | 'error' | 'startup_failed' | 'startup_unknown' | 'cancelled'
|
|
281
321
|
error?: string
|
|
282
322
|
timedOut?: boolean
|
|
283
323
|
resultSummary?: string
|
|
@@ -21,6 +21,11 @@
|
|
|
21
21
|
* acknowledgment, not a completion), so every job gets exactly ONE terminal
|
|
22
22
|
* transition — never a reconciled echo. Unknown sessions are ignored.
|
|
23
23
|
*
|
|
24
|
+
* `finalizeIdleChildrenWithoutMd` proactively finalizes children that are
|
|
25
|
+
* idle on the board but have no MD report on disk — eliminates the phantom
|
|
26
|
+
* "running" window during `pullOutput` (Fix 2 for the TUI stale-running bug).
|
|
27
|
+
* Wired into a periodic scan via `startIdleChildScan` (default 30 s cadence).
|
|
28
|
+
*
|
|
24
29
|
* Pure TypeScript — zero runtime dependencies beyond the board types.
|
|
25
30
|
*
|
|
26
31
|
* Follow-up (Themis review, Phase 5): `extractErrorMessage` handles the two
|
|
@@ -38,6 +43,9 @@ import type { FinalizeInput } from './delegation-finalize.ts'
|
|
|
38
43
|
|
|
39
44
|
export type { FinalizeInput } from './delegation-finalize.ts'
|
|
40
45
|
|
|
46
|
+
/** Default cadence for the proactive idle-child scan (30 s). */
|
|
47
|
+
export const IDLE_SCAN_INTERVAL_MS = 30_000
|
|
48
|
+
|
|
41
49
|
// ─── Types ─────────────────────────────────────────────────────────────
|
|
42
50
|
|
|
43
51
|
/** Structural view of the event payload the `event` hook receives. */
|
|
@@ -98,3 +106,92 @@ export async function handleDelegationEvent(
|
|
|
98
106
|
}
|
|
99
107
|
return true
|
|
100
108
|
}
|
|
109
|
+
|
|
110
|
+
// ─── Proactive finalize (Fix 2) ────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
/** Dependencies for idle-child scanning. */
|
|
113
|
+
export interface IdleChildScanDeps {
|
|
114
|
+
/** Look up a board job by session ID (child session IDs are task IDs). */
|
|
115
|
+
board: {
|
|
116
|
+
get(taskID: string): BackgroundJobRecord | undefined
|
|
117
|
+
list(parentSessionID?: string): BackgroundJobRecord[]
|
|
118
|
+
}
|
|
119
|
+
/** Bound Phase 2 finalize — clears the timeout timer + writes the report. */
|
|
120
|
+
finalize: (childSessionID: string, opts: FinalizeInput) => Promise<unknown>
|
|
121
|
+
/**
|
|
122
|
+
* Check whether an MD report exists for the given board job.
|
|
123
|
+
* Receives the full job record so the caller can construct the report
|
|
124
|
+
* path from `job.parentSessionID` + `job.alias`.
|
|
125
|
+
* If the function throws, the scan skips that child (fail-open).
|
|
126
|
+
*/
|
|
127
|
+
hasReport: (job: BackgroundJobRecord) => boolean
|
|
128
|
+
/** Optional logger for diagnostic messages. */
|
|
129
|
+
logger?: { info?: (msg: string) => void; warn?: (msg: string) => void }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Scan running board jobs for children whose sessions are idle but have no MD
|
|
134
|
+
* report on disk, and proactively finalize them. This eliminates the phantom
|
|
135
|
+
* "running" window that occurs between `session.idle` firing and the
|
|
136
|
+
* `pullOutput`→`writeDelegationReport` cycle completing.
|
|
137
|
+
*
|
|
138
|
+
* Called from the periodic idle-child scan (`startIdleChildScan`, default 30 s).
|
|
139
|
+
* The scan is O(N) where N = number of running jobs (typically < 10).
|
|
140
|
+
*
|
|
141
|
+
* Each child is finalized exactly once — the board's same-terminal idempotency
|
|
142
|
+
* ensures repeated scans are harmless. Errors are caught per-child so one
|
|
143
|
+
* failure never blocks the rest.
|
|
144
|
+
*
|
|
145
|
+
* @returns Number of children finalized (for diagnostics).
|
|
146
|
+
*/
|
|
147
|
+
export async function finalizeIdleChildrenWithoutMd(
|
|
148
|
+
deps: IdleChildScanDeps,
|
|
149
|
+
): Promise<number> {
|
|
150
|
+
const runningJobs = deps.board
|
|
151
|
+
.list()
|
|
152
|
+
.filter((j: BackgroundJobRecord) => j.state === 'running')
|
|
153
|
+
|
|
154
|
+
let finalized = 0
|
|
155
|
+
for (const job of runningJobs) {
|
|
156
|
+
try {
|
|
157
|
+
// If the child already has an MD report, finalize was already handled
|
|
158
|
+
// by handleDelegationEvent — skip.
|
|
159
|
+
if (deps.hasReport(job)) continue
|
|
160
|
+
// Child is idle on the board but has no report — proactively finalize.
|
|
161
|
+
await deps.finalize(job.taskID, { state: 'completed' })
|
|
162
|
+
finalized++
|
|
163
|
+
} catch (err) {
|
|
164
|
+
deps.logger?.warn?.(
|
|
165
|
+
`[delegation-notify] proactive finalize failed for ${job.taskID}: ${err}`,
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return finalized
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ─── Periodic scan launcher ─────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Start a periodic scan that proactively finalizes idle board children
|
|
176
|
+
* without MD reports. The scan runs every `intervalMs` (default 30 s) and
|
|
177
|
+
* is **unref'd** so it never holds the process open on its own.
|
|
178
|
+
*
|
|
179
|
+
* The timer is idempotent — calling this function multiple times is safe
|
|
180
|
+
* (each call creates an independent timer, but the underlying finalize
|
|
181
|
+
* operation is itself idempotent due to the board's same-terminal guard).
|
|
182
|
+
*
|
|
183
|
+
* @returns A `NodeJS.Timeout` handle that can be cleared with
|
|
184
|
+
* `clearInterval(handle)` to stop the scan.
|
|
185
|
+
*/
|
|
186
|
+
export function startIdleChildScan(
|
|
187
|
+
deps: IdleChildScanDeps,
|
|
188
|
+
intervalMs: number = IDLE_SCAN_INTERVAL_MS,
|
|
189
|
+
): NodeJS.Timeout {
|
|
190
|
+
const timer = setInterval(() => {
|
|
191
|
+
void finalizeIdleChildrenWithoutMd(deps).catch((err) => {
|
|
192
|
+
deps.logger?.warn?.(`[delegation-notify] periodic idle scan failed: ${err}`)
|
|
193
|
+
})
|
|
194
|
+
}, intervalMs)
|
|
195
|
+
timer.unref()
|
|
196
|
+
return timer
|
|
197
|
+
}
|