pantheon-opencode 1.5.0-beta.3 → 1.5.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Native Task Status — classifies BackgroundJobRecord results from native
3
+ * task() delegation into actionable status codes.
4
+ *
5
+ * Pure module — zero external dependencies. Consumed by native-probe.ts
6
+ * and the delegation manager to decide retry/skip/escalate strategy.
7
+ *
8
+ * @module native-task-status
9
+ */
10
+
11
+ import type { BackgroundJobRecord } from './background-job-board.ts'
12
+
13
+ // ─── Types ─────────────────────────────────────────────────────────────
14
+
15
+ /** Actionable status for a native task() delegation result. */
16
+ export type NativeTaskStatus =
17
+ | 'OK'
18
+ | 'UNSUPPORTED'
19
+ | 'UNAVAILABLE'
20
+ | 'INVALID_INPUT'
21
+ | 'INVALID_STATE'
22
+ | 'CONFLICT'
23
+ | 'CORRUPT_DATA'
24
+ | 'TIMEOUT'
25
+ | 'ESCALATE'
26
+
27
+ // ─── Helpers ───────────────────────────────────────────────────────────
28
+
29
+ /** Check if a string contains any of the given substrings (case-insensitive). */
30
+ function containsAny(text: string | undefined, patterns: readonly string[]): boolean {
31
+ if (text == null) return false
32
+ const lower = text.toLowerCase()
33
+ return patterns.some((p) => lower.includes(p.toLowerCase()))
34
+ }
35
+
36
+ /** Check if a job's output is empty or whitespace-only. */
37
+ function isOutputEmpty(job: BackgroundJobRecord): boolean {
38
+ const summary = job.resultSummary
39
+ if (summary == null) return true
40
+ if (typeof summary !== 'string') return true
41
+ return summary.trim() === ''
42
+ }
43
+
44
+ // ─── Implementation ────────────────────────────────────────────────────
45
+
46
+ /**
47
+ * Classify a BackgroundJobRecord result into a NativeTaskStatus.
48
+ *
49
+ * Priority order (first match wins):
50
+ * 1. verifyError and lastStatusError patterns → UNSUPPORTED, CONFLICT,
51
+ * INVALID_INPUT, INVALID_STATE, CORRUPT_DATA, ESCALATE
52
+ * 2. state === startup_failed or timedOut → TIMEOUT
53
+ * 3. state === completed + non-empty output → OK
54
+ * 4. state === completed + empty output → UNAVAILABLE (silent failure / admission rejection)
55
+ * 5. state === error → UNAVAILABLE (safe default for provider/infra errors)
56
+ * 6. Fallback → UNAVAILABLE
57
+ */
58
+ export function classifyNativeResult(
59
+ job: BackgroundJobRecord,
60
+ verifyError?: string,
61
+ ): NativeTaskStatus {
62
+ const statusError = [verifyError, job.lastStatusError]
63
+ .filter((value): value is string => value != null && value.length > 0)
64
+ .join(' ')
65
+
66
+ // A missing native task API is terminal and must not be retried or sent
67
+ // through a fallback model. Provider/transport failures remain unavailable.
68
+ if (
69
+ containsAny(statusError, [
70
+ 'api unavailable',
71
+ 'api not available',
72
+ 'api missing',
73
+ 'method not found',
74
+ 'unknown method',
75
+ 'not supported',
76
+ 'unsupported',
77
+ 'does not support',
78
+ 'task() is not available',
79
+ ])
80
+ ) {
81
+ return 'UNSUPPORTED'
82
+ }
83
+
84
+ // ── Phase 1: verifyError explicit signals (highest priority) ───────
85
+
86
+ if (verifyError != null && verifyError.length > 0) {
87
+ // CONFLICT: canDispatch=false or concurrency exceeded
88
+ if (containsAny(verifyError, ['canDispatch', 'concurrency', 'conflict'])) {
89
+ return 'CONFLICT'
90
+ }
91
+
92
+ // INVALID_INPUT: agent not recognized / invalid
93
+ if (containsAny(verifyError, ['invalid agent', 'agent not found', 'unknown agent'])) {
94
+ return 'INVALID_INPUT'
95
+ }
96
+
97
+ // INVALID_STATE: child already in terminal state
98
+ if (containsAny(verifyError, ['already terminal', 'already in terminal', 'invalid state'])) {
99
+ return 'INVALID_STATE'
100
+ }
101
+
102
+ // CORRUPT_DATA: output can't be parsed / corrupted
103
+ if (containsAny(verifyError, ['corrupt', 'parse failed', 'malformed', 'invalid format'])) {
104
+ return 'CORRUPT_DATA'
105
+ }
106
+
107
+ // ESCALATE: unrecoverable failure
108
+ if (containsAny(verifyError, ['unrecoverable', 'fatal', 'max retries exceeded'])) {
109
+ return 'ESCALATE'
110
+ }
111
+ }
112
+
113
+ // ── Phase 2: state-based classification ────────────────────────────
114
+
115
+ // TIMEOUT: startup failure or timed out
116
+ if (job.state === 'startup_failed' || job.state === 'startup_unknown' || job.timedOut) {
117
+ return 'TIMEOUT'
118
+ }
119
+
120
+ // ── Phase 3: output-based classification ───────────────────────────
121
+
122
+ if (job.state === 'completed') {
123
+ if (!isOutputEmpty(job)) {
124
+ return 'OK'
125
+ }
126
+ // completed but empty → silent failure (BackendAdmissionRejected pattern)
127
+ return 'UNAVAILABLE'
128
+ }
129
+
130
+ // ── Phase 4: error state fallback ──────────────────────────────────
131
+
132
+ if (job.state === 'error') {
133
+ return 'UNAVAILABLE'
134
+ }
135
+
136
+ // ── Fallback ───────────────────────────────────────────────────────
137
+
138
+ return 'UNAVAILABLE'
139
+ }
@@ -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 (fallback: proceed without
224
- * injected context, caller should log `notice`);
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' | 'fallback-relative' | 'nonsense'
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. An optional query enables only the explicit low-score fallback.
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
- query?: string,
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
- let signal: C9FilterResult['signal'] =
285
+ const signal: C9FilterResult['signal'] =
292
286
  chunks.length === 0 ? 'empty-input' : selected.length === 0 ? 'all-dropped' : 'ok'
293
- let notice =
287
+ const notice =
294
288
  signal === 'empty-input'
295
- ? 'C9: empty input — no chunks retrieved; fallback: proceed without injected context'
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; fallback: relax cutoff/top-k or proceed without context'
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
- filtered.signal === 'nonsense' ||
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
  }