pantheon-opencode 1.3.7 → 1.4.1

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.
@@ -58,14 +58,9 @@ import {
58
58
  finalizeDelegation as finalizeDelegationReport,
59
59
  readDelegationReport,
60
60
  } from './delegation-finalize.ts'
61
- import {
62
- type IdleChildScanDeps,
63
- finalizeIdleChildrenWithoutMd,
64
- startIdleChildScan,
65
- } from './delegation-notify.ts'
61
+ import { finalizeIdleChildrenWithoutMd } from './delegation-notify.ts'
66
62
  import { createPantheonLogger } from './logger.ts'
67
63
  import { buildAgentListDescription } from './permission-globs.ts'
68
- import { missingProviderKeyEnv, resolveActivePreset } from './presets.mjs'
69
64
  import { buildStopInstruction, cappedSummary, DEFAULT_MAX_STEPS } from './step-cap.ts'
70
65
 
71
66
  export type {
@@ -217,8 +212,10 @@ async function bootstrapChild(
217
212
  const remainingMs = timeoutMs - (now() - startedAt)
218
213
  if (hasMessagesAPI) {
219
214
  try {
215
+ const messagesAPI = client.session.messages
216
+ if (messagesAPI === undefined) continue
220
217
  const messages = await boundedProbe(
221
- client.session.messages!({ path: { id: childSessionID } }),
218
+ messagesAPI({ path: { id: childSessionID } }),
222
219
  remainingMs,
223
220
  )
224
221
  if (messages !== undefined) {
@@ -231,8 +228,10 @@ async function bootstrapChild(
231
228
  }
232
229
  if (hasStatusAPI) {
233
230
  try {
231
+ const statusAPI = client.session.get
232
+ if (statusAPI === undefined) continue
234
233
  const status = await boundedProbe(
235
- client.session.get!({ path: { id: childSessionID } }),
234
+ statusAPI({ path: { id: childSessionID } }),
236
235
  timeoutMs - (now() - startedAt),
237
236
  )
238
237
  if (status !== undefined) {
@@ -591,20 +590,6 @@ export interface ChildSessionModelRef {
591
590
  providerID: string
592
591
  }
593
592
 
594
- /**
595
- * Known-good fallback model (P1, 2026-08-11 — release 1.3.4, updated
596
- * 2026-08-21 for sandbox without PANTHEON_OPENCODE_API_KEY): when an
597
- * AUTO-RESOLVED model (routing.yml agent entry / active preset) points at a
598
- * provider whose API key is not configured, the child is dispatched on the
599
- * `opencode-go` provider instead — `opencode-go/deepseek-v4-flash`
600
- * via https://opencode.ai/zen/go/v1 works without an external API key
601
- * (Go subscription / opencode auth, sandbox default). Validated with the
602
- * same providerKeyConfigured gate as the resolved model, with an
603
- * opencode-go exception for the sandbox; an explicit caller-supplied
604
- * `model` always wins over this fallback.
605
- */
606
- const FALLBACK_MODEL = 'opencode-go/deepseek-v4-flash'
607
-
608
593
  /**
609
594
  * Split a `provider/model` model ID (e.g. `opencode/deepseek-v4-flash-free`)
610
595
  * into the `{ id, providerID }` ref the session.create body expects.
@@ -617,12 +602,10 @@ function splitModelRef(model: string): ChildSessionModelRef | undefined {
617
602
  }
618
603
 
619
604
  /**
620
- * Resolve the child session model with priority:
621
- * 1. explicit `model` option passed to the delegate tool;
622
- * 2. `options.agentModels[agent]` (routing.yml agent entry, case-insensitive);
623
- * 3. the active preset's agent entry via resolveActivePreset();
624
- * 4. fallback: undefined — the child is created without a model (the
625
- * caller warns) so opencode's default applies.
605
+ * Resolve only an explicitly supplied model. A routing profile's `model` is
606
+ * passed through as `options.agentModels`; profiles without one, and agents
607
+ * without a profile, intentionally resolve to undefined so the OpenCode
608
+ * parent session can provide inheritance.
626
609
  */
627
610
  function resolveChildModel(
628
611
  options: DelegationOptions,
@@ -641,43 +624,14 @@ function resolveChildModel(
641
624
  const ref = splitModelRef(mapped)
642
625
  if (ref !== undefined) return ref
643
626
  }
644
- // (c) active preset agent entry (resolveActivePreset reads routing.yml
645
- // presets + .pantheon/active-preset.json). Guarded — a broken routing.yml
646
- // must never kill the delegate.
647
- try {
648
- const presetModel = resolveActivePreset({
649
- ...(options.presetEnv !== undefined ? { env: options.presetEnv } : {}),
650
- ...(options.logger !== undefined ? { logger: options.logger } : {}),
651
- })?.agents?.[key]?.model
652
- if (presetModel !== undefined && presetModel !== '') {
653
- const ref = splitModelRef(presetModel)
654
- if (ref !== undefined) return ref
655
- }
656
- } catch (err: unknown) {
657
- const reason = err instanceof Error ? err.message : String(err)
658
- options.logger?.warn?.(`[pantheon-delegate] active preset resolution failed: ${reason}`)
659
- }
660
627
  return undefined
661
628
  }
662
629
 
663
630
  /**
664
- * Resolve a USABLE child model for a delegate dispatch, gating the resolved
665
- * provider's API key (P1, 2026-08-11 — release 1.3.4):
666
- *
667
- * 1. resolve the model with the existing precedence (explicit `model` >
668
- * options.agentModels > active preset) — see resolveChildModel.
669
- * 2. provider key gate: the resolved provider requires its apiKeyEnv env
670
- * var (routing.yml preset defs — same source applyPreset enforces at
671
- * startup). No gate (native providers) or key present → usable as-is.
672
- * 3. key MISSING + EXPLICIT caller model → respected anyway (user intent),
673
- * warned.
674
- * 4. key MISSING + AUTO-resolved model → fall back to FALLBACK_MODEL
675
- * (validated the same way). Fallback also unusable → returns an `error`
676
- * TEXT (the caller returns it from the tool — never throws) and the
677
- * caller registers NO job on the board.
678
- *
679
- * The no-model case (nothing resolved) keeps the pre-existing behavior:
680
- * warn and let the child use opencode's default.
631
+ * Resolve a child model without selecting a provider or model on the caller's
632
+ * behalf. OpenCode owns model inheritance when this returns no model; if the
633
+ * host requires one, its session.create/promptAsync error is returned with
634
+ * the normal diagnostic rather than silently choosing a fallback.
681
635
  *
682
636
  * @returns `{ model, error }` — at most one of `model` / `error` is set;
683
637
  * both may be unset (no model resolved, warn emitted).
@@ -687,50 +641,15 @@ function resolveUsableChildModel(
687
641
  agent: string,
688
642
  explicitModel: string | undefined,
689
643
  ): { model: ChildSessionModelRef | undefined; error: string | undefined } {
690
- const env = options.presetEnv ?? process.env
691
- const explicit = explicitModel !== undefined && explicitModel !== ''
692
- const warn = (msg: string) => options.logger?.warn?.(msg)
693
-
694
644
  const model = resolveChildModel(options, agent, explicitModel)
695
645
  if (model === undefined) {
696
- warn(
646
+ options.logger?.warn?.(
697
647
  `[pantheon-delegate] no model resolved for agent "${agent}" — ` +
698
- "child session will use opencode's default model (may require API keys)",
648
+ 'omitting model so OpenCode can inherit it from the parent session',
699
649
  )
700
650
  return { model: undefined, error: undefined }
701
651
  }
702
-
703
- const missingVar = missingProviderKeyEnv(model.providerID, { env })
704
- if (missingVar === undefined) return { model, error: undefined }
705
-
706
- if (explicit) {
707
- // Rule of precedence: the caller's explicit model is respected — warn only.
708
- warn(
709
- `[pantheon-delegate] model "${explicitModel}" provider "${model.providerID}" requires ` +
710
- `API key ${missingVar} (unset) — respecting the explicit model anyway`,
711
- )
712
- return { model, error: undefined }
713
- }
714
-
715
- // Auto-resolved (agentModels / preset) → try the known-good fallback.
716
- const fallback = splitModelRef(FALLBACK_MODEL)
717
- if (
718
- fallback !== undefined &&
719
- (fallback.providerID === 'opencode-go' ||
720
- missingProviderKeyEnv(fallback.providerID, { env }) === undefined)
721
- ) {
722
- warn(
723
- `[pantheon-delegate] provider "${model.providerID}" requires API key ${missingVar} ` +
724
- `(unset) — falling back to ${FALLBACK_MODEL}`,
725
- )
726
- return { model: fallback, error: undefined }
727
- }
728
-
729
- const message =
730
- `pantheon_delegate: no usable model for agent "${agent}" — provider "${model.providerID}" ` +
731
- `requires API key (set ${missingVar} or PANTHEON_MODEL_PRESET)`
732
- warn(`[pantheon-delegate] ${message}`)
733
- return { model: undefined, error: message }
652
+ return { model, error: undefined }
734
653
  }
735
654
 
736
655
  // ─── Factory ───────────────────────────────────────────────────────────
@@ -900,13 +819,8 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
900
819
  // session.create failure → return a clear error as TEXT (tools return
901
820
  // errors as text, not thrown) and register NO job on the board — an
902
821
  // unhandled rejection here would otherwise lose the failure entirely.
903
- // The child model is resolved with a provider API-key gate (P1): the
904
- // resolved model (explicit `model` > options.agentModels > active
905
- // preset) is used when its provider key is configured; an explicit
906
- // caller model is always respected (warned); an auto-resolved model
907
- // whose provider key is missing falls back to the known-good
908
- // opencode-go/deepseek-v4-flash; if nothing usable remains the
909
- // failure is returned as TEXT below — no session, no board job.
822
+ // Only an explicit delegate model or routing profile model is sent.
823
+ // Otherwise omit it so OpenCode inherits the parent model.
910
824
  const resolved = resolveUsableChildModel(options, args.agent, args.model)
911
825
  if (resolved.error !== undefined) {
912
826
  if (budgetReserved && budgetKey !== undefined)
@@ -86,13 +86,14 @@ export declare function loadPresetDefs(routingPath?: string): Record<string, Pre
86
86
 
87
87
  export interface RoutingAgentModelsOptions {
88
88
  routingPath?: string
89
+ candidates?: string[]
90
+ env?: Record<string, string | undefined>
89
91
  logger?: { warn?: (msg: string) => void }
90
92
  }
91
93
 
92
94
  /**
93
- * Lowercase agent → "provider/model" mapping from routing.yml's FIRST
94
- * (default) preset — the static source for the delegation toolset's
95
- * options.agentModels. Fail-open: missing/corrupt routing.yml → {}.
95
+ * Lowercase agent → "provider/model" mapping from the explicitly active
96
+ * routing profile. No active profile (or a profile without model) → {}.
96
97
  */
97
98
  export declare function loadRoutingAgentModels(
98
99
  options?: RoutingAgentModelsOptions,
@@ -345,46 +345,42 @@ export function loadRoutingPermissionTask({ routingPath, logger = console } = {}
345
345
  }
346
346
 
347
347
  /**
348
- * Load the DEFAULT agent → model mapping from routing.yml for the delegation
348
+ * Load the active routing profile's agent → model mapping for the delegation
349
349
  * toolset (Fase 6 — wiring agentModels).
350
350
  *
351
351
  * routing.yml keeps per-agent models ONLY inside presets
352
352
  * (`presets.<name>.agents.<agent>.model`) — the top-level `agents:` section
353
- * carries no model field. The FIRST-listed preset is the static default
354
- * (go-deepseek today): a mapping INDEPENDENT of the active preset, so a
355
- * delegated child always gets a sane per-agent model even when no preset is
356
- * active — delegation no longer depends 100% on the active preset (branch (b)
357
- * of resolveChildModel, which outranks the active-preset branch (c)).
353
+ * carries no model field. No profile means an empty mapping; there is no
354
+ * implicit first/default preset or fallback model.
358
355
  *
359
- * Fail-open: a missing or unparseable routing.yml yields {} (the caller keeps
360
- * the previous behavior — active preset / opencode default) and warns via the
356
+ * Fail-open: a missing or unparseable routing.yml yields {} and warns via the
361
357
  * optional logger; it NEVER throws at startup.
362
358
  *
363
359
  * @param {object} [opts]
364
360
  * @param {string} [opts.routingPath]
361
+ * @param {string[]} [opts.candidates]
362
+ * @param {Record<string, string|undefined>} [opts.env]
365
363
  * @param {{warn?: Function}} [opts.logger]
366
364
  * @returns {Record<string, string>} lowercase agent → "provider/model"
367
365
  */
368
- export function loadRoutingAgentModels({ routingPath, logger = console } = {}) {
369
- let defs
366
+ export function loadRoutingAgentModels({ routingPath, candidates, env, logger = console } = {}) {
370
367
  try {
371
- defs = loadPresetDefs(routingPath)
368
+ const active = resolveActivePreset({ routingPath, candidates, env, logger })
369
+ if (active === null) return {}
370
+ const models = {}
371
+ for (const [agent, spec] of Object.entries(active.agents ?? {})) {
372
+ if (spec && typeof spec === 'object' && typeof spec.model === 'string' && spec.model !== '') {
373
+ models[agent.toLowerCase()] = spec.model
374
+ }
375
+ }
376
+ return models
372
377
  } catch (err) {
373
378
  const reason = err instanceof Error ? err.message : String(err)
374
379
  logger.warn?.(
375
- `presets: routing.yml agent models unavailable (${reason}) — delegation falls back to the active preset/default`,
380
+ `presets: routing.yml active profile models unavailable (${reason}) — child model omitted`,
376
381
  )
377
382
  return {}
378
383
  }
379
- const names = Object.keys(defs)
380
- if (names.length === 0) return {}
381
- const models = {}
382
- for (const [agent, spec] of Object.entries(defs[names[0]]?.agents ?? {})) {
383
- if (spec && typeof spec === 'object' && typeof spec.model === 'string' && spec.model !== '') {
384
- models[agent.toLowerCase()] = spec.model
385
- }
386
- }
387
- return models
388
384
  }
389
385
 
390
386
  /**
package/src/plugin.ts CHANGED
@@ -1,9 +1,16 @@
1
- import type { Plugin, PluginInput } from '@opencode-ai/plugin'
2
- import type { PluginConfig } from 'opencode'
3
1
  import { existsSync } from 'node:fs'
4
2
  import { join } from 'node:path'
3
+ import type { Plugin, PluginInput } from '@opencode-ai/plugin'
4
+ import type { PluginConfig } from 'opencode'
5
5
  import { BackgroundJobBoard } from './pantheon/background-job-board.ts'
6
- import { reassertAfterCompaction } from './pantheon/compaction-assert.ts'
6
+ import { createCommandNormalizer } from './pantheon/command-normalizer.ts'
7
+ import {
8
+ type ContextSandboxConfig,
9
+ createContextSandbox,
10
+ resolveSandboxConfig,
11
+ DEFAULT_CONFIG as SANDBOX_DEFAULT_CONFIG,
12
+ DEFAULT_LIMITS as SANDBOX_DEFAULT_LIMITS,
13
+ } from './pantheon/context-sandbox.ts'
7
14
  import { createCostCommand } from './pantheon/cost-command.ts'
8
15
  import {
9
16
  collectRootSessionIDs,
@@ -21,6 +28,7 @@ import {
21
28
  zeusReadGuard,
22
29
  } from './pantheon/delegation-enforce.ts'
23
30
  import { handleDelegationEvent } from './pantheon/delegation-notify.ts'
31
+ import { showDelegationTerminalToast, type ToastClient } from './pantheon/delegation-toast.ts'
24
32
  import { FilePersistenceAdapter } from './pantheon/file-persistence.ts'
25
33
  import { GOAL_LOOP_DEFAULTS, GoalLoop, GoalStore } from './pantheon/goal-loop.ts'
26
34
  import { createReadEnhancer } from './pantheon/hashline/read-enhancer.ts'
@@ -54,17 +62,6 @@ import { activePresetCandidates, createVisionHandler } from './pantheon/vision.t
54
62
  // .pantheon/logs/hooks.log; the console echo is opt-in via PANTHEON_HOOKS_LOG=1.
55
63
  const log = createPantheonLogger({ module: 'pantheon-plugin' })
56
64
 
57
- // Fase 6 (release-134): the delegation toolset's options.agentModels (branch
58
- // (b) of resolveChildModel) is wired from routing.yml's default (first)
59
- // preset — a STATIC per-agent model mapping independent of the active preset,
60
- // so delegated children get a sane model even without an active preset (the
61
- // previous production wiring left branch (b) dead: delegation depended 100%
62
- // on the active preset). Built ONCE at module load, never per dispatch.
63
- // Fail-open: a missing/corrupt routing.yml yields {} (warned by the helper)
64
- // and delegation falls back to the active preset / opencode default — the
65
- // plugin never throws at startup.
66
- const routingAgentModels = loadRoutingAgentModels({ logger: log })
67
-
68
65
  // R4: per-agent step caps from routing.yml `agents.<name>.max_steps`
69
66
  // (fail-open → {} = no agent capped). R1 retry_policy/cooldown are NOT
70
67
  // wired here — the plugin has no retry path (opencode cannot intercept task
@@ -82,6 +79,7 @@ const board = new BackgroundJobBoard({
82
79
  })
83
80
  const persistence = new FilePersistenceAdapter('.pantheon/board/state.json')
84
81
  board.setPersistence(persistence)
82
+ let terminalToastClient: ToastClient = {}
85
83
  board
86
84
  .recoverRunningJobs()
87
85
  .catch((err) => log.error('[Pantheon Plugin] Failed to recover running jobs:', err))
@@ -100,6 +98,8 @@ board.onTerminal((taskID: string) => {
100
98
  log.info(
101
99
  `[Pantheon Plugin] Board terminal: [${job.alias}] ${job.description} → ${job.state}${job.resultSummary ? ` — ${job.resultSummary}` : ''}`,
102
100
  )
101
+ // Deliberately direct-to-TUI: onTerminal must not inject chat/transcript text.
102
+ void showDelegationTerminalToast(terminalToastClient, job)
103
103
  })
104
104
 
105
105
  // Periodically prune terminal/reconciled jobs (24h TTL, every 30 min).
@@ -131,6 +131,13 @@ const enforcementGuard = createEnforcementGuard({
131
131
  },
132
132
  })
133
133
 
134
+ // Fase 1 (command-normalizer): deterministic `python` → `python3` rewrite for
135
+ // `bash` commands (PEP 394 — Ubuntu 20.04+ has no bare `python`). Unlike the
136
+ // enforcement guard above (which THROWS to deny), this guard REWRITES the
137
+ // command in place and lets it run. Logs every rewrite + `python -c` warning
138
+ // to the shared hooks.log for @nyx frequency measurement.
139
+ const commandNormalizer = createCommandNormalizer({ logger: log })
140
+
134
141
  // Mirrors routing.yml background_delegation.max_compaction_items.
135
142
  const COMPACTION_MAX_ITEMS = 10
136
143
 
@@ -208,8 +215,7 @@ function adaptDelegationClient(client: PluginInput['client']): DelegationClient
208
215
  model?: { id: string; providerID: string }
209
216
  } = { parentID: input.body.parentID }
210
217
  if (input.body.title !== undefined) body.title = input.body.title
211
- // Forward the routed child model (E2E fix): without it the child
212
- // falls back to the key-gated default model and dies at startup.
218
+ // Forward a model only when the active routing profile supplied one.
213
219
  if (input.body.model !== undefined) body.model = input.body.model
214
220
  const result = await client.session.create({ body })
215
221
  if (result.error) throw new Error(sdkErrorMessage(result.error))
@@ -328,6 +334,7 @@ function adaptTodoEnforcerClient(client: PluginInput['client']): TodoEnforcerCli
328
334
  * Helpers live in src/pantheon/vision.ts and are imported from there directly.
329
335
  */
330
336
  const plugin: Plugin = async (input: PluginInput) => {
337
+ terminalToastClient = input.client as unknown as ToastClient
331
338
  // release-134: runtime guard against DOUBLE registration (duplicate toasts +
332
339
  // duplicate task_ids in one process — 2026-08-12 live logs). opencode
333
340
  // merges the global config (npm-package plugin paths) with the project
@@ -362,7 +369,12 @@ const plugin: Plugin = async (input: PluginInput) => {
362
369
  },
363
370
  enforceRuntimeMatrix: true,
364
371
  readOnlyAgents: new Set(['apollo', 'gaia']),
365
- agentModels: routingAgentModels,
372
+ // Only an explicitly active routing profile may provide child models.
373
+ // An empty map deliberately lets OpenCode inherit the parent model.
374
+ agentModels: loadRoutingAgentModels({
375
+ candidates: activePresetCandidates(),
376
+ logger: log,
377
+ }),
366
378
  delegationBudgets,
367
379
  stepCapTracker,
368
380
  ...(routingPermissionTask != null ? { permissionTask: routingPermissionTask } : {}),
@@ -436,6 +448,24 @@ const plugin: Plugin = async (input: PluginInput) => {
436
448
  const hashlineEdit = createHashlineEditTool()
437
449
  const readEnhancer = createReadEnhancer()
438
450
 
451
+ // Context Window Optimization (v2 P0) — tool output sandboxing
452
+ // Pure truncation hook: read/grep/glob/webfetch → head + [TRUNCATED] + tail
453
+ // Fail-open: never throws, disabled via opencode.json context_sandbox.enabled
454
+ const sandboxConfig: ContextSandboxConfig = {
455
+ enabled: SANDBOX_DEFAULT_CONFIG.enabled,
456
+ limits: {
457
+ read: { ...SANDBOX_DEFAULT_LIMITS.read },
458
+ grep: { ...SANDBOX_DEFAULT_LIMITS.grep },
459
+ glob: { ...SANDBOX_DEFAULT_LIMITS.glob },
460
+ webfetch: { ...SANDBOX_DEFAULT_LIMITS.webfetch },
461
+ },
462
+ }
463
+ const sandboxHandler = createContextSandbox(sandboxConfig)
464
+ const combinedAfter: typeof readEnhancer = async (input, output) => {
465
+ await sandboxHandler(input, output)
466
+ await readEnhancer(input, output)
467
+ }
468
+
439
469
  // Wave 4 (PR #46): /cost — delegation cost + token visibility. Reads
440
470
  // opencode.db read-only (node:sqlite, falls back to scripts/cost.mjs).
441
471
  // Fully wired (unlike dispatch-guard, which is manual-orchestration-only
@@ -452,6 +482,15 @@ const plugin: Plugin = async (input: PluginInput) => {
452
482
  // WITHOUT a parentID is a root. Fail-open: a failed/throttled list
453
483
  // leaves the set untouched (delegation.ts treats unknown sessions as
454
484
  // roots), and config setup must never wait for the session API.
485
+ // Context sandbox config — opencode.json → context_sandbox: {enabled, limits}
486
+ try {
487
+ const raw = (config as unknown as Record<string, unknown>).context_sandbox
488
+ const resolved = resolveSandboxConfig(raw)
489
+ sandboxConfig.enabled = resolved.enabled
490
+ sandboxConfig.limits = resolved.limits
491
+ } catch {
492
+ // fail-open: keep defaults
493
+ }
455
494
  seedRootSessionsInBackground(input)
456
495
  config.agentsPath = config.agentsPath ?? []
457
496
  config.agentsPath.push(new URL('./agents', import.meta.url).pathname)
@@ -504,9 +543,7 @@ const plugin: Plugin = async (input: PluginInput) => {
504
543
  'chat.message': async (hookInput, output) => {
505
544
  await vision.chatMessage(hookInput, output)
506
545
  // No delegation notification delivery here — the user policy is ZERO
507
- // notification text injected into the chat transcript (previously the
508
- // notifier.flushQueue prepend). pantheon-hooks' chat-reminders.ts keeps
509
- // its own <system-reminder> channel, untouched.
546
+ // notification text injected into the chat transcript.
510
547
  // Wave 1: a user message is activity — the todo enforcer's
511
548
  // user-activity gate skips injection for userActivityQuietMs afterwards.
512
549
  todoEnforcer.noteUserActivity(hookInput.sessionID)
@@ -531,17 +568,6 @@ const plugin: Plugin = async (input: PluginInput) => {
531
568
  // rewritten with the exact list (see todo-preserve.ts). Fail-open.
532
569
  if (ev.type === 'session.compacted') {
533
570
  await todoPreserver.onCompacted(ev.properties.sessionID)
534
- // release-134 Phase 4: re-assert post-compaction state — enqueue a
535
- // fresh-state reminder (running/unread board jobs + active goals) for
536
- // the session's next chat.message delivery. Fail-open (never throws).
537
- await reassertAfterCompaction({
538
- sessionID: ev.properties.sessionID,
539
- board,
540
- goals: {
541
- enabled: GOAL_LOOP_DEFAULTS.enabled,
542
- list: (s: string) => goalStore.list(s),
543
- },
544
- })
545
571
  }
546
572
  // Phase 3: observe completion on child sessions → finalizeDelegation.
547
573
  // The board transition fires onTerminal → the file-only audit log (no
@@ -578,13 +604,16 @@ const plugin: Plugin = async (input: PluginInput) => {
578
604
  // no-ops for non-matching sessions/tools.
579
605
  zeusReadGuard(input.tool, output?.args, sessionAgents.get(input.sessionID))
580
606
  await enforcementGuard(input, output)
607
+ await commandNormalizer(input, output)
581
608
  await todoPreserver.beforeTodoWrite(input, output)
582
609
  },
583
- // Wave 2 (PR #46): augment `read` output with hashline tags. Additive —
610
+ // Wave 2 (PR #46) + Context Sandbox (v2 P0): sandbox truncates oversized
611
+ // outputs BEFORE hashline tags (so tags remain stable on kept lines).
612
+ // Additive —
584
613
  // pantheon-hooks.ts (a separate plugin instance) owns its own
585
614
  // tool.execute.after, so there is no key collision. Non-read tools pass
586
615
  // through untouched.
587
- 'tool.execute.after': readEnhancer,
616
+ 'tool.execute.after': combinedAfter,
588
617
  // Phase 4 + release-134 Phase 2: keep the session's working state across
589
618
  // compaction — preservation directive, active goals (<mission_context>),
590
619
  // pending todos (<todo_context>), and in-flight background delegations