pantheon-opencode 1.5.0-beta.1 → 1.5.0-beta.11
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 +1054 -0
- package/README.md +171 -16
- package/README.pt-BR.md +99 -13
- package/bin/pantheon-init.mjs +178 -21
- package/package.json +23 -15
- package/plugin.json +1 -1
- package/pyproject.toml +65 -0
- package/scripts/code_mode_server.py +431 -55
- package/scripts/cost.mjs +30 -14
- package/scripts/dev-tui.sh +48 -0
- package/scripts/doctor.mjs +207 -9
- package/scripts/install/cli-ui.mjs +17 -11
- package/scripts/install/config-migration.mjs +4 -6
- package/scripts/install/opencode.mjs +137 -35
- package/scripts/install/plugin.mjs +3 -33
- package/scripts/install/shared.mjs +43 -2
- package/scripts/install/strings.mjs +129 -0
- package/scripts/install-mcp.mjs +1 -0
- package/scripts/manifest-inventory.mjs +410 -0
- package/scripts/mcp_persistence_server.py +537 -128
- package/scripts/package-evidence.mjs +242 -0
- package/scripts/probe-context-rehydrate.mjs +240 -0
- package/scripts/probe-pantheon-cost.mjs +313 -0
- package/scripts/release-beta-version.mjs +56 -60
- package/scripts/sync-artifacts.mjs +169 -0
- package/scripts/sync-tui.mjs +121 -53
- package/scripts/test-opencode-v1-v2-sandbox.sh +367 -96
- package/scripts/validation-policy.mjs +7 -7
- package/scripts/version-check.mjs +113 -108
- package/scripts/versioning.mjs +230 -86
- package/src/agents/mnemosyne.md +1 -1
- package/src/agents/zeus.md +2 -2
- package/src/mcp/code_mode_server.py +431 -55
- package/src/mcp/mcp_persistence_server.py +537 -128
- package/src/pantheon/auto-wake.ts +1 -5
- package/src/pantheon/background-job-board.ts +43 -1
- package/src/pantheon/compaction-handoff.ts +304 -0
- package/src/pantheon/cost-command.ts +345 -62
- package/src/pantheon/delegate-manager.ts +390 -8
- package/src/pantheon/delegation-finalize.ts +32 -0
- package/src/pantheon/delegation.ts +235 -18
- package/src/pantheon/file-persistence-salvage.ts +64 -0
- package/src/pantheon/file-persistence.ts +194 -45
- package/src/pantheon/native-probe.ts +234 -0
- package/src/pantheon/native-task-status.ts +139 -0
- package/src/pantheon/provider-capabilities.ts +221 -0
- package/src/pantheon/provider-stream-tracker.ts +182 -0
- package/src/pantheon/shared-board.ts +37 -0
- package/src/pantheon/task-result-guard.ts +107 -0
- package/src/pantheon/token-opt.ts +146 -12
- package/src/pantheon/tool-ceiling.ts +159 -0
- package/src/pantheon/v2-events.ts +18 -1
- package/src/plugin-v2.ts +2 -14
- package/src/plugin.ts +28 -17
- 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 +137 -27
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +459 -163
- package/src/plugins/tui/dist/tui.js.map +1 -1
- package/src/plugins/tui/dist/tui.tsx +506 -103
- package/src/plugins/tui/package-lock.json +2 -2
- package/src/plugins/tui/package.json +1 -1
- package/src/plugins/tui/src/index.tsx +506 -103
- package/.pantheon/code-mode/.gitkeep +0 -0
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-1.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-10.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-11.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-2.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-3.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-4.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-5.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-6.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-7.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-8.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-9.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/heartbeat.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/session.json +0 -11
- package/src/pantheon/delegate-task-adapter.ts +0 -444
|
@@ -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). */
|
|
@@ -173,7 +180,7 @@ export function applyGate(
|
|
|
173
180
|
}
|
|
174
181
|
|
|
175
182
|
/** Kill-switch: `PANTHEON_TOKEN_OPT=off` disables the whole WS3 layer. */
|
|
176
|
-
export function isTokenOptEnabled(env: Record<string, string | undefined
|
|
183
|
+
export function isTokenOptEnabled(env: Readonly<Record<string, string | undefined>> = {}): boolean {
|
|
177
184
|
return env.PANTHEON_TOKEN_OPT !== 'off'
|
|
178
185
|
}
|
|
179
186
|
|
|
@@ -193,12 +200,24 @@ export interface C9Chunk {
|
|
|
193
200
|
text: string
|
|
194
201
|
}
|
|
195
202
|
|
|
203
|
+
/** Immutable input batch for the safe C9 context-preparation contract. */
|
|
204
|
+
export interface C9CandidateBatch {
|
|
205
|
+
query: string
|
|
206
|
+
candidates: readonly C9Chunk[]
|
|
207
|
+
}
|
|
208
|
+
|
|
196
209
|
/** Per-category override of top-k / cutoff. */
|
|
197
210
|
export interface C9CategoryConfig {
|
|
198
211
|
topK?: number
|
|
199
212
|
cutoff?: number
|
|
200
213
|
}
|
|
201
214
|
|
|
215
|
+
/** Optional query used only to activate the explicit low-score fallback. */
|
|
216
|
+
export interface C9FilterOptions {
|
|
217
|
+
perCategory?: Readonly<Record<string, C9CategoryConfig>>
|
|
218
|
+
query?: string
|
|
219
|
+
}
|
|
220
|
+
|
|
202
221
|
/** Result of C9 filtering with debited injection cost. */
|
|
203
222
|
export interface C9FilterResult {
|
|
204
223
|
selected: C9Chunk[]
|
|
@@ -208,10 +227,8 @@ export interface C9FilterResult {
|
|
|
208
227
|
/**
|
|
209
228
|
* Quality-floor signal (never silent on empty):
|
|
210
229
|
* - `ok` — at least one chunk selected;
|
|
211
|
-
* - `empty-input` — no chunks retrieved at all
|
|
212
|
-
*
|
|
213
|
-
* - `all-dropped` — chunks existed but none survived cutoff/top-k
|
|
214
|
-
* (fallback: relax cutoff/top-k or proceed without context).
|
|
230
|
+
* - `empty-input` — no chunks retrieved at all;
|
|
231
|
+
* - `all-dropped` — chunks existed but none survived cutoff/top-k.
|
|
215
232
|
*/
|
|
216
233
|
signal: 'ok' | 'empty-input' | 'all-dropped'
|
|
217
234
|
/** Human-readable alert for the non-`ok` signals (null when `ok`). */
|
|
@@ -228,12 +245,21 @@ export interface C9FilterResult {
|
|
|
228
245
|
|
|
229
246
|
/**
|
|
230
247
|
* Filter scored chunks per category: drop below cutoff, keep top-k by
|
|
231
|
-
* score desc.
|
|
248
|
+
* score desc. Scores must come from retrieval; text is never used as a proxy.
|
|
249
|
+
* The injected context cost is debited (`injectedTokens`).
|
|
232
250
|
*/
|
|
233
251
|
export function c9Filter(
|
|
234
|
-
chunks: C9Chunk[],
|
|
235
|
-
|
|
252
|
+
chunks: readonly C9Chunk[],
|
|
253
|
+
perCategoryOrOptions?: Readonly<Record<string, C9CategoryConfig>> | C9FilterOptions | string,
|
|
254
|
+
_query?: string,
|
|
236
255
|
): C9FilterResult {
|
|
256
|
+
const isOptions = isC9FilterOptions(perCategoryOrOptions)
|
|
257
|
+
const perCategory =
|
|
258
|
+
typeof perCategoryOrOptions === 'string'
|
|
259
|
+
? undefined
|
|
260
|
+
: isOptions
|
|
261
|
+
? perCategoryOrOptions.perCategory
|
|
262
|
+
: perCategoryOrOptions
|
|
237
263
|
const byCategory = new Map<string, C9Chunk[]>()
|
|
238
264
|
for (const chunk of chunks) {
|
|
239
265
|
const group = byCategory.get(chunk.category)
|
|
@@ -256,15 +282,16 @@ export function c9Filter(
|
|
|
256
282
|
dropped.push(...group.filter((c) => c.score < cutoff))
|
|
257
283
|
dropped.push(...eligible.slice(topK))
|
|
258
284
|
}
|
|
259
|
-
const context = buildC9Context(selected)
|
|
260
285
|
const signal: C9FilterResult['signal'] =
|
|
261
286
|
chunks.length === 0 ? 'empty-input' : selected.length === 0 ? 'all-dropped' : 'ok'
|
|
262
287
|
const notice =
|
|
263
288
|
signal === 'empty-input'
|
|
264
|
-
? 'C9: empty input — no chunks retrieved;
|
|
289
|
+
? 'C9: empty input — no chunks retrieved; proceed without injected context'
|
|
265
290
|
: signal === 'all-dropped'
|
|
266
|
-
? 'C9: all chunks dropped by cutoff/top-k;
|
|
291
|
+
? 'C9: all chunks dropped by cutoff/top-k; no context was injected'
|
|
267
292
|
: null
|
|
293
|
+
|
|
294
|
+
const context = buildC9Context(selected)
|
|
268
295
|
return {
|
|
269
296
|
selected,
|
|
270
297
|
dropped,
|
|
@@ -276,8 +303,115 @@ export function c9Filter(
|
|
|
276
303
|
}
|
|
277
304
|
}
|
|
278
305
|
|
|
306
|
+
/** Configuration for the pure C9 context-preparation boundary. */
|
|
307
|
+
export interface C9ContextOptions {
|
|
308
|
+
env?: Readonly<Record<string, string | undefined>>
|
|
309
|
+
perCategory?: Readonly<Record<string, C9CategoryConfig>>
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Metrics emitted by C9; deliberately contains no candidate or context text. */
|
|
313
|
+
export interface C9Telemetry {
|
|
314
|
+
signal: C9FilterResult['signal'] | 'disabled' | 'empty-query'
|
|
315
|
+
counts: {
|
|
316
|
+
candidates: number
|
|
317
|
+
selected: number
|
|
318
|
+
dropped: number
|
|
319
|
+
}
|
|
320
|
+
recall: number
|
|
321
|
+
injectedChars: number
|
|
322
|
+
injectedTokens: number
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** C9 result with the kill-switch state available as a type discriminator. */
|
|
326
|
+
export type C9ContextResult =
|
|
327
|
+
| {
|
|
328
|
+
enabled: true
|
|
329
|
+
context: string
|
|
330
|
+
telemetry: C9Telemetry
|
|
331
|
+
}
|
|
332
|
+
| {
|
|
333
|
+
enabled: false
|
|
334
|
+
context: ''
|
|
335
|
+
telemetry: C9Telemetry
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function telemetry(
|
|
339
|
+
signal: C9Telemetry['signal'],
|
|
340
|
+
candidates: number,
|
|
341
|
+
selected: number,
|
|
342
|
+
dropped: number,
|
|
343
|
+
recall: number,
|
|
344
|
+
context = '',
|
|
345
|
+
): C9Telemetry {
|
|
346
|
+
return {
|
|
347
|
+
signal,
|
|
348
|
+
counts: { candidates, selected, dropped },
|
|
349
|
+
recall,
|
|
350
|
+
injectedChars: context.length,
|
|
351
|
+
injectedTokens: estimateTokens(context),
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Prepare auditable context without performing retrieval or side effects.
|
|
357
|
+
*
|
|
358
|
+
* The caller supplies the query and candidates already retrieved. The
|
|
359
|
+
* kill-switch is read only from the supplied environment object, so this
|
|
360
|
+
* boundary remains deterministic and testable. C9 always receives the batch
|
|
361
|
+
* query explicitly; no option can replace it with an implicit query.
|
|
362
|
+
*/
|
|
363
|
+
export function prepareC9Context(
|
|
364
|
+
batch: C9CandidateBatch,
|
|
365
|
+
options: C9ContextOptions = {},
|
|
366
|
+
): C9ContextResult {
|
|
367
|
+
const candidateCount = batch.candidates.length
|
|
368
|
+
if (!isTokenOptEnabled(options.env)) {
|
|
369
|
+
return {
|
|
370
|
+
enabled: false,
|
|
371
|
+
context: '',
|
|
372
|
+
telemetry: telemetry('disabled', candidateCount, 0, 0, 0),
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const filtered = c9Filter(batch.candidates, options.perCategory, batch.query)
|
|
377
|
+
if (batch.query.trim().length === 0) {
|
|
378
|
+
return {
|
|
379
|
+
enabled: true,
|
|
380
|
+
context: '',
|
|
381
|
+
telemetry: telemetry(
|
|
382
|
+
'empty-query',
|
|
383
|
+
candidateCount,
|
|
384
|
+
0,
|
|
385
|
+
candidateCount,
|
|
386
|
+
candidateCount === 0 ? filtered.recall : 0,
|
|
387
|
+
),
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const context = buildC9Context(filtered.selected)
|
|
392
|
+
const signal = filtered.signal
|
|
393
|
+
const selectedCount = context.length === 0 ? 0 : filtered.selected.length
|
|
394
|
+
const droppedCount = context.length === 0 ? candidateCount : filtered.dropped.length
|
|
395
|
+
const recall = context.length === 0 && filtered.signal === 'ok' ? 0 : filtered.recall
|
|
396
|
+
return {
|
|
397
|
+
enabled: true,
|
|
398
|
+
context,
|
|
399
|
+
telemetry: telemetry(signal, candidateCount, selectedCount, droppedCount, recall, context),
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function isC9FilterOptions(
|
|
404
|
+
value: Readonly<Record<string, C9CategoryConfig>> | C9FilterOptions | string | undefined,
|
|
405
|
+
): value is C9FilterOptions {
|
|
406
|
+
return (
|
|
407
|
+
value !== undefined &&
|
|
408
|
+
typeof value !== 'string' &&
|
|
409
|
+
('perCategory' in value || ('query' in value && typeof value.query === 'string'))
|
|
410
|
+
)
|
|
411
|
+
}
|
|
412
|
+
|
|
279
413
|
/** Build the injected context: only auditable chunks (id + score + text). */
|
|
280
|
-
export function buildC9Context(selected: C9Chunk[]): string {
|
|
414
|
+
export function buildC9Context(selected: readonly C9Chunk[]): string {
|
|
281
415
|
return selected.map((c) => `[${c.category}:${c.id}|${c.score}] ${c.text}`).join('\n')
|
|
282
416
|
}
|
|
283
417
|
|
|
@@ -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,30 @@ 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
|
+
try {
|
|
108
|
+
await deps.board.registerLaunchIfAbsent({
|
|
109
|
+
taskID: info.id,
|
|
110
|
+
parentSessionID: info.parentID,
|
|
111
|
+
agent: 'native',
|
|
112
|
+
description: `native child ${info.id}`,
|
|
113
|
+
})
|
|
114
|
+
} catch (err) {
|
|
115
|
+
// The per-agent concurrency limit can refuse the launch. Do not
|
|
116
|
+
// abort the rest of the handler: this child is simply not
|
|
117
|
+
// mirrored on the board, while finalize/idle dispatch for every
|
|
118
|
+
// other event must still run.
|
|
119
|
+
log.warn(`[Pantheon V2] native child ${info.id} not mirrored on board:`, err)
|
|
120
|
+
}
|
|
104
121
|
}
|
|
105
122
|
}
|
|
106
123
|
}
|
package/src/plugin-v2.ts
CHANGED
|
@@ -612,18 +612,6 @@ export interface V2ToolResult {
|
|
|
612
612
|
output: string
|
|
613
613
|
}
|
|
614
614
|
|
|
615
|
-
/**
|
|
616
|
-
* Wrap raw tool text into the object shape the V2 beta host requires.
|
|
617
|
-
* Never throws (fail-open): non-string input degrades to String(input).
|
|
618
|
-
*/
|
|
619
|
-
export function toV2ToolResult(text: unknown): V2ToolResult {
|
|
620
|
-
try {
|
|
621
|
-
return { output: typeof text === 'string' ? text : String(text) }
|
|
622
|
-
} catch {
|
|
623
|
-
return { output: '[pantheon] tool output unavailable' }
|
|
624
|
-
}
|
|
625
|
-
}
|
|
626
|
-
|
|
627
615
|
/**
|
|
628
616
|
* A V2 tool definition ready for registration.
|
|
629
617
|
*/
|
|
@@ -698,9 +686,9 @@ function createV2ToolDefinitionsFromContext(_context: PluginContext): V2ToolDef[
|
|
|
698
686
|
// costCommand/goalTools) once V1 infrastructure is resolvable in the V2
|
|
699
687
|
// standalone context. The before/after factories in v2-hooks.ts:216-282
|
|
700
688
|
// are likewise unwired. Until then, placeholders resolve to the object
|
|
701
|
-
// shape
|
|
689
|
+
// shape so the beta host never sees a bare string.
|
|
702
690
|
execute: async (_input: Record<string, unknown>, _context: unknown): Promise<V2ToolResult> => {
|
|
703
|
-
return
|
|
691
|
+
return { output: `${name}: ${v2UnavailableMessage}` }
|
|
704
692
|
},
|
|
705
693
|
}))
|
|
706
694
|
}
|
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,
|
|
@@ -12,11 +11,7 @@ import {
|
|
|
12
11
|
DEFAULT_LIMITS as SANDBOX_DEFAULT_LIMITS,
|
|
13
12
|
} from './pantheon/context-sandbox.ts'
|
|
14
13
|
import { createCostCommand } from './pantheon/cost-command.ts'
|
|
15
|
-
import {
|
|
16
|
-
buildPluginNativeDelegation,
|
|
17
|
-
resolveDelegateMode,
|
|
18
|
-
withDelegationKillSwitch,
|
|
19
|
-
} from './pantheon/delegate-task-adapter.ts'
|
|
14
|
+
import { buildPluginNativeDelegation, resolveDelegateMode } from './pantheon/delegate-manager.ts'
|
|
20
15
|
import {
|
|
21
16
|
collectRootSessionIDs,
|
|
22
17
|
createDelegationTools,
|
|
@@ -34,7 +29,6 @@ import {
|
|
|
34
29
|
} from './pantheon/delegation-enforce.ts'
|
|
35
30
|
import { handleDelegationEvent } from './pantheon/delegation-notify.ts'
|
|
36
31
|
import { showDelegationTerminalToast, type ToastClient } from './pantheon/delegation-toast.ts'
|
|
37
|
-
import { FilePersistenceAdapter } from './pantheon/file-persistence.ts'
|
|
38
32
|
import { GOAL_LOOP_DEFAULTS, GoalLoop, GoalStore } from './pantheon/goal-loop.ts'
|
|
39
33
|
import { createReadEnhancer } from './pantheon/hashline/read-enhancer.ts'
|
|
40
34
|
import { createHashlineEditTool } from './pantheon/hashline/tool.ts'
|
|
@@ -49,7 +43,9 @@ import {
|
|
|
49
43
|
loadRoutingPermissionTask,
|
|
50
44
|
} from './pantheon/presets.mjs'
|
|
51
45
|
import { safeSessionPath } from './pantheon/session-guard.ts'
|
|
46
|
+
import { getSharedBoard } from './pantheon/shared-board.ts'
|
|
52
47
|
import { StepCapTracker } from './pantheon/step-cap.ts'
|
|
48
|
+
import { createTaskResultGuard } from './pantheon/task-result-guard.ts'
|
|
53
49
|
import {
|
|
54
50
|
TODO_ENFORCER_DEFAULTS,
|
|
55
51
|
TodoEnforcer,
|
|
@@ -57,6 +53,7 @@ import {
|
|
|
57
53
|
todoEnforcerEnabledFromEnv,
|
|
58
54
|
} from './pantheon/todo-enforcer.ts'
|
|
59
55
|
import { TodoPreserver } from './pantheon/todo-preserve.ts'
|
|
56
|
+
import { handleToolCeilingEvent, type ToolCeilingResult } from './pantheon/tool-ceiling.ts'
|
|
60
57
|
import { checkTuiVersionStaleness } from './pantheon/tui-version-check.ts'
|
|
61
58
|
import { activePresetCandidates, createVisionHandler } from './pantheon/vision.ts'
|
|
62
59
|
|
|
@@ -79,12 +76,11 @@ const stepCapTracker = new StepCapTracker(loadRoutingMaxSteps({ logger: log }))
|
|
|
79
76
|
// (fail-open → undefined = existing runtime matrix only).
|
|
80
77
|
const routingPermissionTask = loadRoutingPermissionTask({ logger: log })
|
|
81
78
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
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()
|
|
88
84
|
let terminalToastClient: ToastClient = {}
|
|
89
85
|
board
|
|
90
86
|
.recoverRunningJobs()
|
|
@@ -169,6 +165,7 @@ const COMPACTION_MAX_ITEMS = 10
|
|
|
169
165
|
const rootSessions = new Set<string>()
|
|
170
166
|
const sessionHierarchy = new SessionHierarchyRegistry()
|
|
171
167
|
const sessionAgents = new Map<string, string>()
|
|
168
|
+
const sessionToolCeilings = new Map<string, ToolCeilingResult>()
|
|
172
169
|
|
|
173
170
|
/**
|
|
174
171
|
* Seed resumed root sessions without making OpenCode startup depend on the
|
|
@@ -375,8 +372,7 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
375
372
|
// WS1 (PR #94): delegation backend gate. Default = legacy (the V1
|
|
376
373
|
// fire-and-forget toolset, intact); PANTHEON_DELEGATE_MODE=native selects
|
|
377
374
|
// the thin manager over native task() semantics. Kill-switch
|
|
378
|
-
// PANTHEON_DELEGATION=off covers
|
|
379
|
-
// legacy via withDelegationKillSwitch). finalizeDelegation always stays on
|
|
375
|
+
// PANTHEON_DELEGATION=off covers the native manager; finalizeDelegation stays on
|
|
380
376
|
// the legacy observer: it works on the shared board in both modes and must
|
|
381
377
|
// never be kill-switched, so children never orphan. The idle hook below is
|
|
382
378
|
// untouched.
|
|
@@ -428,7 +424,7 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
428
424
|
...(wallClockTimeoutMs !== undefined ? { wallClockTimeoutMs } : {}),
|
|
429
425
|
finalizeDelegation: legacyDelegation.finalizeDelegation,
|
|
430
426
|
})
|
|
431
|
-
:
|
|
427
|
+
: legacyDelegation
|
|
432
428
|
|
|
433
429
|
// Proactive idle-child scan (Fix 2): finalize children that are idle on the
|
|
434
430
|
// board but have no MD report on disk — eliminates the phantom "running"
|
|
@@ -509,13 +505,18 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
509
505
|
},
|
|
510
506
|
}
|
|
511
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()
|
|
512
512
|
const combinedAfter: typeof readEnhancer = async (input, output) => {
|
|
513
|
+
await taskResultGuard(input, output)
|
|
513
514
|
await sandboxHandler(input, output)
|
|
514
515
|
await readEnhancer(input, output)
|
|
515
516
|
}
|
|
516
517
|
|
|
517
518
|
// Wave 4 (PR #46): /cost — delegation cost + token visibility. Reads
|
|
518
|
-
// opencode.db read-only
|
|
519
|
+
// opencode.db read-only via the single node:sqlite backend.
|
|
519
520
|
// Fully wired (unlike dispatch-guard, which is manual-orchestration-only
|
|
520
521
|
// because opencode 1.18.x cannot intercept task completion via hooks).
|
|
521
522
|
const costCommand = createCostCommand()
|
|
@@ -620,6 +621,9 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
620
621
|
if (ev.type === 'session.compacted') {
|
|
621
622
|
await todoPreserver.onCompacted(ev.properties.sessionID)
|
|
622
623
|
}
|
|
624
|
+
if (ev.type === 'message.updated') {
|
|
625
|
+
await handleToolCeilingEvent(input.client, ev, sessionToolCeilings)
|
|
626
|
+
}
|
|
623
627
|
// Phase 3: observe completion on child sessions → finalizeDelegation.
|
|
624
628
|
// The board transition fires onTerminal → the file-only audit log (no
|
|
625
629
|
// chat delivery — see the onTerminal listener above). Unknown sessions
|
|
@@ -685,6 +689,13 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
685
689
|
list: (sessionID: string) => todoEnforcer.listPendingTodos(sessionID),
|
|
686
690
|
},
|
|
687
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
|
+
}
|
|
688
699
|
if (blocks.length > 0) {
|
|
689
700
|
output.context.push(...blocks)
|
|
690
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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.js","names":[],"sources":["../src/server.ts"],"sourcesContent":["/**\n * No-op server stub for the opencode plugin loader.\n *\n * The opencode plugin host requires every plugin (TUI included) to expose a\n * `server()` entry via `exports[\"./server\"]` (or package.json `main`). When\n * that entry is missing the host fails to attach the plugin's server half and\n * the TUI runtime never wires the reactive channel — the initial JSX mounts\n * but effects/refresh/events stay dead (the \"mudo panel\" symptom).\n *\n * This stub satisfies the loader contract; pantheon-tui is TUI-only and has\n * no background server work of its own.\n */\nexport default function server() {\n return {}\n}\n"],"mappings":";;;;;;;;;;;;;AAYA,SAAwB,SAAS;CAC/B,OAAO,CAAC;AACV"}
|
|
1
|
+
{"version":3,"file":"server.js","names":[],"sources":["../src/server.ts"],"sourcesContent":["/**\n * No-op server stub for the opencode plugin loader.\n *\n * The opencode plugin host requires every plugin (TUI included) to expose a\n * `server()` entry via `exports[\"./server\"]` (or package.json `main`). When\n * that entry is missing the host fails to attach the plugin's server half and\n * the TUI runtime never wires the reactive channel — the initial JSX mounts\n * but effects/refresh/events stay dead (the \"mudo panel\" symptom).\n *\n * This stub satisfies the loader contract; pantheon-tui is TUI-only and has\n * no background server work of its own.\n */\nexport default function server() {\n return {}\n}\n"],"mappings":";;;;;;;;;;;;;AAYA,SAAwB,SAAS;CAC/B,OAAO,CAAC;AACV"}
|