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.
- package/README.md +36 -46
- package/opencode.json +2 -3
- package/package.json +1 -1
- package/scripts/install/opencode.mjs +2 -0
- package/src/mcp/mcp_codemap_module.py +185 -0
- package/src/mcp/memory_mcp_server.py +91 -3
- package/src/pantheon/chat-reminders.ts +4 -74
- package/src/pantheon/command-normalizer.ts +101 -0
- package/src/pantheon/compaction-assert.ts +28 -28
- package/src/pantheon/context-sandbox.ts +273 -0
- package/src/pantheon/delegation-finalize.ts +44 -11
- package/src/pantheon/delegation-toast.ts +73 -0
- package/src/pantheon/delegation.ts +20 -106
- package/src/pantheon/presets.d.mts +4 -3
- package/src/pantheon/presets.mjs +17 -21
- package/src/plugin.ts +62 -33
- package/src/plugins/pantheon-hooks.ts +15 -158
- package/src/plugins/tui/dist/tui.d.ts +18 -3
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +42 -10
- package/src/plugins/tui/dist/tui.tsx +69 -17
- package/src/plugins/tui/package-lock.json +2 -2
- package/src/plugins/tui/package.json +1 -1
- package/src/plugins/tui/src/index.tsx +14 -10
- package/src/skills/codemap/SKILL.md +7 -0
- package/src/skills/context-compression/SKILL.md +39 -0
|
@@ -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) —
|
|
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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
139
|
-
*
|
|
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
|
|
142
|
-
* assert (silent skip) or a source failed (warned). NEVER throws —
|
|
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(
|
|
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
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
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
|
-
|
|
176
|
-
|
|
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
|
|
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,
|
|
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:
|
|
359
|
+
state: effectiveOpts.state,
|
|
327
360
|
resultSummary: summarizeOutput(output),
|
|
328
361
|
}
|
|
329
|
-
if (
|
|
330
|
-
if (
|
|
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 ===
|
|
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
|
+
}
|