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.
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Command Normalizer (Fase 1) — deterministic `python` → `python3` rewrite.
3
+ *
4
+ * Ubuntu 20.04+ ships only `python3` (PEP 394); the bare `python` command does
5
+ * not exist. Agents frequently emit `python` instead of `python3`, so every
6
+ * `bash` command is normalized BEFORE execution via the `tool.execute.before`
7
+ * hook. Unlike the read-only enforcement guard (which THROWS to deny), this
8
+ * guard REWRITES the command in place and lets it run.
9
+ *
10
+ * The rewrite is anchored to the regex below — it only matches `python` as a
11
+ * standalone command token (start of line or after a shell delimiter), never
12
+ * as a substring. This is what keeps `pip install python-dateutil` intact.
13
+ *
14
+ * @module command-normalizer
15
+ */
16
+
17
+ // Matches `python` as a command token: at the start of the command or after a
18
+ // shell delimiter (`;`, `&`, `|`, `(`, `)`, or a quote), followed by
19
+ // whitespace or end. Quotes are included so `bash -c "python foo"` (a common
20
+ // bypass) is normalized too. Deliberately NOT `\bpython\b` — that would
21
+ // corrupt `python-dateutil` in `pip install python-dateutil` (the `-` is a
22
+ // word boundary).
23
+ const PYTHON_NORMALIZE_RE = /(^|[\s;&|()"'])python([\s]|$)/g
24
+
25
+ /** Result of normalizing a command string. */
26
+ export interface NormalizeResult {
27
+ /** The command with `python` → `python3` applied (unchanged if no match). */
28
+ normalized: string
29
+ /** Whether any rewrite actually happened. */
30
+ wasRewritten: boolean
31
+ }
32
+
33
+ /** Whether the command invokes `python -c` (arbitrary eval — flagged as risk). */
34
+ const PYTHON_C_RE = /(^|[\s;&|()"'])python([\s]+)-c([\s]|$)/
35
+
36
+ /**
37
+ * Normalize a bash command string, rewriting standalone `python` tokens to
38
+ * `python3`. Pure function — no side effects, safe to unit test directly.
39
+ */
40
+ export function normalizePythonCommand(command: string): NormalizeResult {
41
+ // Reset lastIndex first — the regex is global and `.test()` advances it.
42
+ PYTHON_NORMALIZE_RE.lastIndex = 0
43
+ const wasRewritten = PYTHON_NORMALIZE_RE.test(command)
44
+ PYTHON_NORMALIZE_RE.lastIndex = 0
45
+ const normalized = command.replace(PYTHON_NORMALIZE_RE, '$1python3$2')
46
+ return { normalized, wasRewritten }
47
+ }
48
+
49
+ /** Whether the command contains a `python -c` invocation (arbitrary eval). */
50
+ export function isPythonCEval(command: string): boolean {
51
+ PYTHON_C_RE.lastIndex = 0
52
+ return PYTHON_C_RE.test(command)
53
+ }
54
+
55
+ /** Minimal logger surface the guard needs (matches plugin logger shape). */
56
+ export interface NormalizerLogger {
57
+ warn(message: string): void
58
+ }
59
+
60
+ /** Options for createCommandNormalizer(). */
61
+ export interface CommandNormalizerOptions {
62
+ /** Audit sink for rewrites and `python -c` warnings. */
63
+ logger?: NormalizerLogger
64
+ }
65
+
66
+ /**
67
+ * Build the `tool.execute.before` handler that rewrites `bash` commands.
68
+ * Only the `bash` tool is touched; every other tool passes through untouched.
69
+ * The rewrite mutates `output.args.command` so the normalized command is what
70
+ * actually executes. Never throws — normalization must not break a session.
71
+ */
72
+ export function createCommandNormalizer(
73
+ options: CommandNormalizerOptions = {},
74
+ ): (input: { tool: string; sessionID: string }, output?: { args?: unknown }) => Promise<void> {
75
+ const logger = options.logger
76
+
77
+ return async (input, output): Promise<void> => {
78
+ if (input.tool !== 'bash') return
79
+ if (output?.args === null || typeof output?.args !== 'object') return
80
+
81
+ const args = output.args as Record<string, unknown>
82
+ const command = args.command
83
+ if (typeof command !== 'string' || command.trim() === '') return
84
+
85
+ const { normalized, wasRewritten } = normalizePythonCommand(command)
86
+
87
+ if (isPythonCEval(command)) {
88
+ logger?.warn(
89
+ `[command-normalizer] WARNING python -c detected: session=${input.sessionID} command="${command}"`,
90
+ )
91
+ }
92
+
93
+ if (wasRewritten) {
94
+ args.command = normalized
95
+ logger?.warn(
96
+ `[command-normalizer] python→python3 rewritten: session=${input.sessionID} ` +
97
+ `original="${command}" normalized="${normalized}"`,
98
+ )
99
+ }
100
+ }
101
+ }
@@ -1,37 +1,31 @@
1
1
  /**
2
- * Compaction Re-Assert (release-134 Phase 4) — re-inject fresh state after
3
- * compaction.
2
+ * Compaction Re-Assert (release-134 Phase 4) — detect fresh state after
3
+ * compaction without writing to the chat transcript.
4
4
  *
5
5
  * WHY: after a session is compacted, the system state may have changed since
6
6
  * the summary was built (new background delegations launched, results
7
7
  * delivered, goals updated). The compacted session's context is frozen at
8
8
  * summary time — the model would operate on stale state.
9
9
  *
10
- * HOW: the `session.compacted` event hook calls `reassertAfterCompaction`,
11
- * which builds a COMPACT fresh-state block (running + unread delegations from
12
- * the board, active goals from the GoalStore — same gating as Phase 2) and
13
- * enqueues it into the SHARED chat-reminder buffer (chat-reminders.ts). The
14
- * existing pantheon-hooks `chat.message` hook delivers it as a
15
- * <system-reminder> into the session's next message — zero new delivery
16
- * code, and the P0 messageID guard already protects subagent fires.
10
+ * HOW: the compaction lifecycle keeps the board and goal stores authoritative;
11
+ * the `experimental.session.compacting` hook builds the context that is
12
+ * consumed by the compaction itself. Chat is never used as a delivery channel.
17
13
  *
18
14
  * Deliberately NOT `session.promptAsync({noReply:true})`: the sprint 4+7
19
15
  * spike refuted noReply as a push mechanism on this opencode build (nothing
20
16
  * is delivered to the parent).
21
17
  *
22
- * FAIL-OPEN: every source (board list, goals list, enqueue) is wrapped —
18
+ * FAIL-OPEN: every source (board list and goals list) is wrapped —
23
19
  * a failure logs a warn via the silence-by-default logger and the hook never
24
20
  * throws. A session with NO fresh state (nothing running/unread/goals) is a
25
- * SILENT skip (no warn, no reminder). The block is capped at
21
+ * SILENT skip (no warn). The block is capped at
26
22
  * REASSERT_MAX_LINES item lines (1 header) so it stays compact.
27
23
  *
28
- * Pure TypeScript — zero runtime deps beyond the board/goal types and the
29
- * shared reminder buffer.
24
+ * Pure TypeScript — zero runtime deps beyond the board/goal types.
30
25
  *
31
26
  * @module compaction-assert
32
27
  */
33
28
  import type { BackgroundJobBoard } from './background-job-board.ts'
34
- import { enqueueChatReminder } from './chat-reminders.ts'
35
29
  import type { CompactionGoalSource } from './delegation-compaction.ts'
36
30
  import { createPantheonLogger } from './logger.ts'
37
31
 
@@ -51,16 +45,16 @@ export const REASSERT_HEADER = 'State re-assertion after compaction — the summ
51
45
 
52
46
  /** Dependencies threaded to reassertAfterCompaction (wired from plugin.ts). */
53
47
  export interface CompactionAssertDeps {
54
- /** The compacted session whose next message receives the reminder. */
48
+ /** The session whose compaction context is being prepared. */
55
49
  sessionID: string
56
50
  /** Board query surface — `list` is all the re-assertion needs. */
57
51
  board: Pick<BackgroundJobBoard, 'list'>
58
52
  /** Goal source for the goals block (optional — same gating as Phase 2). */
59
53
  goals?: CompactionGoalSource
60
- /** Injectable enqueue (testable). Defaults to the shared chat buffer. */
61
- enqueue?: (text: string) => void
62
54
  /** Injectable warn logger (testable). Defaults to silence-by-default. */
63
55
  logger?: { warn: (message: string) => void }
56
+ /** Non-chat delivery sink (for example a pending `output.context` block). */
57
+ deliverContext?: (context: string[]) => void | Promise<void>
64
58
  }
65
59
 
66
60
  // ─── Formatting ────────────────────────────────────────────────────────
@@ -135,26 +129,32 @@ async function buildStateLines(
135
129
  }
136
130
 
137
131
  /**
138
- * Re-assert fresh state for a compacted session: build the compact block and
139
- * enqueue it as a chat reminder (delivered on the session's next message).
132
+ * Re-assert fresh state for a compacted session and return the context block
133
+ * to the non-chat caller. The caller can hand this array to OpenCode's
134
+ * `output.context` or persist it through the board; constructing it here and
135
+ * throwing it away would make the hook a no-op.
140
136
  *
141
- * Returns true when a reminder was enqueued; false when there was nothing to
142
- * assert (silent skip) or a source failed (warned). NEVER throws — the event
143
- * hook must never break the session.
137
+ * Returns the fresh-state context when found, or undefined when there was
138
+ * nothing to assert (silent skip) or a source failed (warned). NEVER throws —
139
+ * the event hook must never break the session.
144
140
  */
145
- export async function reassertAfterCompaction(deps: CompactionAssertDeps): Promise<boolean> {
141
+ export async function reassertAfterCompaction(
142
+ deps: CompactionAssertDeps,
143
+ ): Promise<string[] | undefined> {
146
144
  // Silence-by-default logger: file-only warn, console echo opt-in via
147
145
  // PANTHEON_HOOKS_LOG=1 (TUI pollution policy, pantheon-hooks L42-58).
148
146
  const warn = deps.logger?.warn ?? ((message: string) => log.warn(message))
149
147
  try {
150
148
  const lines = await buildStateLines(deps, warn)
151
- if (lines === undefined) return false
152
- const enqueue = deps.enqueue ?? enqueueChatReminder
153
- enqueue(`${REASSERT_HEADER}\n${lines.join('\n')}`)
154
- return true
149
+ if (lines === undefined) return undefined
150
+ // This is a context return value, not a chat message. The caller owns the
151
+ // supported delivery path (output.context or persisted board state).
152
+ const context = [REASSERT_HEADER, ...lines]
153
+ await deps.deliverContext?.(context)
154
+ return context
155
155
  } catch (err: unknown) {
156
156
  const reason = err instanceof Error ? err.message : String(err)
157
157
  warn(`compaction re-assert failed for session ${deps.sessionID}: ${reason}`)
158
- return false
158
+ return undefined
159
159
  }
160
160
  }
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Tool Output Sandboxing — Context Window Optimization (v2 P0)
3
+ *
4
+ * Truncates oversized tool outputs before they flood the context window.
5
+ * Pure truncation with metadata — no tiktoken, no LLM summarization (YAGNI).
6
+ *
7
+ * Hook: tool.execute.after — intercepts read/grep/glob/webfetch outputs.
8
+ *
9
+ * @module pantheon/context-sandbox
10
+ */
11
+
12
+ export interface ToolExecuteAfterInput {
13
+ tool: string
14
+ sessionID: string
15
+ callID: string
16
+ args?: unknown
17
+ }
18
+
19
+ export interface ToolExecuteAfterOutput {
20
+ title?: string
21
+ output: string
22
+ metadata?: Record<string, unknown>
23
+ }
24
+
25
+ export type ToolExecuteAfterHandler = (
26
+ input: ToolExecuteAfterInput,
27
+ output: ToolExecuteAfterOutput,
28
+ ) => Promise<void>
29
+
30
+ export interface SandboxToolLimits {
31
+ read: { maxLines: number; keepHead: number; keepTail: number }
32
+ grep: { maxResults: number; keepTop: number }
33
+ glob: { maxFiles: number; keepTop: number }
34
+ webfetch: { maxChars: number; keepHead: number }
35
+ }
36
+
37
+ export interface ContextSandboxConfig {
38
+ enabled: boolean
39
+ limits: SandboxToolLimits
40
+ }
41
+
42
+ /** Default limits — sensatos, cobrem 80% do problema com ~30 LOC. */
43
+ export const DEFAULT_LIMITS: SandboxToolLimits = {
44
+ read: { maxLines: 200, keepHead: 50, keepTail: 10 },
45
+ grep: { maxResults: 20, keepTop: 10 },
46
+ glob: { maxFiles: 50, keepTop: 20 },
47
+ webfetch: { maxChars: 5000, keepHead: 2000 },
48
+ }
49
+
50
+ export const DEFAULT_CONFIG: ContextSandboxConfig = {
51
+ enabled: true,
52
+ limits: DEFAULT_LIMITS,
53
+ }
54
+
55
+ // ─── Pure sandbox functions ──────────────────────────────────────────────
56
+
57
+ /**
58
+ * Sandbox truncation for `read` tool output.
59
+ * Input is expected to be line-numbered read output (e.g. "1: foo").
60
+ * If lines > maxLines, keep head + marker + tail.
61
+ */
62
+ export function sandboxRead(output: string, limits = DEFAULT_LIMITS.read): string {
63
+ const lines = output.split('\n')
64
+ // trailing empty after final newline is not a real line — drop it for counting
65
+ const effectiveCount =
66
+ lines.length > 0 && lines[lines.length - 1] === '' ? lines.length - 1 : lines.length
67
+ if (effectiveCount <= limits.maxLines) return output
68
+
69
+ const head = lines.slice(0, limits.keepHead)
70
+ const tail =
71
+ limits.keepTail > 0
72
+ ? lines.slice(Math.max(limits.keepHead, effectiveCount - limits.keepTail), effectiveCount)
73
+ : []
74
+ const hidden = effectiveCount - head.length - tail.length
75
+ const marker = `[TRUNCATED: ${hidden} lines hidden — showing ${head.length} head + ${tail.length} tail of ${effectiveCount} total lines]`
76
+
77
+ // Preserve trailing newline semantics of original if present
78
+ const hadTrailingNewline = output.endsWith('\n')
79
+ const parts = [...head, marker, ...tail]
80
+ let result = parts.join('\n')
81
+ if (hadTrailingNewline) result += '\n'
82
+ return result
83
+ }
84
+
85
+ /**
86
+ * Sandbox truncation for `grep` tool output.
87
+ * Each non-empty line is considered a match. Keeps top N, drops tail.
88
+ */
89
+ export function sandboxGrep(output: string, limits = DEFAULT_LIMITS.grep): string {
90
+ const lines = output.split('\n')
91
+ const effectiveCount =
92
+ lines.length > 0 && lines[lines.length - 1] === '' ? lines.length - 1 : lines.length
93
+ // Count non-empty? spec says 20 results — treat every line as result for simplicity,
94
+ // but filter obvious empties: if effective count includes empty trailing, already handled.
95
+ // Use effectiveCount as result count.
96
+ if (effectiveCount <= limits.maxResults) return output
97
+
98
+ const head = lines.slice(0, limits.keepTop)
99
+ const hidden = effectiveCount - head.length
100
+ const marker = `[TRUNCATED: Showing ${head.length} of ${effectiveCount} matches — ${hidden} more hidden. Use more specific pattern.]`
101
+ const hadTrailingNewline = output.endsWith('\n')
102
+ let result = [...head, marker].join('\n')
103
+ if (hadTrailingNewline) result += '\n'
104
+ return result
105
+ }
106
+
107
+ /**
108
+ * Sandbox truncation for `glob` tool output.
109
+ * Each line is a file path. Keeps top N.
110
+ */
111
+ export function sandboxGlob(output: string, limits = DEFAULT_LIMITS.glob): string {
112
+ const lines = output.split('\n')
113
+ const effectiveCount =
114
+ lines.length > 0 && lines[lines.length - 1] === '' ? lines.length - 1 : lines.length
115
+ if (effectiveCount <= limits.maxFiles) return output
116
+
117
+ const head = lines.slice(0, limits.keepTop)
118
+ const hidden = effectiveCount - head.length
119
+ const marker = `[TRUNCATED: showing ${head.length} of ${effectiveCount} files — ${hidden} more hidden]`
120
+ const hadTrailingNewline = output.endsWith('\n')
121
+ let result = [...head, marker].join('\n')
122
+ if (hadTrailingNewline) result += '\n'
123
+ return result
124
+ }
125
+
126
+ /**
127
+ * Sandbox truncation for `webfetch` tool output.
128
+ * Character-based: keep head chars, drop rest.
129
+ */
130
+ export function sandboxWebfetch(output: string, limits = DEFAULT_LIMITS.webfetch): string {
131
+ if (output.length <= limits.maxChars) return output
132
+ const head = output.slice(0, limits.keepHead)
133
+ const hidden = output.length - head.length
134
+ const marker = `\n[TRUNCATED: Content truncated — showing first ${head.length} of ${output.length} chars, ${hidden} chars hidden]\n`
135
+ return head + marker
136
+ }
137
+
138
+ /**
139
+ * Dispatch sandbox truncation by tool name. Pure function — no I/O.
140
+ * Unknown tools pass through unchanged.
141
+ */
142
+ export function sandboxOutput(
143
+ tool: string,
144
+ output: string,
145
+ limits: SandboxToolLimits = DEFAULT_LIMITS,
146
+ ): string {
147
+ if (typeof output !== 'string' || output.length === 0) return output
148
+ const normalized = tool.trim().toLowerCase()
149
+ switch (normalized) {
150
+ case 'read':
151
+ return sandboxRead(output, limits.read)
152
+ case 'grep':
153
+ return sandboxGrep(output, limits.grep)
154
+ case 'glob':
155
+ return sandboxGlob(output, limits.glob)
156
+ case 'webfetch':
157
+ case 'fetch':
158
+ case 'web_fetch':
159
+ return sandboxWebfetch(output, limits.webfetch)
160
+ default:
161
+ return output
162
+ }
163
+ }
164
+
165
+ // ─── Plugin hook factory ───────────────────────────────────────────────
166
+
167
+ /**
168
+ * Resolve sandbox config from opencode.json `context_sandbox` block.
169
+ * Fail-open: any missing/invalid field falls back to defaults.
170
+ */
171
+ export function resolveSandboxConfig(raw?: unknown): ContextSandboxConfig {
172
+ if (raw === null || raw === undefined || typeof raw !== 'object')
173
+ return { ...DEFAULT_CONFIG, limits: { ...DEFAULT_LIMITS } }
174
+ const obj = raw as Record<string, unknown>
175
+ const enabled = typeof obj.enabled === 'boolean' ? obj.enabled : true
176
+ const limitsRaw = obj.limits as Record<string, unknown> | undefined
177
+
178
+ const limits: SandboxToolLimits = {
179
+ read: {
180
+ maxLines:
181
+ typeof limitsRaw?.read === 'object' &&
182
+ limitsRaw.read !== null &&
183
+ typeof (limitsRaw.read as Record<string, unknown>).maxLines === 'number'
184
+ ? ((limitsRaw.read as Record<string, unknown>).maxLines as number)
185
+ : DEFAULT_LIMITS.read.maxLines,
186
+ keepHead:
187
+ typeof limitsRaw?.read === 'object' &&
188
+ limitsRaw.read !== null &&
189
+ typeof (limitsRaw.read as Record<string, unknown>).keepHead === 'number'
190
+ ? ((limitsRaw.read as Record<string, unknown>).keepHead as number)
191
+ : DEFAULT_LIMITS.read.keepHead,
192
+ keepTail:
193
+ typeof limitsRaw?.read === 'object' &&
194
+ limitsRaw.read !== null &&
195
+ typeof (limitsRaw.read as Record<string, unknown>).keepTail === 'number'
196
+ ? ((limitsRaw.read as Record<string, unknown>).keepTail as number)
197
+ : DEFAULT_LIMITS.read.keepTail,
198
+ },
199
+ grep: {
200
+ maxResults:
201
+ typeof limitsRaw?.grep === 'object' &&
202
+ limitsRaw.grep !== null &&
203
+ typeof (limitsRaw.grep as Record<string, unknown>).maxResults === 'number'
204
+ ? ((limitsRaw.grep as Record<string, unknown>).maxResults as number)
205
+ : DEFAULT_LIMITS.grep.maxResults,
206
+ keepTop:
207
+ typeof limitsRaw?.grep === 'object' &&
208
+ limitsRaw.grep !== null &&
209
+ typeof (limitsRaw.grep as Record<string, unknown>).keepTop === 'number'
210
+ ? ((limitsRaw.grep as Record<string, unknown>).keepTop as number)
211
+ : DEFAULT_LIMITS.grep.keepTop,
212
+ },
213
+ glob: {
214
+ maxFiles:
215
+ typeof limitsRaw?.glob === 'object' &&
216
+ limitsRaw.glob !== null &&
217
+ typeof (limitsRaw.glob as Record<string, unknown>).maxFiles === 'number'
218
+ ? ((limitsRaw.glob as Record<string, unknown>).maxFiles as number)
219
+ : DEFAULT_LIMITS.glob.maxFiles,
220
+ keepTop:
221
+ typeof limitsRaw?.glob === 'object' &&
222
+ limitsRaw.glob !== null &&
223
+ typeof (limitsRaw.glob as Record<string, unknown>).keepTop === 'number'
224
+ ? ((limitsRaw.glob as Record<string, unknown>).keepTop as number)
225
+ : DEFAULT_LIMITS.glob.keepTop,
226
+ },
227
+ webfetch: {
228
+ maxChars:
229
+ typeof limitsRaw?.webfetch === 'object' &&
230
+ limitsRaw.webfetch !== null &&
231
+ typeof (limitsRaw.webfetch as Record<string, unknown>).maxChars === 'number'
232
+ ? ((limitsRaw.webfetch as Record<string, unknown>).maxChars as number)
233
+ : DEFAULT_LIMITS.webfetch.maxChars,
234
+ keepHead:
235
+ typeof limitsRaw?.webfetch === 'object' &&
236
+ limitsRaw.webfetch !== null &&
237
+ typeof (limitsRaw.webfetch as Record<string, unknown>).keepHead === 'number'
238
+ ? ((limitsRaw.webfetch as Record<string, unknown>).keepHead as number)
239
+ : DEFAULT_LIMITS.webfetch.keepHead,
240
+ },
241
+ }
242
+ return { enabled, limits }
243
+ }
244
+
245
+ /**
246
+ * Create the `tool.execute.after` handler that sandboxes oversized outputs.
247
+ * The handler mutates `output.output` in-place and tags metadata when truncated.
248
+ * Fail-open: never throws.
249
+ */
250
+ export function createContextSandbox(
251
+ config: ContextSandboxConfig = DEFAULT_CONFIG,
252
+ ): ToolExecuteAfterHandler {
253
+ // Capture mutable reference — caller (plugin config hook) may update limits live
254
+ const cfgRef = config
255
+ return async (input: ToolExecuteAfterInput, output: ToolExecuteAfterOutput): Promise<void> => {
256
+ try {
257
+ if (!cfgRef.enabled) return
258
+ if (typeof output.output !== 'string') return
259
+ const before = output.output
260
+ const after = sandboxOutput(input.tool, before, cfgRef.limits)
261
+ if (after !== before) {
262
+ output.output = after
263
+ output.metadata = {
264
+ ...(output.metadata ?? {}),
265
+ truncated: true,
266
+ sandbox: input.tool.toLowerCase(),
267
+ }
268
+ }
269
+ } catch {
270
+ // never break the tool result
271
+ }
272
+ }
273
+ }
@@ -172,24 +172,48 @@ export const DELEGATION_DEFAULTS = {
172
172
 
173
173
  // ─── Output pulling ────────────────────────────────────────────────────
174
174
 
175
- /** Concatenate every non-empty text part across the child's messages. */
176
- async function pullOutput(client: DelegationClient, childSessionID: string): Promise<string> {
175
+ interface PulledOutput {
176
+ text: string
177
+ /** True only when the child produced an assistant message or tool activity. */
178
+ hasWork: boolean
179
+ }
180
+
181
+ /** Pull visible text and structurally identify real child work. */
182
+ async function pullOutput(client: DelegationClient, childSessionID: string): Promise<PulledOutput> {
177
183
  if (typeof client.session.messages !== 'function')
178
- return '(child session messages API unavailable)'
184
+ return { text: '(child session messages API unavailable)', hasWork: false }
179
185
  try {
180
186
  const bundles = await client.session.messages({ path: { id: childSessionID } })
181
187
  const lines: string[] = []
188
+ let hasWork = false
182
189
  for (const bundle of bundles) {
190
+ const hasToolPart = (bundle.parts ?? []).some((part) => part.type === 'tool')
191
+ const hasTextPart = (bundle.parts ?? []).some(
192
+ (part) => part.type === 'text' && typeof part.text === 'string' && part.text.trim() !== '',
193
+ )
194
+ if (
195
+ bundle.info?.role === 'assistant' &&
196
+ bundle.info.error === undefined &&
197
+ (hasToolPart || hasTextPart)
198
+ ) {
199
+ hasWork = true
200
+ }
183
201
  for (const part of bundle.parts ?? []) {
202
+ if (
203
+ part.type === 'tool' &&
204
+ bundle.info?.role === 'assistant' &&
205
+ bundle.info.error === undefined
206
+ )
207
+ hasWork = true
184
208
  if (part.type === 'text' && typeof part.text === 'string' && part.text.trim() !== '') {
185
209
  lines.push(part.text)
186
210
  }
187
211
  }
188
212
  }
189
- return lines.join('\n\n')
213
+ return { text: lines.join('\n\n'), hasWork }
190
214
  } catch (err: unknown) {
191
215
  const reason = err instanceof Error ? err.message : String(err)
192
- return `(failed to pull child session output: ${reason})`
216
+ return { text: `(failed to pull child session output: ${reason})`, hasWork: false }
193
217
  }
194
218
  }
195
219
 
@@ -311,9 +335,18 @@ export async function finalizeDelegation(
311
335
  const job = deps.board.get(childSessionID)
312
336
  if (!job) return undefined
313
337
 
314
- const output = await pullOutput(deps.client, childSessionID)
338
+ const pulled = await pullOutput(deps.client, childSessionID)
339
+ const output = pulled.text
340
+ const effectiveOpts: FinalizeInput =
341
+ opts.state === 'completed' && !pulled.hasWork
342
+ ? {
343
+ state: 'error',
344
+ error: opts.error ?? 'Child session produced no assistant or tool output',
345
+ ...(opts.timedOut !== undefined ? { timedOut: opts.timedOut } : {}),
346
+ }
347
+ : opts
315
348
  const outputDir = deps.options.outputDir ?? DELEGATION_DEFAULTS.outputDir
316
- await writeDelegationReport(outputDir, job, renderDelegationMarkdown(job, output, opts))
349
+ await writeDelegationReport(outputDir, job, renderDelegationMarkdown(job, output, effectiveOpts))
317
350
 
318
351
  const status: {
319
352
  taskID: string
@@ -323,15 +356,15 @@ export async function finalizeDelegation(
323
356
  resultSummary?: string
324
357
  } = {
325
358
  taskID: childSessionID,
326
- state: opts.state,
359
+ state: effectiveOpts.state,
327
360
  resultSummary: summarizeOutput(output),
328
361
  }
329
- if (opts.error !== undefined) status.error = opts.error
330
- if (opts.timedOut !== undefined) status.timedOut = opts.timedOut
362
+ if (effectiveOpts.error !== undefined) status.error = effectiveOpts.error
363
+ if (effectiveOpts.timedOut !== undefined) status.timedOut = effectiveOpts.timedOut
331
364
 
332
365
  const current = deps.board.get(childSessionID)
333
366
  if (current !== undefined) {
334
- if (current.state === 'running' || current.state === opts.state) {
367
+ if (current.state === 'running' || current.state === effectiveOpts.state) {
335
368
  await deps.board.updateStatus(status)
336
369
  }
337
370
  // Terminal in a DIFFERENT state (e.g. timeout already recorded) — the
@@ -0,0 +1,73 @@
1
+ import type { BackgroundJobRecord } from './background-job-board.ts'
2
+
3
+ type ToastCategory = 'errors' | 'delegations' | 'council'
4
+
5
+ const toastMode = (process.env.PANTHEON_TOASTS ?? '').trim().toLowerCase()
6
+ const enabledToastCategories: ReadonlySet<ToastCategory> = (() => {
7
+ switch (toastMode) {
8
+ case 'off':
9
+ return new Set()
10
+ case 'errors':
11
+ return new Set(['errors'])
12
+ case 'council':
13
+ return new Set(['errors', 'council'])
14
+ case 'all':
15
+ return new Set(['errors', 'delegations', 'council'])
16
+ default:
17
+ return new Set(['errors', 'delegations', 'council'])
18
+ }
19
+ })()
20
+ const terminalToastsShown = new Set<string>()
21
+ const terminalStates = new Set(['completed', 'error', 'cancelled'])
22
+
23
+ /** Match the lifecycle toast gate used by pantheon-hooks. */
24
+ export function delegationToastEnabled(): boolean {
25
+ return enabledToastCategories.has('delegations')
26
+ }
27
+
28
+ export type ToastClient = {
29
+ tui?: {
30
+ showToast?: (input: {
31
+ body: {
32
+ title?: string
33
+ message: string
34
+ variant: 'info' | 'success' | 'warning' | 'error'
35
+ duration: number
36
+ }
37
+ }) => Promise<unknown>
38
+ }
39
+ }
40
+
41
+ /** Best-effort visual completion signal; never affects board finalization. */
42
+ export async function showDelegationTerminalToast(
43
+ client: ToastClient,
44
+ job: Pick<BackgroundJobRecord, 'taskID' | 'alias' | 'agent' | 'state' | 'timedOut'>,
45
+ ): Promise<void> {
46
+ try {
47
+ if (!delegationToastEnabled()) return
48
+ const isTerminal = job.timedOut || terminalStates.has(job.state)
49
+ if (isTerminal) {
50
+ if (terminalToastsShown.has(job.taskID)) return
51
+ terminalToastsShown.add(job.taskID)
52
+ }
53
+ const showToast = client.tui?.showToast
54
+ if (typeof showToast !== 'function') return
55
+ const state = job.timedOut ? 'timeout' : job.state
56
+ const variant =
57
+ state === 'completed'
58
+ ? 'success'
59
+ : state === 'error' || state === 'timeout'
60
+ ? 'error'
61
+ : 'warning'
62
+ await showToast({
63
+ body: {
64
+ title: 'Delegação concluída',
65
+ message: `${job.alias} (${job.agent}) — ${state}`,
66
+ variant,
67
+ duration: 4000,
68
+ },
69
+ })
70
+ } catch {
71
+ // TUI is optional (and may disappear during shutdown); completion is durable.
72
+ }
73
+ }