pantheon-opencode 1.3.3 → 1.3.5-beta.52.c75b04d

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.
Files changed (52) hide show
  1. package/AGENTS.md +919 -0
  2. package/README.md +157 -15
  3. package/bin/pantheon-init.mjs +177 -120
  4. package/opencode.json +3 -6
  5. package/package.json +8 -4
  6. package/scripts/build-agents-md.mjs +195 -0
  7. package/scripts/cost.mjs +17 -0
  8. package/scripts/doctor.mjs +56 -22
  9. package/scripts/hooks/on-subagent-delegation-stop.sh +22 -5
  10. package/scripts/install/opencode.mjs +189 -38
  11. package/scripts/install/plugin.mjs +112 -28
  12. package/scripts/install-mcp.mjs +1 -1
  13. package/scripts/redaction-gate.mjs +69 -0
  14. package/scripts/release-beta-version.mjs +80 -0
  15. package/scripts/release-notes.mjs +317 -0
  16. package/scripts/test-context-compression.sh +5 -2
  17. package/scripts/validate-package.mjs +56 -0
  18. package/scripts/versioning.mjs +117 -18
  19. package/src/agents/zeus.md +6 -2
  20. package/src/pantheon/background-job-board.ts +24 -38
  21. package/src/pantheon/chat-reminders.ts +79 -0
  22. package/src/pantheon/compaction-assert.ts +160 -0
  23. package/src/pantheon/cost-command.ts +57 -18
  24. package/src/pantheon/delegation-compaction.ts +143 -16
  25. package/src/pantheon/delegation-finalize.ts +8 -1
  26. package/src/pantheon/delegation-notify.ts +19 -157
  27. package/src/pantheon/delegation.ts +330 -28
  28. package/src/pantheon/dispatch-guard.ts +9 -4
  29. package/src/pantheon/goal-loop.ts +7 -4
  30. package/src/pantheon/hashline/tool.ts +2 -1
  31. package/src/pantheon/logger.ts +117 -0
  32. package/src/pantheon/plugin-once.ts +76 -0
  33. package/src/pantheon/preemptive-compact.ts +76 -0
  34. package/src/pantheon/presets.d.mts +31 -0
  35. package/src/pantheon/presets.mjs +105 -3
  36. package/src/pantheon/todo-enforcer.ts +79 -27
  37. package/src/pantheon/todo-preserve.ts +295 -0
  38. package/src/plugin.ts +177 -50
  39. package/src/plugins/pantheon-hooks.ts +35 -22
  40. package/src/plugins/tui/dist/server.d.ts +17 -0
  41. package/src/plugins/tui/dist/server.d.ts.map +1 -0
  42. package/src/plugins/tui/dist/server.js +20 -0
  43. package/src/plugins/tui/dist/server.js.map +1 -0
  44. package/src/plugins/tui/dist/tui.d.ts +303 -1
  45. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  46. package/src/plugins/tui/dist/tui.js +977 -350
  47. package/src/plugins/tui/dist/tui.tsx +1427 -150
  48. package/src/plugins/tui/package.json +4 -3
  49. package/src/plugins/tui/src/index.tsx +1427 -150
  50. package/src/plugins/tui/src/server.ts +15 -0
  51. package/src/plugins/tui/tsdown.config.ts +1 -1
  52. package/src/routing.yml +3 -1
@@ -19,11 +19,19 @@
19
19
  * Depth guard (root-session detection): the ToolContext carries NO parentID
20
20
  * (spike), so root detection is configurable. Resolution order:
21
21
  * 1. `options.isRootSession(sessionID)` custom predicate;
22
- * 2. `options.rootSessions` explicit allowlist;
23
- * 3. default: any session WE created via `session.create` is a sub-session;
24
- * everything else is treated as root. Phase 3 should pass the root
25
- * registry derived from `session.created` events (Session.parentID
26
- * undefined ⇒ root) for authoritative enforcement.
22
+ * 2. `options.rootSessions` explicit allowlist — with a knownChildren
23
+ * fallback (compaction-134): a session is a sub-session ONLY if WE
24
+ * created it as a child (`session.create` via pantheon_delegate).
25
+ * Sessions in neither set are treated as root, so a resumed root can
26
+ * delegate after a restart even when the allowlist is incomplete
27
+ * (opencode does not replay `session.created` events for pre-existing
28
+ * sessions — the plugin seeds the registry from session.list() at
29
+ * startup, and this fallback covers a failed/incomplete seed);
30
+ * 3. default (no rootSessions): any session WE created via
31
+ * `session.create` is a sub-session; everything else is treated as
32
+ * root. Phase 3 should pass the root registry derived from
33
+ * `session.created` events (Session.parentID undefined ⇒ root) for
34
+ * authoritative enforcement.
27
35
  *
28
36
  * Read-only enforcement is Phase 4 — `read_only` is exposed on the delegate
29
37
  * args and the child session is registered in the read-only registry when the
@@ -41,12 +49,14 @@ import {
41
49
  DELEGATION_DEFAULTS,
42
50
  type DelegationClient,
43
51
  type DelegationDeps,
52
+ type DelegationMessageBundle,
44
53
  type DelegationOptions,
45
54
  type FinalizeInput,
46
55
  finalizeDelegation as finalizeDelegationReport,
47
56
  readDelegationReport,
48
57
  } from './delegation-finalize.ts'
49
- import { resolveActivePreset } from './presets.mjs'
58
+ import { createPantheonLogger } from './logger.ts'
59
+ import { missingProviderKeyEnv, resolveActivePreset } from './presets.mjs'
50
60
 
51
61
  export type {
52
62
  DelegationClient,
@@ -59,6 +69,11 @@ export { DELEGATION_DEFAULTS } from './delegation-finalize.ts'
59
69
 
60
70
  // ─── Types ─────────────────────────────────────────────────────────────
61
71
 
72
+ // Silence-by-default TUI policy (pantheon-hooks L42-58): console output in a
73
+ // plugin renders into the opencode TUI — the "lixo". Errors go to
74
+ // .pantheon/logs/hooks.log; console echo is opt-in via PANTHEON_HOOKS_LOG=1.
75
+ const log = createPantheonLogger({ module: 'pantheon-delegate' })
76
+
62
77
  /** Structural view of the tool context opencode passes to execute(). */
63
78
  export interface ToolContextLike {
64
79
  sessionID: string
@@ -131,6 +146,167 @@ function stateLabel(state: BackgroundJobRecord['state']): string {
131
146
  }
132
147
  }
133
148
 
149
+ // ─── Agent activity sampling ────────────────────────────────────────────
150
+ //
151
+ // Visibility for `pantheon_delegation_read` / `pantheon_delegation_list`
152
+ // (issue: the read blocks silently while the child works). While a read waits
153
+ // on `waitForTerminal` it periodically samples the CHILD session's messages
154
+ // via the same `client.session.messages` the finalize path uses, and the
155
+ // collected lines are appended to the report as `## Agent Activity`.
156
+ //
157
+ // Fail-open by design: a missing/throwing `session.messages` (or empty
158
+ // response) degrades to the current behavior — report without a section —
159
+ // and NEVER breaks the read. No streaming/SSE, no persistent timers: the
160
+ // poll lives inside the read's existing wait and is cleared when it settles.
161
+
162
+ /** Cap for one activity line (~200 chars, spec). */
163
+ const ACTIVITY_LINE_MAX = 200
164
+ /** How often the read re-samples child activity while waiting (ms). */
165
+ const ACTIVITY_POLL_MS = 2000
166
+ /** Keep only the latest N readable entries per sample. */
167
+ const ACTIVITY_LINES = 3
168
+
169
+ /**
170
+ * Mutable collector shared between the wait loop and the read result.
171
+ * `sampled` distinguishes "messages unavailable/never succeeded" (fail-open
172
+ * → NO activity section, current behavior) from "messages OK but nothing
173
+ * readable" (→ `_no activity captured_` section).
174
+ */
175
+ interface ActivityCollector {
176
+ /** Readable activity lines captured so far (latest window). */
177
+ lines: string[]
178
+ /** True once session.messages returned a bundle list at least once. */
179
+ sampled: boolean
180
+ }
181
+
182
+ /** Collapse whitespace and cap a single line at ~200 chars. */
183
+ function truncateActivityLine(text: string, max = ACTIVITY_LINE_MAX): string {
184
+ const clean = text.replace(/\s+/g, ' ').trim()
185
+ if (clean.length <= max) return clean
186
+ return `${clean.slice(0, max - 1)}…`
187
+ }
188
+
189
+ /** Readable tool args from a tool part (SDK ToolPart.metadata.input), or ''. */
190
+ function toolArgsFromPart(part: { metadata?: { input?: unknown } }): string {
191
+ const meta = part.metadata
192
+ const raw = meta !== undefined && 'input' in meta ? meta.input : meta
193
+ if (raw === undefined) return ''
194
+ try {
195
+ const s = typeof raw === 'string' ? raw : JSON.stringify(raw)
196
+ return s === '' || s === '{}' ? '' : s
197
+ } catch {
198
+ return ''
199
+ }
200
+ }
201
+
202
+ /**
203
+ * One readable activity line per message bundle — tool calls first (highest
204
+ * signal), then the first non-empty text part. Image/attachment/reasoning
205
+ * parts are skipped (only `text` and `tool` parts are user-visible).
206
+ */
207
+ function activityLineFromBundle(bundle: DelegationMessageBundle): string | undefined {
208
+ const role = bundle.info?.role
209
+ const parts = bundle.parts ?? []
210
+ for (const part of parts) {
211
+ if (part.type === 'tool' && typeof part.tool === 'string' && part.tool !== '') {
212
+ const args = toolArgsFromPart(part)
213
+ return truncateActivityLine(`tool: ${part.tool}${args !== '' ? ` ${args}` : ''}`)
214
+ }
215
+ }
216
+ for (const part of parts) {
217
+ if (part.type === 'text' && typeof part.text === 'string' && part.text.trim() !== '') {
218
+ const label = role === 'user' ? 'user' : 'assistant'
219
+ return truncateActivityLine(`${label}: ${part.text.trim()}`)
220
+ }
221
+ }
222
+ return undefined
223
+ }
224
+
225
+ /**
226
+ * Sample the child's latest readable activity into `collector` (replace
227
+ * window). Fail-open: unavailable/throwing messages leaves the collector
228
+ * untouched (previous lines, or none → `_no activity captured_`), while a
229
+ * successful (even empty) response marks the collector as sampled.
230
+ */
231
+ async function sampleChildActivity(
232
+ client: DelegationClient,
233
+ childSessionID: string,
234
+ collector: ActivityCollector,
235
+ ): Promise<void> {
236
+ if (typeof client.session.messages !== 'function') return
237
+ try {
238
+ const bundles = await client.session.messages({ path: { id: childSessionID } })
239
+ if (!Array.isArray(bundles)) return
240
+ collector.sampled = true
241
+ if (bundles.length === 0) return
242
+ const fresh = bundles
243
+ .map((b) => activityLineFromBundle(b))
244
+ .filter((l): l is string => l !== undefined)
245
+ .slice(-ACTIVITY_LINES)
246
+ if (fresh.length > 0) {
247
+ collector.lines.length = 0
248
+ collector.lines.push(...fresh)
249
+ }
250
+ } catch (err: unknown) {
251
+ const reason = err instanceof Error ? err.message : String(err)
252
+ log.warn(`[pantheon-delegate] activity sample failed (fail-open): ${reason}`)
253
+ }
254
+ }
255
+
256
+ /** Last readable activity line for a child, or undefined when unavailable. */
257
+ async function lastChildActivity(
258
+ client: DelegationClient,
259
+ childSessionID: string,
260
+ ): Promise<string | undefined> {
261
+ if (typeof client.session.messages !== 'function') return undefined
262
+ try {
263
+ const bundles = await client.session.messages({ path: { id: childSessionID } })
264
+ if (!Array.isArray(bundles)) return undefined
265
+ for (let i = bundles.length - 1; i >= 0; i--) {
266
+ const bundle = bundles[i]
267
+ if (bundle === undefined) continue
268
+ const line = activityLineFromBundle(bundle)
269
+ if (line !== undefined) return line
270
+ }
271
+ return undefined
272
+ } catch (err: unknown) {
273
+ const reason = err instanceof Error ? err.message : String(err)
274
+ log.warn(`[pantheon-delegate] last activity fetch failed (fail-open): ${reason}`)
275
+ return undefined
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Wait for a terminal board state while sampling the child's activity every
281
+ * ACTIVITY_POLL_MS (first sample fires immediately — a quick finalize can
282
+ * still capture something). The interval is cleared when the wait settles;
283
+ * collection never delays the terminal resolution.
284
+ */
285
+ async function waitForTerminalWithActivity(
286
+ board: BackgroundJobBoard,
287
+ client: DelegationClient,
288
+ taskID: string,
289
+ timeoutMs: number,
290
+ collector: ActivityCollector,
291
+ ): Promise<BackgroundJobRecord> {
292
+ void sampleChildActivity(client, taskID, collector)
293
+ const timer = setInterval(() => {
294
+ void sampleChildActivity(client, taskID, collector)
295
+ }, ACTIVITY_POLL_MS)
296
+ timer.unref()
297
+ try {
298
+ return await board.waitForTerminal(taskID, timeoutMs)
299
+ } finally {
300
+ clearInterval(timer)
301
+ }
302
+ }
303
+
304
+ /** Render the trailing activity section appended to the read result. */
305
+ export function formatActivitySection(lines: string[]): string {
306
+ if (lines.length === 0) return '## Agent Activity\n\n_no activity captured_'
307
+ return `## Agent Activity\n\n${lines.map((l) => `- ${l}`).join('\n')}`
308
+ }
309
+
134
310
  // ─── Model resolution ──────────────────────────────────────────────────
135
311
 
136
312
  /** Session model ref accepted by the opencode server's session.create. */
@@ -139,6 +315,17 @@ export interface ChildSessionModelRef {
139
315
  providerID: string
140
316
  }
141
317
 
318
+ /**
319
+ * Known-good fallback model (P1, 2026-08-11 — release 1.3.4): when an
320
+ * AUTO-RESOLVED model (routing.yml agent entry / active preset) points at a
321
+ * provider whose API key is not configured, the child is dispatched on the
322
+ * native `opencode` provider instead — verified to work in the field without
323
+ * an external provider key. Validated with the same providerKeyConfigured
324
+ * gate as the resolved model; an explicit caller-supplied `model` always wins
325
+ * over this fallback.
326
+ */
327
+ const FALLBACK_MODEL = 'opencode/deepseek-v4-flash-free'
328
+
142
329
  /**
143
330
  * Split a `provider/model` model ID (e.g. `opencode/deepseek-v4-flash-free`)
144
331
  * into the `{ id, providerID }` ref the session.create body expects.
@@ -194,8 +381,96 @@ function resolveChildModel(
194
381
  return undefined
195
382
  }
196
383
 
384
+ /**
385
+ * Resolve a USABLE child model for a delegate dispatch, gating the resolved
386
+ * provider's API key (P1, 2026-08-11 — release 1.3.4):
387
+ *
388
+ * 1. resolve the model with the existing precedence (explicit `model` >
389
+ * options.agentModels > active preset) — see resolveChildModel.
390
+ * 2. provider key gate: the resolved provider requires its apiKeyEnv env
391
+ * var (routing.yml preset defs — same source applyPreset enforces at
392
+ * startup). No gate (native providers) or key present → usable as-is.
393
+ * 3. key MISSING + EXPLICIT caller model → respected anyway (user intent),
394
+ * warned.
395
+ * 4. key MISSING + AUTO-resolved model → fall back to FALLBACK_MODEL
396
+ * (validated the same way). Fallback also unusable → returns an `error`
397
+ * TEXT (the caller returns it from the tool — never throws) and the
398
+ * caller registers NO job on the board.
399
+ *
400
+ * The no-model case (nothing resolved) keeps the pre-existing behavior:
401
+ * warn and let the child use opencode's default.
402
+ *
403
+ * @returns `{ model, error }` — at most one of `model` / `error` is set;
404
+ * both may be unset (no model resolved, warn emitted).
405
+ */
406
+ function resolveUsableChildModel(
407
+ options: DelegationOptions,
408
+ agent: string,
409
+ explicitModel: string | undefined,
410
+ ): { model: ChildSessionModelRef | undefined; error: string | undefined } {
411
+ const env = options.presetEnv ?? process.env
412
+ const explicit = explicitModel !== undefined && explicitModel !== ''
413
+ const warn = (msg: string) => options.logger?.warn?.(msg)
414
+
415
+ const model = resolveChildModel(options, agent, explicitModel)
416
+ if (model === undefined) {
417
+ warn(
418
+ `[pantheon-delegate] no model resolved for agent "${agent}" — ` +
419
+ "child session will use opencode's default model (may require API keys)",
420
+ )
421
+ return { model: undefined, error: undefined }
422
+ }
423
+
424
+ const missingVar = missingProviderKeyEnv(model.providerID, { env })
425
+ if (missingVar === undefined) return { model, error: undefined }
426
+
427
+ if (explicit) {
428
+ // Rule of precedence: the caller's explicit model is respected — warn only.
429
+ warn(
430
+ `[pantheon-delegate] model "${explicitModel}" provider "${model.providerID}" requires ` +
431
+ `API key ${missingVar} (unset) — respecting the explicit model anyway`,
432
+ )
433
+ return { model, error: undefined }
434
+ }
435
+
436
+ // Auto-resolved (agentModels / preset) → try the known-good fallback.
437
+ const fallback = splitModelRef(FALLBACK_MODEL)
438
+ if (fallback !== undefined && missingProviderKeyEnv(fallback.providerID, { env }) === undefined) {
439
+ warn(
440
+ `[pantheon-delegate] provider "${model.providerID}" requires API key ${missingVar} ` +
441
+ `(unset) — falling back to ${FALLBACK_MODEL}`,
442
+ )
443
+ return { model: fallback, error: undefined }
444
+ }
445
+
446
+ const message =
447
+ `pantheon_delegate: no usable model for agent "${agent}" — provider "${model.providerID}" ` +
448
+ `requires API key (set ${missingVar} or PANTHEON_MODEL_PRESET)`
449
+ warn(`[pantheon-delegate] ${message}`)
450
+ return { model: undefined, error: message }
451
+ }
452
+
197
453
  // ─── Factory ───────────────────────────────────────────────────────────
198
454
 
455
+ /**
456
+ * Pure helper: collect the IDs of root sessions (parentID === undefined)
457
+ * from a session list. Used to SEED the depth-guard allowlist at startup —
458
+ * opencode does not replay `session.created` events for sessions that exist
459
+ * before plugin load, so a resumed root would never enter `rootSessions`
460
+ * and the depth guard would reject its pantheon_delegate calls after a
461
+ * restart. Fail-open contract lives at the call site (plugin.ts): a failed
462
+ * `session.list()` leaves the registry untouched, never crashes startup.
463
+ */
464
+ export function collectRootSessionIDs(
465
+ sessions: ReadonlyArray<{ id: string; parentID?: string }>,
466
+ ): Set<string> {
467
+ const roots = new Set<string>()
468
+ for (const session of sessions) {
469
+ if (session.parentID === undefined) roots.add(session.id)
470
+ }
471
+ return roots
472
+ }
473
+
199
474
  /**
200
475
  * Build the delegation toolset. Keeps per-instance state: the set of child
201
476
  * sessions WE created (depth-guard default) and the per-job timeout timers
@@ -214,7 +489,18 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
214
489
 
215
490
  function isRootSession(sessionID: string): boolean {
216
491
  if (options.isRootSession !== undefined) return options.isRootSession(sessionID)
217
- if (options.rootSessions !== undefined) return options.rootSessions.has(sessionID)
492
+ if (options.rootSessions !== undefined) {
493
+ // compaction-134 (fix layer 2): the allowlist alone is NOT
494
+ // authoritative. opencode does not replay `session.created` events for
495
+ // pre-existing sessions, so after a restart the resumed root never
496
+ // entered rootSessions (the plugin seeds it from session.list() at
497
+ // startup, but that seed is fail-open). A session is a sub-session
498
+ // ONLY if WE created it as a child (knownChildren — filled on every
499
+ // pantheon_delegate dispatch). Everything unknown is treated as root:
500
+ // resumed/unknown sessions can delegate, while the real depth guard
501
+ // (a child of a delegate cannot re-delegate) is preserved.
502
+ return options.rootSessions.has(sessionID) || !knownChildren.has(sessionID)
503
+ }
218
504
  return !knownChildren.has(sessionID)
219
505
  }
220
506
 
@@ -257,16 +543,16 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
257
543
  // session.create failure → return a clear error as TEXT (tools return
258
544
  // errors as text, not thrown) and register NO job on the board — an
259
545
  // unhandled rejection here would otherwise lose the failure entirely.
260
- // The child model is resolved from (a) the explicit `model` option,
261
- // (b) options.agentModels (routing.yml), (c) the active preset — else
262
- // left unset so opencode's default applies (warned below).
263
- const childModel = resolveChildModel(options, args.agent, args.model)
264
- if (childModel === undefined) {
265
- options.logger?.warn?.(
266
- `[pantheon-delegate] no model resolved for agent "${args.agent}" — ` +
267
- "child session will use opencode's default model (may require API keys)",
268
- )
269
- }
546
+ // The child model is resolved with a provider API-key gate (P1): the
547
+ // resolved model (explicit `model` > options.agentModels > active
548
+ // preset) is used when its provider key is configured; an explicit
549
+ // caller model is always respected (warned); an auto-resolved model
550
+ // whose provider key is missing falls back to the known-good
551
+ // opencode/deepseek-v4-flash-free; if nothing usable remains the
552
+ // failure is returned as TEXT below — no session, no board job.
553
+ const resolved = resolveUsableChildModel(options, args.agent, args.model)
554
+ if (resolved.error !== undefined) return resolved.error
555
+ const childModel = resolved.model
270
556
  let created: { id: string }
271
557
  try {
272
558
  created = await client.session.create({
@@ -312,9 +598,7 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
312
598
  state: 'error',
313
599
  error: `Delegation [${job.alias}] timed out after ${timeoutMs}ms without reaching a terminal state`,
314
600
  timedOut: true,
315
- }).catch((err: unknown) =>
316
- console.error('[pantheon-delegate] timeout finalize failed:', err),
317
- )
601
+ }).catch((err: unknown) => log.error('[pantheon-delegate] timeout finalize failed:', err))
318
602
  }, timeoutMs)
319
603
  timer.unref()
320
604
  timers.set(childSessionID, timer)
@@ -326,7 +610,7 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
326
610
  path: { id: childSessionID },
327
611
  body: { agent: args.agent, parts: [{ type: 'text', text: args.prompt }] },
328
612
  })
329
- .catch((err: unknown) => console.error('[pantheon-delegate] promptAsync failed:', err))
613
+ .catch((err: unknown) => log.error('[pantheon-delegate] promptAsync failed:', err))
330
614
 
331
615
  return (
332
616
  `Delegated to ${args.agent}: [${job.alias}] (task ${childSessionID}).\n` +
@@ -338,7 +622,8 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
338
622
  const pantheon_delegation_read: DelegationTool<typeof readArgs> = {
339
623
  description:
340
624
  'Block until a background delegation finishes (completed/error/cancelled), then return its ' +
341
- 'report markdown and mark the job reconciled. Resolves by alias or task ID.',
625
+ 'report markdown (with a trailing agent-activity section) and mark the job reconciled. ' +
626
+ 'Resolves by alias or task ID.',
342
627
  args: readArgs,
343
628
  execute: async (args, ctx) => {
344
629
  const job = board.resolve(ctx.sessionID, args.id)
@@ -346,9 +631,18 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
346
631
  return `Unknown delegation "${args.id}" for this session. Use pantheon_delegation_list to see active delegations.`
347
632
  }
348
633
 
634
+ // Sample the child's messages while waiting so the caller sees what the
635
+ // agent is doing — fail-open: no messages support → report as before.
636
+ const collector: ActivityCollector = { lines: [], sampled: false }
349
637
  let terminal: BackgroundJobRecord
350
638
  try {
351
- terminal = await board.waitForTerminal(job.taskID, readTimeoutMs)
639
+ terminal = await waitForTerminalWithActivity(
640
+ board,
641
+ client,
642
+ job.taskID,
643
+ readTimeoutMs,
644
+ collector,
645
+ )
352
646
  } catch {
353
647
  return `Timed out after ${readTimeoutMs}ms waiting for delegation "${args.id}" ([${job.alias}]).`
354
648
  }
@@ -359,7 +653,9 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
359
653
  }
360
654
 
361
655
  await board.markReconciled(job.taskID)
362
- return md
656
+ // Fail-open: if session.messages never succeeded, keep the report as-is.
657
+ if (!collector.sampled && collector.lines.length === 0) return md
658
+ return `${md.replace(/\n+$/, '')}\n\n${formatActivitySection(collector.lines)}`
363
659
  },
364
660
  }
365
661
 
@@ -371,10 +667,16 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
371
667
  const jobs = board.list(ctx.sessionID)
372
668
  if (jobs.length === 0) return 'No background delegations for this session.'
373
669
 
374
- const lines = jobs.map((j) => {
375
- const unread = j.terminalUnreconciled ? ' [unread]' : ''
376
- return ` [${j.alias}] ${j.agent} — ${j.description} — ${stateLabel(j.state)}${unread}`
377
- })
670
+ const lines = await Promise.all(
671
+ jobs.map(async (j) => {
672
+ const unread = j.terminalUnreconciled ? ' [unread]' : ''
673
+ const base = ` [${j.alias}] ${j.agent} — ${j.description} — ${stateLabel(j.state)}${unread}`
674
+ // Running jobs get a live `last activity:` line (fail-open fetch).
675
+ if (j.state !== 'running') return base
676
+ const last = await lastChildActivity(client, j.taskID)
677
+ return last === undefined ? base : `${base}\n last activity: ${last}`
678
+ }),
679
+ )
378
680
  return `Background Delegations (${jobs.length}):\n${lines.join('\n')}`
379
681
  },
380
682
  }
@@ -19,6 +19,13 @@
19
19
  * @module dispatch-guard
20
20
  */
21
21
 
22
+ import { createPantheonLogger } from './logger.ts'
23
+
24
+ // Silence-by-default TUI policy (pantheon-hooks L42-58): the default warn
25
+ // fallback logs to .pantheon/logs/hooks.log; console echo is opt-in via
26
+ // PANTHEON_HOOKS_LOG=1. `logger` injection stays for tests.
27
+ const log = createPantheonLogger({ module: 'pantheon-dispatch-guard' })
28
+
22
29
  /** A dispatch result — the minimal shape needed to classify emptiness. */
23
30
  export interface DispatchResultLike {
24
31
  /** Text content of the result (task_result / report markdown). */
@@ -35,7 +42,7 @@ export type DispatchClassification = 'content' | 'empty-mode1' | 'empty-mode2'
35
42
  export interface DispatchGuardOptions {
36
43
  /** Enable auto-retry on empty results. Default: true. */
37
44
  retryOnEmpty?: boolean
38
- /** Injectable logger (defaults to console.warn with a prefix). */
45
+ /** Injectable logger (defaults to the Pantheon file log, env-gated echo). */
39
46
  logger?: { warn: (message: string) => void }
40
47
  }
41
48
 
@@ -72,9 +79,7 @@ function hasTokens(result: DispatchResultLike): boolean {
72
79
 
73
80
  export function createDispatchGuard(options?: DispatchGuardOptions): DispatchGuard {
74
81
  const retryOnEmpty = options?.retryOnEmpty ?? true
75
- const warn =
76
- options?.logger?.warn ??
77
- ((message: string) => console.warn(`[pantheon-dispatch-guard] ${message}`))
82
+ const warn = options?.logger?.warn ?? ((message: string) => log.warn(message))
78
83
  // Per-instance retry budget — the "cap 1" enforcement point.
79
84
  let retriesUsed = 0
80
85
 
@@ -19,16 +19,20 @@ import { z } from 'zod'
19
19
  import type { BackgroundJobBoard } from './background-job-board.ts'
20
20
  import type { ToolContextLike } from './delegation.ts'
21
21
  import { GoalStore } from './goal-store.ts'
22
+ import { createPantheonLogger } from './logger.ts'
22
23
  import type { TodoEnforcerMessage } from './todo-enforcer.ts'
23
24
 
25
+ // Silence-by-default TUI policy (pantheon-hooks L42-58): warn → hooks.log,
26
+ // console echo opt-in via PANTHEON_HOOKS_LOG=1. `deps.logger` stays for tests.
27
+ const log = createPantheonLogger({ module: 'pantheon-goal' })
28
+
24
29
  export type { Goal, GoalStatus } from './goal-store.ts'
25
30
  export { GoalStore }
26
31
 
27
32
  // ─── Constants ─────────────────────────────────────────────────────────
28
33
 
29
34
  /** Continuation text injected into idle sessions. `{objective}` is substituted. */
30
- export const GOAL_CONTINUATION_PROMPT =
31
- "Active goal: {objective}. Continue working toward it. Do not stop until you update the goal to done. If you believe it's complete, call pantheon_goal_update with status done."
35
+ export const GOAL_CONTINUATION_PROMPT = 'Continue goal: {objective}'
32
36
 
33
37
  /** Defaults matching routing.yml `full_auto`. */
34
38
  export const GOAL_LOOP_DEFAULTS = {
@@ -141,8 +145,7 @@ export class GoalLoop {
141
145
  maxContinuations: opts.maxContinuations ?? GOAL_LOOP_DEFAULTS.maxContinuations,
142
146
  now: opts.now ?? Date.now,
143
147
  }
144
- this.warn =
145
- deps.logger?.warn ?? ((message: string) => console.warn(`[pantheon-goal] ${message}`))
148
+ this.warn = deps.logger?.warn ?? ((message: string) => log.warn(message))
146
149
  }
147
150
 
148
151
  /** True when the session has an active (non-done) goal. Used by the idle dispatcher. */
@@ -105,7 +105,8 @@ function resolveFile(
105
105
  if (isAbsolute(file)) return { ok: true, file: resolve(file) }
106
106
  const base = resolve(ctx.worktree ?? ctx.directory ?? process.cwd())
107
107
  const resolved = resolve(base, file)
108
- // Prefix check with a path.sep boundary: `/tmp/x` must not match `/tmp2/x`.
108
+ // Prefix check with a path.sep boundary: base + sep + name must not
109
+ // match a sibling prefix (e.g. `/data/x` vs `/data2/x`).
109
110
  if (resolved !== base && !resolved.startsWith(base + sep)) {
110
111
  return {
111
112
  ok: false,
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Pantheon plugin logger — silence-by-default TUI policy (shared with
3
+ * pantheon-hooks, see src/plugins/pantheon-hooks.ts L42-58).
4
+ *
5
+ * WHY: `console.log`/`console.error` in an opencode plugin writes to the
6
+ * process stdout/stderr, which the TUI renders DIRECTLY into the terminal —
7
+ * the user-visible "lixo" (e.g. `[Pantheon Plugin] Board terminal: ...` in
8
+ * the TUI footer). Every Pantheon module therefore routes its logs through
9
+ * this helper:
10
+ *
11
+ * 1. ALWAYS append one ISO-stamped, module-prefixed line to the
12
+ * project-local `.pantheon/logs/hooks.log` (same file pantheon-hooks
13
+ * uses, so the plugin trail stays auditable on disk);
14
+ * 2. echo to the console ONLY when `PANTHEON_HOOKS_LOG=1` (or any truthy
15
+ * value — same env var pantheon-hooks uses; read at creation time).
16
+ *
17
+ * All channels are best-effort and fire-and-forget: a logging failure must
18
+ * never break the plugin, and an unhandled rejection could itself pollute
19
+ * the TUI, so writes are swallowed.
20
+ *
21
+ * Pure TypeScript — zero external deps beyond Node.js builtins (fs/path).
22
+ *
23
+ * @module logger
24
+ */
25
+
26
+ import { appendFile, mkdir } from 'node:fs/promises'
27
+ import { dirname, join } from 'node:path'
28
+
29
+ export interface PantheonLoggerOptions {
30
+ /** Module tag prefixed to every line (e.g. 'pantheon-plugin'). */
31
+ module: string
32
+ /**
33
+ * Env gate for the console echo — truthy means echo in addition to the
34
+ * log file. Defaults to `process.env.PANTHEON_HOOKS_LOG` (read at
35
+ * creation), matching the pantheon-hooks policy. Injectable for tests.
36
+ */
37
+ env?: string | undefined
38
+ /**
39
+ * Project directory — the log lands at `<logDir>/.pantheon/logs/hooks.log`.
40
+ * Defaults to `process.cwd()` (opencode runs plugins with cwd = project,
41
+ * consistent with the plugin's other relative `.pantheon/...` paths).
42
+ */
43
+ logDir?: string | undefined
44
+ }
45
+
46
+ export interface PantheonLogger {
47
+ info(message: string, ...args: unknown[]): void
48
+ warn(message: string, ...args: unknown[]): void
49
+ error(message: string, ...args: unknown[]): void
50
+ }
51
+
52
+ const LOG_LEVELS = ['info', 'warn', 'error'] as const
53
+ type LogLevel = (typeof LOG_LEVELS)[number]
54
+
55
+ /** Console method per level — info maps to console.log (the only console touch). */
56
+ const CONSOLE_METHODS: Record<LogLevel, 'log' | 'warn' | 'error'> = {
57
+ info: 'log',
58
+ warn: 'warn',
59
+ error: 'error',
60
+ }
61
+
62
+ /** Serialize a log arg — Errors to stack/message, never "[object Object]". */
63
+ function formatArg(arg: unknown): string {
64
+ if (arg instanceof Error) return arg.stack ?? arg.message
65
+ if (typeof arg === 'string') return arg
66
+ try {
67
+ return JSON.stringify(arg)
68
+ } catch {
69
+ return String(arg)
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Append one ISO-stamped line to the hooks.log. Multi-line messages are
75
+ * split so EVERY line carries the stamp (pantheon-hooks appendHookLog
76
+ * pattern). Throws only on IO failure — callers swallow.
77
+ */
78
+ async function appendToLog(logPath: string, prefix: string, args: unknown[]): Promise<void> {
79
+ const body = [prefix, ...args.map(formatArg)].join(' ')
80
+ const stamp = new Date().toISOString()
81
+ const lines = body
82
+ .split('\n')
83
+ .map((p) => p.trim())
84
+ .filter((p) => p !== '')
85
+ .map((p) => `[${stamp}] ${p}`)
86
+ if (lines.length === 0) return
87
+ await mkdir(dirname(logPath), { recursive: true })
88
+ await appendFile(logPath, `${lines.join('\n')}\n`, 'utf8')
89
+ }
90
+
91
+ /**
92
+ * Create a module logger. Every level writes to the log file ALWAYS and
93
+ * echoes to the console only when the env gate is truthy. Fire-and-forget:
94
+ * returns void, never throws.
95
+ */
96
+ export function createPantheonLogger(options: PantheonLoggerOptions): PantheonLogger {
97
+ const echo = (options.env ?? process.env.PANTHEON_HOOKS_LOG ?? '').trim() !== ''
98
+ const logPath = join(options.logDir ?? process.cwd(), '.pantheon', 'logs', 'hooks.log')
99
+
100
+ const write = (level: LogLevel, message: string, args: unknown[]): void => {
101
+ void appendToLog(logPath, `[${options.module}] ${message}`, args).catch(() => {
102
+ // Best-effort file log — never break the plugin over logging.
103
+ })
104
+ if (echo) {
105
+ // Opt-in debug echo (PANTHEON_HOOKS_LOG=1). Off by default: console
106
+ // output in a plugin renders into the opencode TUI (the pollution bug).
107
+ const consoleMethod = CONSOLE_METHODS[level]
108
+ console[consoleMethod](`[${options.module}] ${message}`, ...args)
109
+ }
110
+ }
111
+
112
+ return {
113
+ info: (message, ...args) => write('info', message, args),
114
+ warn: (message, ...args) => write('warn', message, args),
115
+ error: (message, ...args) => write('error', message, args),
116
+ }
117
+ }