pantheon-opencode 1.5.0-beta.3 → 1.5.0-beta.5
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/.pantheon/code-mode/manifest.json +21 -0
- package/CHANGELOG.md +871 -0
- package/README.md +101 -1
- package/README.pt-BR.md +55 -1
- package/bin/pantheon-init.mjs +178 -21
- package/package.json +14 -10
- package/plugin.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/code_mode_server.py +339 -28
- package/scripts/doctor.mjs +178 -9
- package/scripts/install/cli-ui.mjs +17 -11
- package/scripts/install/config-migration.mjs +4 -6
- package/scripts/install/opencode.mjs +116 -35
- package/scripts/install/shared.mjs +43 -2
- package/scripts/install/strings.mjs +129 -0
- package/scripts/install-mcp.mjs +1 -0
- package/scripts/probe-pantheon-cost.mjs +10 -1
- package/scripts/release-beta-version.mjs +2 -7
- package/scripts/sync-artifacts.mjs +169 -0
- package/scripts/sync-tui.mjs +124 -34
- package/src/agents/zeus.md +2 -2
- package/src/mcp/code_mode_server.py +339 -28
- package/src/pantheon/auto-wake.ts +1 -5
- package/src/pantheon/background-job-board.ts +14 -0
- package/src/pantheon/cost-command.ts +62 -42
- package/src/pantheon/delegate-manager.ts +109 -12
- package/src/pantheon/native-probe.ts +234 -0
- package/src/pantheon/native-task-status.ts +139 -0
- package/src/pantheon/shared-board.ts +37 -0
- package/src/pantheon/task-result-guard.ts +107 -0
- package/src/pantheon/token-opt.ts +18 -110
- package/src/pantheon/tool-ceiling.ts +159 -0
- package/src/pantheon/v2-events.ts +10 -1
- package/src/plugin.ts +25 -9
- package/src/plugins/pantheon-hooks.ts +51 -0
- package/src/plugins/tui/dist/server.d.ts +1 -1
- package/src/plugins/tui/dist/server.d.ts.map +1 -1
- package/src/plugins/tui/dist/server.js +1 -1
- package/src/plugins/tui/dist/server.js.map +1 -1
- package/src/plugins/tui/dist/tui.d.ts +1 -1
- package/src/plugins/tui/dist/tui.js +1 -1
- package/src/plugins/tui/dist/tui.js.map +1 -1
- package/src/plugins/tui/package-lock.json +2 -2
- package/src/plugins/tui/package.json +1 -1
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task Result Guard — detects empty task() results from native opencode
|
|
3
|
+
* subagent calls and converts them to explicit errors.
|
|
4
|
+
*
|
|
5
|
+
* Bug context (reproduced 2026-09-10): task() native creates a child session
|
|
6
|
+
* that inherits the parent transcript. When the payload exceeds the provider's
|
|
7
|
+
* admission limit (e.g. free-tier max_outstanding_uncached_prefill_tokens=1000
|
|
8
|
+
* vs 57k incoming tokens), the stream errors with BackendAdmissionRejected.
|
|
9
|
+
* The error is NOT propagated — the child loop exits normally, the child
|
|
10
|
+
* session goes idle with zero assistant/tool output, and opencode marks the
|
|
11
|
+
* task as `completed` with empty content. The parent LLM receives an empty
|
|
12
|
+
* result and proceeds as if the task succeeded.
|
|
13
|
+
*
|
|
14
|
+
* The guard intercepts this in the `tool.execute.after` hook: when the tool
|
|
15
|
+
* is `task` and the output is empty/whitespace-only, it replaces the output
|
|
16
|
+
* with an explicit error message so the parent sees a failure, not silence.
|
|
17
|
+
*
|
|
18
|
+
* Pure TypeScript — zero runtime dependencies. Meant to be wired into the
|
|
19
|
+
* `combinedAfter` hook chain in plugin.ts alongside the sandbox and read
|
|
20
|
+
* enhancer handlers.
|
|
21
|
+
*
|
|
22
|
+
* @module task-result-guard
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { createPantheonLogger } from './logger.ts'
|
|
26
|
+
|
|
27
|
+
const log = createPantheonLogger({ module: 'pantheon-task-result-guard' })
|
|
28
|
+
|
|
29
|
+
// ─── Types ─────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/** Input shape of the opencode `tool.execute.after` hook. */
|
|
32
|
+
export interface TaskGuardInput {
|
|
33
|
+
tool: string
|
|
34
|
+
sessionID: string
|
|
35
|
+
callID: string
|
|
36
|
+
args: unknown
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Output shape of the opencode `tool.execute.after` hook (mutable). */
|
|
40
|
+
export interface TaskGuardOutput {
|
|
41
|
+
title?: string
|
|
42
|
+
output: string
|
|
43
|
+
metadata?: Record<string, unknown>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The `tool.execute.after` handler shape. */
|
|
47
|
+
export type TaskResultGuardHandler = (
|
|
48
|
+
input: TaskGuardInput,
|
|
49
|
+
output: TaskGuardOutput,
|
|
50
|
+
) => Promise<void>
|
|
51
|
+
|
|
52
|
+
export interface TaskResultGuardOptions {
|
|
53
|
+
/** Injectable logger (defaults to the Pantheon file log). */
|
|
54
|
+
logger?: { warn: (message: string) => void }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── Constants ─────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The error message injected when a task() result is empty.
|
|
61
|
+
* Matches the existing delegation-finalize.ts contract:
|
|
62
|
+
* "Child session produced no assistant or tool output".
|
|
63
|
+
*/
|
|
64
|
+
export const EMPTY_TASK_ERROR =
|
|
65
|
+
'ERROR: task() returned empty output — child session produced no assistant or tool output. ' +
|
|
66
|
+
'This typically means the provider rejected the request (admission control / rate limit) ' +
|
|
67
|
+
'or the child session crashed before producing any response. ' +
|
|
68
|
+
'Consider using pantheon_delegate (clean context) instead of task() for large payloads.'
|
|
69
|
+
|
|
70
|
+
// ─── Implementation ────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Whether a result carries any text content (whitespace-only counts as empty).
|
|
74
|
+
*/
|
|
75
|
+
function isEmptyOutput(output: unknown): boolean {
|
|
76
|
+
if (output == null) return true
|
|
77
|
+
if (typeof output !== 'string') return true
|
|
78
|
+
return output.trim() === ''
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Create a task result guard handler for the `tool.execute.after` hook.
|
|
83
|
+
*
|
|
84
|
+
* When the tool is `task` and the output is empty/whitespace-only, replaces
|
|
85
|
+
* the output with an explicit error message. All other tools pass through
|
|
86
|
+
* untouched.
|
|
87
|
+
*
|
|
88
|
+
* @returns A `ToolExecuteAfterHandler`-compatible async function.
|
|
89
|
+
*/
|
|
90
|
+
export function createTaskResultGuard(options?: TaskResultGuardOptions): TaskResultGuardHandler {
|
|
91
|
+
const warn = options?.logger?.warn ?? ((message: string) => log.warn(message))
|
|
92
|
+
|
|
93
|
+
return async (input: TaskGuardInput, output: TaskGuardOutput): Promise<void> => {
|
|
94
|
+
// Only intercept the `task` tool — all others pass through untouched.
|
|
95
|
+
if (input.tool !== 'task') return
|
|
96
|
+
|
|
97
|
+
// Only flag truly empty results — non-empty content is the happy path.
|
|
98
|
+
if (!isEmptyOutput(output.output)) return
|
|
99
|
+
|
|
100
|
+
warn(
|
|
101
|
+
`[task-result-guard] Empty task() result detected on session ${input.sessionID} ` +
|
|
102
|
+
`(callID=${input.callID}). Replacing with explicit error.`,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
output.output = EMPTY_TASK_ERROR
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -35,6 +35,13 @@
|
|
|
35
35
|
|
|
36
36
|
import type { ToonValue } from './toon-codec.ts'
|
|
37
37
|
|
|
38
|
+
export {
|
|
39
|
+
type HostToolCeilingSource,
|
|
40
|
+
probeToolCeiling,
|
|
41
|
+
type ToolCeilingResult,
|
|
42
|
+
type ToolCeilingSnapshot,
|
|
43
|
+
} from './tool-ceiling.ts'
|
|
44
|
+
|
|
38
45
|
// ─── Deterministic token estimator (WS0 extension, tokens-only) ──────────
|
|
39
46
|
|
|
40
47
|
/** Nominal chars-per-token ratio (fixed so measurements are comparable). */
|
|
@@ -220,17 +227,10 @@ export interface C9FilterResult {
|
|
|
220
227
|
/**
|
|
221
228
|
* Quality-floor signal (never silent on empty):
|
|
222
229
|
* - `ok` — at least one chunk selected;
|
|
223
|
-
* - `empty-input` — no chunks retrieved at all
|
|
224
|
-
*
|
|
225
|
-
* - `all-dropped` — chunks existed but none survived cutoff/top-k and no
|
|
226
|
-
* lexical query fallback was available;
|
|
227
|
-
* - `fallback-relative` — the absolute cutoff rejected every chunk, but a
|
|
228
|
-
* query-matching lexical/relative ranking selected auditable low-score
|
|
229
|
-
* chunks;
|
|
230
|
-
* - `nonsense` — a supplied query had no lexical overlap, so nothing is
|
|
231
|
-
* injected.
|
|
230
|
+
* - `empty-input` — no chunks retrieved at all;
|
|
231
|
+
* - `all-dropped` — chunks existed but none survived cutoff/top-k.
|
|
232
232
|
*/
|
|
233
|
-
signal: 'ok' | 'empty-input' | 'all-dropped'
|
|
233
|
+
signal: 'ok' | 'empty-input' | 'all-dropped'
|
|
234
234
|
/** Human-readable alert for the non-`ok` signals (null when `ok`). */
|
|
235
235
|
notice: string | null
|
|
236
236
|
/**
|
|
@@ -245,13 +245,13 @@ export interface C9FilterResult {
|
|
|
245
245
|
|
|
246
246
|
/**
|
|
247
247
|
* Filter scored chunks per category: drop below cutoff, keep top-k by
|
|
248
|
-
* score desc.
|
|
248
|
+
* score desc. Scores must come from retrieval; text is never used as a proxy.
|
|
249
249
|
* The injected context cost is debited (`injectedTokens`).
|
|
250
250
|
*/
|
|
251
251
|
export function c9Filter(
|
|
252
252
|
chunks: readonly C9Chunk[],
|
|
253
253
|
perCategoryOrOptions?: Readonly<Record<string, C9CategoryConfig>> | C9FilterOptions | string,
|
|
254
|
-
|
|
254
|
+
_query?: string,
|
|
255
255
|
): C9FilterResult {
|
|
256
256
|
const isOptions = isC9FilterOptions(perCategoryOrOptions)
|
|
257
257
|
const perCategory =
|
|
@@ -260,12 +260,6 @@ export function c9Filter(
|
|
|
260
260
|
: isOptions
|
|
261
261
|
? perCategoryOrOptions.perCategory
|
|
262
262
|
: perCategoryOrOptions
|
|
263
|
-
const filterQuery =
|
|
264
|
-
typeof perCategoryOrOptions === 'string'
|
|
265
|
-
? (query ?? perCategoryOrOptions)
|
|
266
|
-
: isOptions
|
|
267
|
-
? (query ?? perCategoryOrOptions.query)
|
|
268
|
-
: query
|
|
269
263
|
const byCategory = new Map<string, C9Chunk[]>()
|
|
270
264
|
for (const chunk of chunks) {
|
|
271
265
|
const group = byCategory.get(chunk.category)
|
|
@@ -288,41 +282,15 @@ export function c9Filter(
|
|
|
288
282
|
dropped.push(...group.filter((c) => c.score < cutoff))
|
|
289
283
|
dropped.push(...eligible.slice(topK))
|
|
290
284
|
}
|
|
291
|
-
|
|
285
|
+
const signal: C9FilterResult['signal'] =
|
|
292
286
|
chunks.length === 0 ? 'empty-input' : selected.length === 0 ? 'all-dropped' : 'ok'
|
|
293
|
-
|
|
287
|
+
const notice =
|
|
294
288
|
signal === 'empty-input'
|
|
295
|
-
? 'C9: empty input — no chunks retrieved;
|
|
289
|
+
? 'C9: empty input — no chunks retrieved; proceed without injected context'
|
|
296
290
|
: signal === 'all-dropped'
|
|
297
|
-
? 'C9: all chunks dropped by cutoff/top-k;
|
|
291
|
+
? 'C9: all chunks dropped by cutoff/top-k; no context was injected'
|
|
298
292
|
: null
|
|
299
293
|
|
|
300
|
-
// Keep the absolute cutoff intact. This fallback is deliberately entered
|
|
301
|
-
// only after it rejects every chunk, and only lexical overlap can authorize
|
|
302
|
-
// injecting one of those low-score chunks.
|
|
303
|
-
if (chunks.length > 0 && selected.length === 0 && filterQuery !== undefined) {
|
|
304
|
-
const queryTerms = lexicalTerms(filterQuery)
|
|
305
|
-
const fallbackSelected =
|
|
306
|
-
queryTerms.length > 0 ? relativeLexicalSelection(byCategory, perCategory, queryTerms) : []
|
|
307
|
-
if (fallbackSelected.length > 0) {
|
|
308
|
-
selected.push(...fallbackSelected)
|
|
309
|
-
signal = 'fallback-relative'
|
|
310
|
-
notice =
|
|
311
|
-
`C9: absolute cutoff ${C9_DEFAULT_CUTOFF} yielded no eligible chunks; ` +
|
|
312
|
-
`lexical/relative fallback selected ${fallbackSelected.length} auditable ` +
|
|
313
|
-
`low-score chunk(s) (${queryTerms.length} query term(s)); selected scores remain below cutoff`
|
|
314
|
-
} else if (filterQuery.trim().length > 0) {
|
|
315
|
-
signal = 'nonsense'
|
|
316
|
-
notice =
|
|
317
|
-
'C9: query has no lexical overlap with low-score chunks; fallback returned zero and no context was injected'
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
if (selected.length > 0 && signal === 'fallback-relative') {
|
|
322
|
-
const selectedSet = new Set(selected)
|
|
323
|
-
dropped.length = 0
|
|
324
|
-
dropped.push(...chunks.filter((chunk) => !selectedSet.has(chunk)))
|
|
325
|
-
}
|
|
326
294
|
const context = buildC9Context(selected)
|
|
327
295
|
return {
|
|
328
296
|
selected,
|
|
@@ -384,15 +352,6 @@ function telemetry(
|
|
|
384
352
|
}
|
|
385
353
|
}
|
|
386
354
|
|
|
387
|
-
function hasLexicalOverlap(query: string, chunks: readonly C9Chunk[]): boolean {
|
|
388
|
-
const queryTerms = lexicalTerms(query)
|
|
389
|
-
if (queryTerms.length === 0) return false
|
|
390
|
-
return chunks.some((chunk) => {
|
|
391
|
-
const chunkTerms = new Set(lexicalTerms(chunk.text))
|
|
392
|
-
return queryTerms.some((term) => chunkTerms.has(term))
|
|
393
|
-
})
|
|
394
|
-
}
|
|
395
|
-
|
|
396
355
|
/**
|
|
397
356
|
* Prepare auditable context without performing retrieval or side effects.
|
|
398
357
|
*
|
|
@@ -429,13 +388,8 @@ export function prepareC9Context(
|
|
|
429
388
|
}
|
|
430
389
|
}
|
|
431
390
|
|
|
432
|
-
const context =
|
|
433
|
-
|
|
434
|
-
(filtered.signal === 'ok' && !hasLexicalOverlap(batch.query, filtered.selected))
|
|
435
|
-
? ''
|
|
436
|
-
: buildC9Context(filtered.selected)
|
|
437
|
-
const signal =
|
|
438
|
-
context.length === 0 && filtered.signal === 'ok' ? ('nonsense' as const) : filtered.signal
|
|
391
|
+
const context = buildC9Context(filtered.selected)
|
|
392
|
+
const signal = filtered.signal
|
|
439
393
|
const selectedCount = context.length === 0 ? 0 : filtered.selected.length
|
|
440
394
|
const droppedCount = context.length === 0 ? candidateCount : filtered.dropped.length
|
|
441
395
|
const recall = context.length === 0 && filtered.signal === 'ok' ? 0 : filtered.recall
|
|
@@ -446,52 +400,6 @@ export function prepareC9Context(
|
|
|
446
400
|
}
|
|
447
401
|
}
|
|
448
402
|
|
|
449
|
-
/** Normalize query/text terms without introducing a semantic or runtime dependency. */
|
|
450
|
-
function lexicalTerms(value: string): string[] {
|
|
451
|
-
return [
|
|
452
|
-
...new Set(
|
|
453
|
-
value
|
|
454
|
-
.normalize('NFKD')
|
|
455
|
-
.replace(/\p{M}/gu, '')
|
|
456
|
-
.toLowerCase()
|
|
457
|
-
.match(/[\p{L}\p{N}]+/gu)
|
|
458
|
-
?.filter((term) => term.length > 1) ?? [],
|
|
459
|
-
),
|
|
460
|
-
]
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
/** Select only query-matching low-score chunks, ranked lexically then relatively. */
|
|
464
|
-
function relativeLexicalSelection(
|
|
465
|
-
byCategory: Map<string, C9Chunk[]>,
|
|
466
|
-
perCategory: Readonly<Record<string, C9CategoryConfig>> | undefined,
|
|
467
|
-
queryTerms: string[],
|
|
468
|
-
): C9Chunk[] {
|
|
469
|
-
const selected: C9Chunk[] = []
|
|
470
|
-
for (const [category, group] of byCategory) {
|
|
471
|
-
const config = perCategory?.[category]
|
|
472
|
-
const topK = config?.topK ?? C9_DEFAULT_TOP_K
|
|
473
|
-
const maxScore = Math.max(...group.map((chunk) => chunk.score), 0)
|
|
474
|
-
const ranked = group
|
|
475
|
-
.map((chunk, index) => {
|
|
476
|
-
const textSet = new Set(lexicalTerms(chunk.text))
|
|
477
|
-
const overlap = queryTerms.filter((term) => textSet.has(term)).length
|
|
478
|
-
const lexicalScore = overlap / queryTerms.length
|
|
479
|
-
const relativeScore = maxScore > 0 ? chunk.score / maxScore : 0
|
|
480
|
-
return { chunk, index, lexicalScore, relativeScore }
|
|
481
|
-
})
|
|
482
|
-
.filter((entry) => entry.lexicalScore > 0)
|
|
483
|
-
.sort(
|
|
484
|
-
(left, right) =>
|
|
485
|
-
right.lexicalScore - left.lexicalScore ||
|
|
486
|
-
right.relativeScore - left.relativeScore ||
|
|
487
|
-
right.chunk.score - left.chunk.score ||
|
|
488
|
-
left.index - right.index,
|
|
489
|
-
)
|
|
490
|
-
selected.push(...ranked.slice(0, topK).map((entry) => entry.chunk))
|
|
491
|
-
}
|
|
492
|
-
return selected
|
|
493
|
-
}
|
|
494
|
-
|
|
495
403
|
function isC9FilterOptions(
|
|
496
404
|
value: Readonly<Record<string, C9CategoryConfig>> | C9FilterOptions | string | undefined,
|
|
497
405
|
): value is C9FilterOptions {
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/** Host-real context ceiling probe for C9/tool filtering. */
|
|
2
|
+
|
|
3
|
+
import type { PluginInput } from '@opencode-ai/plugin'
|
|
4
|
+
import { createPantheonLogger, type PantheonLogger } from './logger.ts'
|
|
5
|
+
import type { NativeTaskStatus } from './native-task-status.ts'
|
|
6
|
+
|
|
7
|
+
export interface ToolCeilingSnapshot {
|
|
8
|
+
usage: { inputTokens: number; outputTokens: number }
|
|
9
|
+
limit: { contextTokens: number }
|
|
10
|
+
supportsToolFiltering: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type ToolCeilingResult =
|
|
14
|
+
| {
|
|
15
|
+
status: 'OK'
|
|
16
|
+
ceiling: number
|
|
17
|
+
usage: ToolCeilingSnapshot['usage']
|
|
18
|
+
limit: ToolCeilingSnapshot['limit']
|
|
19
|
+
}
|
|
20
|
+
| { status: Exclude<NativeTaskStatus, 'OK'>; detail: string }
|
|
21
|
+
|
|
22
|
+
export type HostToolCeilingSource = () => unknown | Promise<unknown>
|
|
23
|
+
|
|
24
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
25
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isFiniteNonNegative(value: unknown): value is number {
|
|
29
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isSnapshot(value: unknown): value is ToolCeilingSnapshot {
|
|
33
|
+
if (!isRecord(value) || !isRecord(value.usage) || !isRecord(value.limit)) return false
|
|
34
|
+
return (
|
|
35
|
+
isFiniteNonNegative(value.usage.inputTokens) &&
|
|
36
|
+
isFiniteNonNegative(value.usage.outputTokens) &&
|
|
37
|
+
isFiniteNonNegative(value.limit.contextTokens) &&
|
|
38
|
+
typeof value.supportsToolFiltering === 'boolean'
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Probe exact host usage/limit without estimation, fallback, or retry. */
|
|
43
|
+
export async function probeToolCeiling(source?: HostToolCeilingSource): Promise<ToolCeilingResult> {
|
|
44
|
+
if (source === undefined)
|
|
45
|
+
return { status: 'UNSUPPORTED', detail: 'host SDK exposes no context probe' }
|
|
46
|
+
|
|
47
|
+
let raw: unknown
|
|
48
|
+
try {
|
|
49
|
+
raw = await source()
|
|
50
|
+
} catch (error: unknown) {
|
|
51
|
+
return { status: 'UNAVAILABLE', detail: error instanceof Error ? error.message : String(error) }
|
|
52
|
+
}
|
|
53
|
+
if (!isSnapshot(raw))
|
|
54
|
+
return { status: 'CORRUPT_DATA', detail: 'host context probe returned malformed data' }
|
|
55
|
+
if (raw.supportsToolFiltering === false) {
|
|
56
|
+
return { status: 'UNSUPPORTED', detail: 'host SDK does not filter tools' }
|
|
57
|
+
}
|
|
58
|
+
const used = raw.usage.inputTokens + raw.usage.outputTokens
|
|
59
|
+
if (used > raw.limit.contextTokens) {
|
|
60
|
+
return { status: 'CORRUPT_DATA', detail: 'host usage exceeds context limit' }
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
status: 'OK',
|
|
64
|
+
ceiling: raw.limit.contextTokens - used,
|
|
65
|
+
usage: raw.usage,
|
|
66
|
+
limit: raw.limit,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Read a ceiling from the actual OpenCode SDK event and provider catalogue.
|
|
72
|
+
*
|
|
73
|
+
* `message.updated` is the SDK's runtime usage source (`info.tokens`). The
|
|
74
|
+
* provider catalogue supplies the selected model's context limit and its
|
|
75
|
+
* `capabilities.toolcall` flag. This adapter intentionally does not accept a
|
|
76
|
+
* caller-provided snapshot/callback, so tests cannot accidentally turn a
|
|
77
|
+
* fixture into a claimed host probe.
|
|
78
|
+
*/
|
|
79
|
+
export async function probeToolCeilingFromHost(
|
|
80
|
+
client: PluginInput['client'],
|
|
81
|
+
event: unknown,
|
|
82
|
+
): Promise<ToolCeilingResult> {
|
|
83
|
+
if (!isRecord(event) || event.type !== 'message.updated' || !isRecord(event.properties)) {
|
|
84
|
+
return { status: 'UNSUPPORTED', detail: 'host SDK event has no message usage' }
|
|
85
|
+
}
|
|
86
|
+
const info = event.properties.info
|
|
87
|
+
if (
|
|
88
|
+
!isRecord(info) ||
|
|
89
|
+
typeof info.providerID !== 'string' ||
|
|
90
|
+
typeof info.modelID !== 'string' ||
|
|
91
|
+
!isRecord(info.tokens)
|
|
92
|
+
) {
|
|
93
|
+
return { status: 'UNSUPPORTED', detail: 'host SDK message has no token usage' }
|
|
94
|
+
}
|
|
95
|
+
const providerID = info.providerID as string
|
|
96
|
+
const modelID = info.modelID as string
|
|
97
|
+
const tokens = info.tokens as Record<string, unknown>
|
|
98
|
+
|
|
99
|
+
let providersResult: unknown
|
|
100
|
+
try {
|
|
101
|
+
providersResult = await client.config.providers()
|
|
102
|
+
} catch (error: unknown) {
|
|
103
|
+
return { status: 'UNAVAILABLE', detail: error instanceof Error ? error.message : String(error) }
|
|
104
|
+
}
|
|
105
|
+
if (!isRecord(providersResult) || providersResult.error !== undefined) {
|
|
106
|
+
return { status: 'UNAVAILABLE', detail: 'host SDK provider catalogue unavailable' }
|
|
107
|
+
}
|
|
108
|
+
const data = providersResult.data
|
|
109
|
+
if (!isRecord(data) || !Array.isArray(data.providers)) {
|
|
110
|
+
return { status: 'CORRUPT_DATA', detail: 'host SDK provider catalogue malformed' }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const provider = data.providers.find(
|
|
114
|
+
(candidate) =>
|
|
115
|
+
isRecord(candidate) &&
|
|
116
|
+
candidate.id === providerID &&
|
|
117
|
+
isRecord(candidate.models) &&
|
|
118
|
+
isRecord(candidate.models[modelID]),
|
|
119
|
+
)
|
|
120
|
+
if (!isRecord(provider) || !isRecord(provider.models)) {
|
|
121
|
+
return { status: 'UNSUPPORTED', detail: 'host SDK model metadata unavailable' }
|
|
122
|
+
}
|
|
123
|
+
const model = provider.models[modelID]
|
|
124
|
+
if (!isRecord(model) || !isRecord(model.limit) || !isRecord(model.capabilities)) {
|
|
125
|
+
return { status: 'UNSUPPORTED', detail: 'host SDK model ceiling capability unavailable' }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return probeToolCeiling(() => ({
|
|
129
|
+
usage: { inputTokens: tokens.input, outputTokens: tokens.output },
|
|
130
|
+
limit: { contextTokens: (model.limit as Record<string, unknown>).context },
|
|
131
|
+
supportsToolFiltering: (model.capabilities as Record<string, unknown>).toolcall,
|
|
132
|
+
}))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const log = createPantheonLogger({ module: 'pantheon-tool-ceiling' })
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Handle the host usage event and retain the result for the compaction hook.
|
|
139
|
+
* Malformed SDK events are diagnostic-only: they must never break the event
|
|
140
|
+
* pipeline or create an entry under an invalid session identifier.
|
|
141
|
+
*/
|
|
142
|
+
export async function handleToolCeilingEvent(
|
|
143
|
+
client: PluginInput['client'],
|
|
144
|
+
event: unknown,
|
|
145
|
+
ceilings: Map<string, ToolCeilingResult>,
|
|
146
|
+
logger: Pick<PantheonLogger, 'warn'> = log,
|
|
147
|
+
): Promise<ToolCeilingResult> {
|
|
148
|
+
const sessionID = (
|
|
149
|
+
event as { properties?: { info?: { sessionID?: unknown } } } | null | undefined
|
|
150
|
+
)?.properties?.info?.sessionID
|
|
151
|
+
if (typeof sessionID !== 'string' || sessionID.trim() === '') {
|
|
152
|
+
logger.warn('[Pantheon Plugin] message.updated event has no valid sessionID')
|
|
153
|
+
return { status: 'UNSUPPORTED', detail: 'host SDK event has no valid sessionID' }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const result = await probeToolCeilingFromHost(client, event)
|
|
157
|
+
ceilings.set(sessionID, result)
|
|
158
|
+
return result
|
|
159
|
+
}
|
|
@@ -94,13 +94,22 @@ export function createV2EventDispatcher(deps: V2EventDeps): V2EventDispatcher {
|
|
|
94
94
|
return {
|
|
95
95
|
handleEvent: async (event: V2SessionEvent): Promise<boolean> => {
|
|
96
96
|
try {
|
|
97
|
-
// session.created → seed hierarchy
|
|
97
|
+
// session.created → seed hierarchy + board put-if-absent for native children
|
|
98
98
|
if (event.type === 'session.created') {
|
|
99
99
|
const info = event.properties?.info as SessionInfo | undefined
|
|
100
100
|
if (info) {
|
|
101
101
|
deps.registerSession?.(info)
|
|
102
102
|
if (info.parentID === undefined) {
|
|
103
103
|
deps.addRootSession?.(info.id)
|
|
104
|
+
} else {
|
|
105
|
+
// Native task(background=true) child: register on the board
|
|
106
|
+
// atomically (skip if V1 idle hook or re-dispatch already claimed it).
|
|
107
|
+
await deps.board.registerLaunchIfAbsent({
|
|
108
|
+
taskID: info.id,
|
|
109
|
+
parentSessionID: info.parentID,
|
|
110
|
+
agent: 'native',
|
|
111
|
+
description: `native child ${info.id}`,
|
|
112
|
+
})
|
|
104
113
|
}
|
|
105
114
|
}
|
|
106
115
|
}
|
package/src/plugin.ts
CHANGED
|
@@ -2,7 +2,6 @@ import { existsSync } from 'node:fs'
|
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import type { Plugin, PluginInput } from '@opencode-ai/plugin'
|
|
4
4
|
import type { PluginConfig } from 'opencode'
|
|
5
|
-
import { BackgroundJobBoard } from './pantheon/background-job-board.ts'
|
|
6
5
|
import { createCommandNormalizer } from './pantheon/command-normalizer.ts'
|
|
7
6
|
import {
|
|
8
7
|
type ContextSandboxConfig,
|
|
@@ -30,7 +29,6 @@ import {
|
|
|
30
29
|
} from './pantheon/delegation-enforce.ts'
|
|
31
30
|
import { handleDelegationEvent } from './pantheon/delegation-notify.ts'
|
|
32
31
|
import { showDelegationTerminalToast, type ToastClient } from './pantheon/delegation-toast.ts'
|
|
33
|
-
import { FilePersistenceAdapter } from './pantheon/file-persistence.ts'
|
|
34
32
|
import { GOAL_LOOP_DEFAULTS, GoalLoop, GoalStore } from './pantheon/goal-loop.ts'
|
|
35
33
|
import { createReadEnhancer } from './pantheon/hashline/read-enhancer.ts'
|
|
36
34
|
import { createHashlineEditTool } from './pantheon/hashline/tool.ts'
|
|
@@ -45,7 +43,9 @@ import {
|
|
|
45
43
|
loadRoutingPermissionTask,
|
|
46
44
|
} from './pantheon/presets.mjs'
|
|
47
45
|
import { safeSessionPath } from './pantheon/session-guard.ts'
|
|
46
|
+
import { getSharedBoard } from './pantheon/shared-board.ts'
|
|
48
47
|
import { StepCapTracker } from './pantheon/step-cap.ts'
|
|
48
|
+
import { createTaskResultGuard } from './pantheon/task-result-guard.ts'
|
|
49
49
|
import {
|
|
50
50
|
TODO_ENFORCER_DEFAULTS,
|
|
51
51
|
TodoEnforcer,
|
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
todoEnforcerEnabledFromEnv,
|
|
54
54
|
} from './pantheon/todo-enforcer.ts'
|
|
55
55
|
import { TodoPreserver } from './pantheon/todo-preserve.ts'
|
|
56
|
+
import { handleToolCeilingEvent, type ToolCeilingResult } from './pantheon/tool-ceiling.ts'
|
|
56
57
|
import { checkTuiVersionStaleness } from './pantheon/tui-version-check.ts'
|
|
57
58
|
import { activePresetCandidates, createVisionHandler } from './pantheon/vision.ts'
|
|
58
59
|
|
|
@@ -75,12 +76,11 @@ const stepCapTracker = new StepCapTracker(loadRoutingMaxSteps({ logger: log }))
|
|
|
75
76
|
// (fail-open → undefined = existing runtime matrix only).
|
|
76
77
|
const routingPermissionTask = loadRoutingPermissionTask({ logger: log })
|
|
77
78
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
board.setPersistence(persistence)
|
|
79
|
+
// beta.5: the board is a PROCESS-wide singleton (getSharedBoard) shared with
|
|
80
|
+
// src/plugins/pantheon-hooks.ts, whose native-task mirror registers children
|
|
81
|
+
// here; the finalize path below (session.idle + idle scan) must observe the
|
|
82
|
+
// SAME instance. globalThis anchoring dedupes the npm+repo double load.
|
|
83
|
+
const board = getSharedBoard()
|
|
84
84
|
let terminalToastClient: ToastClient = {}
|
|
85
85
|
board
|
|
86
86
|
.recoverRunningJobs()
|
|
@@ -165,6 +165,7 @@ const COMPACTION_MAX_ITEMS = 10
|
|
|
165
165
|
const rootSessions = new Set<string>()
|
|
166
166
|
const sessionHierarchy = new SessionHierarchyRegistry()
|
|
167
167
|
const sessionAgents = new Map<string, string>()
|
|
168
|
+
const sessionToolCeilings = new Map<string, ToolCeilingResult>()
|
|
168
169
|
|
|
169
170
|
/**
|
|
170
171
|
* Seed resumed root sessions without making OpenCode startup depend on the
|
|
@@ -504,13 +505,18 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
504
505
|
},
|
|
505
506
|
}
|
|
506
507
|
const sandboxHandler = createContextSandbox(sandboxConfig)
|
|
508
|
+
// Task Result Guard (2026-09-10): detect empty task() results from native
|
|
509
|
+
// opencode subagent calls and convert to explicit errors. Runs FIRST in the
|
|
510
|
+
// chain so the error message is visible even if later handlers skip.
|
|
511
|
+
const taskResultGuard = createTaskResultGuard()
|
|
507
512
|
const combinedAfter: typeof readEnhancer = async (input, output) => {
|
|
513
|
+
await taskResultGuard(input, output)
|
|
508
514
|
await sandboxHandler(input, output)
|
|
509
515
|
await readEnhancer(input, output)
|
|
510
516
|
}
|
|
511
517
|
|
|
512
518
|
// Wave 4 (PR #46): /cost — delegation cost + token visibility. Reads
|
|
513
|
-
// opencode.db read-only
|
|
519
|
+
// opencode.db read-only via the single node:sqlite backend.
|
|
514
520
|
// Fully wired (unlike dispatch-guard, which is manual-orchestration-only
|
|
515
521
|
// because opencode 1.18.x cannot intercept task completion via hooks).
|
|
516
522
|
const costCommand = createCostCommand()
|
|
@@ -615,6 +621,9 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
615
621
|
if (ev.type === 'session.compacted') {
|
|
616
622
|
await todoPreserver.onCompacted(ev.properties.sessionID)
|
|
617
623
|
}
|
|
624
|
+
if (ev.type === 'message.updated') {
|
|
625
|
+
await handleToolCeilingEvent(input.client, ev, sessionToolCeilings)
|
|
626
|
+
}
|
|
618
627
|
// Phase 3: observe completion on child sessions → finalizeDelegation.
|
|
619
628
|
// The board transition fires onTerminal → the file-only audit log (no
|
|
620
629
|
// chat delivery — see the onTerminal listener above). Unknown sessions
|
|
@@ -680,6 +689,13 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
680
689
|
list: (sessionID: string) => todoEnforcer.listPendingTodos(sessionID),
|
|
681
690
|
},
|
|
682
691
|
})
|
|
692
|
+
const ceiling = sessionToolCeilings.get(_input.sessionID)
|
|
693
|
+
if (ceiling?.status === 'OK') {
|
|
694
|
+
output.context.push(
|
|
695
|
+
`<tool_ceiling remaining_context_tokens="${ceiling.ceiling}">` +
|
|
696
|
+
'Use the remaining host-reported context budget when selecting tools.</tool_ceiling>',
|
|
697
|
+
)
|
|
698
|
+
}
|
|
683
699
|
if (blocks.length > 0) {
|
|
684
700
|
output.context.push(...blocks)
|
|
685
701
|
}
|
|
@@ -177,6 +177,7 @@ import { appendFile, mkdir } from 'node:fs/promises'
|
|
|
177
177
|
import { join } from 'node:path'
|
|
178
178
|
import type { Plugin, PluginInput } from '@opencode-ai/plugin'
|
|
179
179
|
import { pantheonPluginOnce } from '../pantheon/plugin-once.ts'
|
|
180
|
+
import { getSharedBoard } from '../pantheon/shared-board.ts'
|
|
180
181
|
import { type HookPayload, type HookResult, runHook } from './hook-runner.ts'
|
|
181
182
|
|
|
182
183
|
/** Tools that represent a subagent delegation (opencode `task` tool etc.). */
|
|
@@ -775,6 +776,51 @@ function delegationAgent(tool: string, args?: unknown): string {
|
|
|
775
776
|
return tool
|
|
776
777
|
}
|
|
777
778
|
|
|
779
|
+
/**
|
|
780
|
+
* beta.5: mirror a native background task dispatch onto the SHARED board
|
|
781
|
+
* (getSharedBoard) so the existing finalize path in plugin.ts (session.idle
|
|
782
|
+
* event + 30s idle scan) writes the terminal report and the TUI delegation
|
|
783
|
+
* panel tracks the child end-to-end. Fire-and-forget and silent: a mirror
|
|
784
|
+
* failure must never surface into the tool call or the transcript.
|
|
785
|
+
* registerLaunchIfAbsent keeps it idempotent against any other dispatcher
|
|
786
|
+
* (v2-events, plugin double-load, repeated after-hook firings).
|
|
787
|
+
*/
|
|
788
|
+
function mirrorNativeBackgroundDispatch(
|
|
789
|
+
input: { tool: string; sessionID: string },
|
|
790
|
+
args: unknown,
|
|
791
|
+
result: ToolExecuteAfterResult | null,
|
|
792
|
+
): void {
|
|
793
|
+
const taskID = extractTaskId(result)
|
|
794
|
+
if (taskID === '' || typeof input.sessionID !== 'string' || input.sessionID === '') return
|
|
795
|
+
const description = trim(delegationDescription(args) ?? `native ${input.tool} dispatch`, 200)
|
|
796
|
+
try {
|
|
797
|
+
getSharedBoard()
|
|
798
|
+
.registerLaunchIfAbsent({
|
|
799
|
+
taskID,
|
|
800
|
+
parentSessionID: input.sessionID,
|
|
801
|
+
agent: delegationAgent(input.tool, args),
|
|
802
|
+
description,
|
|
803
|
+
})
|
|
804
|
+
.catch(() => {
|
|
805
|
+
// Mirror is best-effort — never surface into the tool call.
|
|
806
|
+
})
|
|
807
|
+
} catch {
|
|
808
|
+
// Never let the mirror take down the tool call.
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/** Task description from task-tool args (description ?? prompt); undefined when absent. */
|
|
813
|
+
function delegationDescription(args: unknown): string | undefined {
|
|
814
|
+
try {
|
|
815
|
+
const a = (args ?? {}) as Record<string, unknown>
|
|
816
|
+
const raw = a.description ?? a.prompt
|
|
817
|
+
if (typeof raw === 'string' && raw.trim() !== '') return raw
|
|
818
|
+
} catch {
|
|
819
|
+
// Malformed args — fall through to the generic description.
|
|
820
|
+
}
|
|
821
|
+
return undefined
|
|
822
|
+
}
|
|
823
|
+
|
|
778
824
|
/** 🚀 delegation-started toast (variant 'info', 2500ms), deduped per agent. */
|
|
779
825
|
async function delegationStartedToast(ctx: HookContext, agent: string): Promise<void> {
|
|
780
826
|
await throttledToast(ctx, {
|
|
@@ -1311,6 +1357,11 @@ const plugin: Plugin = async ({ client, directory }) => {
|
|
|
1311
1357
|
// NOT a completion: do not mark the group done and do not fire ✅
|
|
1312
1358
|
// — that would corrupt olympians aggregation. Real completion of
|
|
1313
1359
|
// background tasks is unobservable on 1.18.13 (see header).
|
|
1360
|
+
// beta.5: mirror the native background child onto the SHARED
|
|
1361
|
+
// board so the existing finalize path (session.idle + idle scan
|
|
1362
|
+
// in plugin.ts) writes the terminal report and the TUI
|
|
1363
|
+
// delegation panel tracks the child end-to-end. Fire-and-forget.
|
|
1364
|
+
mirrorNativeBackgroundDispatch(input, args, result)
|
|
1314
1365
|
} else {
|
|
1315
1366
|
const handledByOlympians = handleOlympiansCompletion(ctx, agent)
|
|
1316
1367
|
if (!handledByOlympians) void delegationDoneToast(ctx, agent)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","names":[],"sources":["../src/server.ts"],"mappings":";;;;;;;;;;;;;iBAYwB"}
|
|
1
|
+
{"version":3,"file":"server.d.ts","names":[],"sources":["../src/server.ts"],"mappings":";;;;;;;;;;;;;iBAYwB"}
|