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.
- package/AGENTS.md +919 -0
- package/README.md +157 -15
- package/bin/pantheon-init.mjs +177 -120
- package/opencode.json +3 -6
- package/package.json +8 -4
- package/scripts/build-agents-md.mjs +195 -0
- package/scripts/cost.mjs +17 -0
- package/scripts/doctor.mjs +56 -22
- package/scripts/hooks/on-subagent-delegation-stop.sh +22 -5
- package/scripts/install/opencode.mjs +189 -38
- package/scripts/install/plugin.mjs +112 -28
- package/scripts/install-mcp.mjs +1 -1
- package/scripts/redaction-gate.mjs +69 -0
- package/scripts/release-beta-version.mjs +80 -0
- package/scripts/release-notes.mjs +317 -0
- package/scripts/test-context-compression.sh +5 -2
- package/scripts/validate-package.mjs +56 -0
- package/scripts/versioning.mjs +117 -18
- package/src/agents/zeus.md +6 -2
- package/src/pantheon/background-job-board.ts +24 -38
- package/src/pantheon/chat-reminders.ts +79 -0
- package/src/pantheon/compaction-assert.ts +160 -0
- package/src/pantheon/cost-command.ts +57 -18
- package/src/pantheon/delegation-compaction.ts +143 -16
- package/src/pantheon/delegation-finalize.ts +8 -1
- package/src/pantheon/delegation-notify.ts +19 -157
- package/src/pantheon/delegation.ts +330 -28
- package/src/pantheon/dispatch-guard.ts +9 -4
- package/src/pantheon/goal-loop.ts +7 -4
- package/src/pantheon/hashline/tool.ts +2 -1
- package/src/pantheon/logger.ts +117 -0
- package/src/pantheon/plugin-once.ts +76 -0
- package/src/pantheon/preemptive-compact.ts +76 -0
- package/src/pantheon/presets.d.mts +31 -0
- package/src/pantheon/presets.mjs +105 -3
- package/src/pantheon/todo-enforcer.ts +79 -27
- package/src/pantheon/todo-preserve.ts +295 -0
- package/src/plugin.ts +177 -50
- package/src/plugins/pantheon-hooks.ts +35 -22
- package/src/plugins/tui/dist/server.d.ts +17 -0
- package/src/plugins/tui/dist/server.d.ts.map +1 -0
- package/src/plugins/tui/dist/server.js +20 -0
- package/src/plugins/tui/dist/server.js.map +1 -0
- package/src/plugins/tui/dist/tui.d.ts +303 -1
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +977 -350
- package/src/plugins/tui/dist/tui.tsx +1427 -150
- package/src/plugins/tui/package.json +4 -3
- package/src/plugins/tui/src/index.tsx +1427 -150
- package/src/plugins/tui/src/server.ts +15 -0
- package/src/plugins/tui/tsdown.config.ts +1 -1
- package/src/routing.yml +3 -1
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* by the `experimental.session.compacting` hook so a
|
|
4
|
-
* not lose
|
|
2
|
+
* Compaction Context (Phase 4 + release-134 Phase 2) — build the context
|
|
3
|
+
* blocks injected by the `experimental.session.compacting` hook so a
|
|
4
|
+
* compacted session does not lose its working state: in-flight background
|
|
5
|
+
* delegations, active goals and pending todos.
|
|
5
6
|
*
|
|
6
|
-
* Returns a string[] (empty when there is nothing to preserve)
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* Returns a Promise<string[]> (empty when there is nothing to preserve).
|
|
8
|
+
* Section order is stable:
|
|
9
|
+
* - "<pantheon-context directive>" — preservation directive (prefix,
|
|
10
|
+
* PANTHEON_COMPACTION_DIRECTIVE). Emitted whenever at least one other
|
|
11
|
+
* section follows; a totally-empty state returns [] (an orphan
|
|
12
|
+
* directive is noise).
|
|
13
|
+
* - "<mission_context>" — active (non-done) goals from `opts.goals`:
|
|
14
|
+
* " [id] objective — status". Omitted when the goal source is absent,
|
|
15
|
+
* disabled, unscoped (no sessionID), fails, or has no active goals.
|
|
16
|
+
* - "<todo_context>" — pending (not completed/cancelled) todos from
|
|
17
|
+
* `opts.todos`: " [id] description — status". Omitted when the todo
|
|
18
|
+
* source is absent, disabled, unscoped (no sessionID), fails, or has
|
|
19
|
+
* no pending todos.
|
|
20
|
+
* - "Background Delegations (running):" — every running job launched by
|
|
21
|
+
* the session (active work is never capped): alias, agent, started ISO,
|
|
9
22
|
* truncated prompt.
|
|
10
23
|
* - "Background Delegations (finished, unread):" — terminal-unreconciled
|
|
11
24
|
* jobs, newest first, capped at `maxItems` (default 10, matching
|
|
@@ -21,14 +34,52 @@
|
|
|
21
34
|
*/
|
|
22
35
|
|
|
23
36
|
import type { BackgroundJobBoard, BackgroundJobRecord } from './background-job-board.ts'
|
|
37
|
+
import type { Goal } from './goal-store.ts'
|
|
38
|
+
import { createPantheonLogger } from './logger.ts'
|
|
39
|
+
import type { TodoLike } from './todo-enforcer.ts'
|
|
40
|
+
|
|
41
|
+
// Silence-by-default TUI policy (pantheon-hooks L42-58): the default warn
|
|
42
|
+
// fallback logs to .pantheon/logs/hooks.log; console echo is opt-in via
|
|
43
|
+
// PANTHEON_HOOKS_LOG=1. `deps.logger` injection stays for tests.
|
|
44
|
+
const log = createPantheonLogger({ module: 'pantheon-compaction' })
|
|
45
|
+
|
|
46
|
+
// ─── Constants ─────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Preservation directive (release-134 Phase 2): the native compaction
|
|
50
|
+
* prompt keeps every pushed section, so this prefix asks the model to keep
|
|
51
|
+
* the pantheon sections verbatim — not paraphrased, merged, or dropped.
|
|
52
|
+
*/
|
|
53
|
+
export const PANTHEON_COMPACTION_DIRECTIVE =
|
|
54
|
+
'preserve these sections verbatim in the summarized context'
|
|
24
55
|
|
|
25
56
|
// ─── Types ─────────────────────────────────────────────────────────────
|
|
26
57
|
|
|
58
|
+
/** Source for the <mission_context> section (active goals). */
|
|
59
|
+
export interface CompactionGoalSource {
|
|
60
|
+
/** True when the goal loop is enabled; false/undefined → section omitted. */
|
|
61
|
+
enabled: boolean
|
|
62
|
+
/** List the session's goals (any status; the builder keeps non-done). */
|
|
63
|
+
list(sessionID: string): Promise<Goal[]>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Source for the <todo_context> section (pending todos). */
|
|
67
|
+
export interface CompactionTodoSource {
|
|
68
|
+
/** True when the todo enforcer is enabled; false/undefined → section omitted. */
|
|
69
|
+
enabled: boolean
|
|
70
|
+
/** List the session's todos (any status; the builder keeps pending). */
|
|
71
|
+
list(sessionID: string): Promise<TodoLike[]>
|
|
72
|
+
}
|
|
73
|
+
|
|
27
74
|
export interface CompactionContextOptions {
|
|
28
75
|
/** Scope to jobs launched by this session; omit for all jobs. */
|
|
29
76
|
sessionID?: string
|
|
30
77
|
/** Cap on unread terminal jobs kept (default 10). */
|
|
31
78
|
maxItems?: number
|
|
79
|
+
/** Goal source for <mission_context> (optional — omitted when absent). */
|
|
80
|
+
goals?: CompactionGoalSource
|
|
81
|
+
/** Todo source for <todo_context> (optional — omitted when absent). */
|
|
82
|
+
todos?: CompactionTodoSource
|
|
32
83
|
}
|
|
33
84
|
|
|
34
85
|
/** Prompt truncation length for a context line. */
|
|
@@ -71,17 +122,69 @@ function unreadTerminalLine(job: BackgroundJobRecord): string {
|
|
|
71
122
|
)
|
|
72
123
|
}
|
|
73
124
|
|
|
74
|
-
// ───
|
|
125
|
+
// ─── Sections ──────────────────────────────────────────────────────────
|
|
75
126
|
|
|
76
127
|
/**
|
|
77
|
-
*
|
|
78
|
-
* Returns
|
|
79
|
-
*
|
|
128
|
+
* `<mission_context>` — active (non-done) goals for the session.
|
|
129
|
+
* Returns undefined when the section must be omitted: no goal source, the
|
|
130
|
+
* goal loop is disabled, no sessionID (per-session data cannot be scoped),
|
|
131
|
+
* the source throws (logged, fail-open), or no active goals remain.
|
|
80
132
|
*/
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
opts
|
|
84
|
-
|
|
133
|
+
async function missionContextBlock(opts: CompactionContextOptions): Promise<string | undefined> {
|
|
134
|
+
const source = opts.goals
|
|
135
|
+
if (opts.sessionID === undefined || source === undefined || !source.enabled) return undefined
|
|
136
|
+
|
|
137
|
+
let goals: Goal[]
|
|
138
|
+
try {
|
|
139
|
+
goals = await source.list(opts.sessionID)
|
|
140
|
+
} catch (err: unknown) {
|
|
141
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
142
|
+
log.warn(`compaction goal context failed for session ${opts.sessionID}: ${reason}`)
|
|
143
|
+
return undefined
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const active = goals.filter((goal) => goal.status !== 'done')
|
|
147
|
+
if (active.length === 0) return undefined
|
|
148
|
+
|
|
149
|
+
const lines = active.map((goal) => ` [${goal.id}] ${goal.objective} — ${goal.status}`)
|
|
150
|
+
return `<mission_context>\n${lines.join('\n')}`
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* `<todo_context>` — pending (not completed/cancelled) todos for the
|
|
155
|
+
* session. Returns undefined when the section must be omitted: no todo
|
|
156
|
+
* source, the enforcer is disabled, no sessionID, the source throws
|
|
157
|
+
* (logged, fail-open), or no pending todos remain.
|
|
158
|
+
*/
|
|
159
|
+
async function todoContextBlock(opts: CompactionContextOptions): Promise<string | undefined> {
|
|
160
|
+
const source = opts.todos
|
|
161
|
+
if (opts.sessionID === undefined || source === undefined || !source.enabled) return undefined
|
|
162
|
+
|
|
163
|
+
let todos: TodoLike[]
|
|
164
|
+
try {
|
|
165
|
+
todos = await source.list(opts.sessionID)
|
|
166
|
+
} catch (err: unknown) {
|
|
167
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
168
|
+
log.warn(`compaction todo context failed for session ${opts.sessionID}: ${reason}`)
|
|
169
|
+
return undefined
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const pending = todos.filter((todo) => todo.status !== 'completed' && todo.status !== 'cancelled')
|
|
173
|
+
if (pending.length === 0) return undefined
|
|
174
|
+
|
|
175
|
+
const lines = pending.map((todo) => {
|
|
176
|
+
const label = todo.content?.trim() || todo.id || '(no description)'
|
|
177
|
+
const id = todo.id !== undefined && todo.id !== '' ? `[${todo.id}] ` : ''
|
|
178
|
+
return ` ${id}${label} — ${todo.status}`
|
|
179
|
+
})
|
|
180
|
+
return `<todo_context>\n${lines.join('\n')}`
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* `<delegation_context>` — the existing delegation blocks, byte-for-byte
|
|
185
|
+
* unchanged from Phase 4 (running, then unread terminal ≤ maxItems).
|
|
186
|
+
*/
|
|
187
|
+
function delegationBlocks(board: BackgroundJobBoard, opts: CompactionContextOptions): string[] {
|
|
85
188
|
const maxItems = opts.maxItems ?? 10
|
|
86
189
|
const jobs = opts.sessionID !== undefined ? board.list(opts.sessionID) : board.list()
|
|
87
190
|
|
|
@@ -93,8 +196,6 @@ export function buildCompactionContext(
|
|
|
93
196
|
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
94
197
|
.slice(0, maxItems)
|
|
95
198
|
|
|
96
|
-
if (running.length === 0 && unreadTerminal.length === 0) return []
|
|
97
|
-
|
|
98
199
|
const blocks: string[] = []
|
|
99
200
|
if (running.length > 0) {
|
|
100
201
|
blocks.push(`Background Delegations (running):\n${running.map(runningLine).join('\n')}`)
|
|
@@ -108,3 +209,29 @@ export function buildCompactionContext(
|
|
|
108
209
|
}
|
|
109
210
|
return blocks
|
|
110
211
|
}
|
|
212
|
+
|
|
213
|
+
// ─── Build ─────────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Build the compaction context blocks for a session: preservation
|
|
217
|
+
* directive, active goals, pending todos, and in-flight background
|
|
218
|
+
* delegations. Returns an empty array when there is nothing to preserve
|
|
219
|
+
* (caller skips the hook injection entirely).
|
|
220
|
+
*/
|
|
221
|
+
export async function buildCompactionContext(
|
|
222
|
+
board: BackgroundJobBoard,
|
|
223
|
+
opts: CompactionContextOptions = {},
|
|
224
|
+
): Promise<string[]> {
|
|
225
|
+
const [mission, todo] = await Promise.all([missionContextBlock(opts), todoContextBlock(opts)])
|
|
226
|
+
const delegation = delegationBlocks(board, opts)
|
|
227
|
+
|
|
228
|
+
// Totally-empty state → nothing to preserve; a lone directive is noise.
|
|
229
|
+
if (mission === undefined && todo === undefined && delegation.length === 0) return []
|
|
230
|
+
|
|
231
|
+
const blocks: string[] = []
|
|
232
|
+
blocks.push(`<pantheon-context directive>\n${PANTHEON_COMPACTION_DIRECTIVE}`)
|
|
233
|
+
if (mission !== undefined) blocks.push(mission)
|
|
234
|
+
if (todo !== undefined) blocks.push(todo)
|
|
235
|
+
blocks.push(...delegation)
|
|
236
|
+
return blocks
|
|
237
|
+
}
|
|
@@ -26,7 +26,14 @@ import type { BackgroundJobBoard, BackgroundJobRecord } from './background-job-b
|
|
|
26
26
|
/** A minimal structural view of one message bundle from session.messages(). */
|
|
27
27
|
export interface DelegationMessageBundle {
|
|
28
28
|
info?: { role?: string; finish?: string; error?: unknown }
|
|
29
|
-
parts?: Array<{
|
|
29
|
+
parts?: Array<{
|
|
30
|
+
type?: string
|
|
31
|
+
text?: string
|
|
32
|
+
/** Tool name on `tool` parts — used by the activity sampler. */
|
|
33
|
+
tool?: string
|
|
34
|
+
/** Tool input on `tool` parts (opencode SDK ToolPart.metadata). */
|
|
35
|
+
metadata?: { input?: unknown }
|
|
36
|
+
}>
|
|
30
37
|
}
|
|
31
38
|
|
|
32
39
|
/** Minimal structural view of the opencode SDK session client we use. */
|
|
@@ -1,26 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Delegation
|
|
2
|
+
* Delegation event wiring — observe background-job lifecycle events and
|
|
3
|
+
* route them to finalize. This module is the `event`-hook complement of the
|
|
4
|
+
* board: `session.idle`/`session.error` on a child session that is a board
|
|
5
|
+
* job → `finalizeDelegation` (Phase 2), which transitions the board and
|
|
6
|
+
* fires `onTerminal`.
|
|
3
7
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* notifications onto the first text part of the parent's user message —
|
|
13
|
-
* the same graceful-degradation channel pantheon-hooks uses for its
|
|
14
|
-
* <system-reminder> fallback buffer (TUI toasts are broken on 1.18.13).
|
|
15
|
-
*
|
|
16
|
-
* If the parent never sends another message the notification stays queued —
|
|
17
|
-
* the graceful-degradation path. There is deliberately NO client push.
|
|
8
|
+
* NOT a notification channel. The user policy is ZERO delegation
|
|
9
|
+
* notifications in the chat transcript — no `<task-notification>` block is
|
|
10
|
+
* ever injected into a `chat.message` output, and no queued delivery path
|
|
11
|
+
* exists. Completion visibility lives in the legitimate channels only:
|
|
12
|
+
* the board `[unread]` marker (`pantheon_delegation_list`),
|
|
13
|
+
* `pantheon_delegation_read`, TUI toasts (pantheon-hooks, PANTHEON_TOASTS
|
|
14
|
+
* gate), and compaction carry-forward. The plugin's `onTerminal` listener
|
|
15
|
+
* writes a file-only log line for the on-disk audit trail.
|
|
18
16
|
*
|
|
19
17
|
* `handleDelegationEvent` wires the `event` hook: `session.idle`/`session.error`
|
|
20
18
|
* on a child session that is a board job → `finalizeDelegation` (Phase 2),
|
|
21
|
-
* which transitions the board and fires `onTerminal
|
|
22
|
-
*
|
|
23
|
-
*
|
|
19
|
+
* which transitions the board and fires `onTerminal`. Finalize never notifies
|
|
20
|
+
* directly, and `markReconciled` does not re-fire onTerminal (reconcile is an
|
|
21
|
+
* acknowledgment, not a completion), so every job gets exactly ONE terminal
|
|
22
|
+
* transition — never a reconciled echo. Unknown sessions are ignored.
|
|
24
23
|
*
|
|
25
24
|
* Pure TypeScript — zero runtime dependencies beyond the board types.
|
|
26
25
|
*
|
|
@@ -41,12 +40,6 @@ export type { FinalizeInput } from './delegation-finalize.ts'
|
|
|
41
40
|
|
|
42
41
|
// ─── Types ─────────────────────────────────────────────────────────────
|
|
43
42
|
|
|
44
|
-
/** One queued notification: text + enqueue timestamp. */
|
|
45
|
-
export interface QueuedNotification {
|
|
46
|
-
text: string
|
|
47
|
-
at: number
|
|
48
|
-
}
|
|
49
|
-
|
|
50
43
|
/** Structural view of the event payload the `event` hook receives. */
|
|
51
44
|
export interface DelegationEventLike {
|
|
52
45
|
type?: string
|
|
@@ -61,137 +54,6 @@ export interface DelegationEventDeps {
|
|
|
61
54
|
finalize: (childSessionID: string, opts: FinalizeInput) => Promise<unknown>
|
|
62
55
|
}
|
|
63
56
|
|
|
64
|
-
// ─── Notification text ─────────────────────────────────────────────────
|
|
65
|
-
|
|
66
|
-
/** Cap for description/summary text embedded in a notification. */
|
|
67
|
-
const NOTIFICATION_TEXT_MAX = 120
|
|
68
|
-
|
|
69
|
-
function truncate(text: string, max: number): string {
|
|
70
|
-
const trimmed = text.trim()
|
|
71
|
-
return trimmed.length > max ? `${trimmed.slice(0, max)}…` : trimmed
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Build the self-contained `<task-notification>` block for a terminal job.
|
|
76
|
-
* Carries the task ID, alias, agent, state, timeout flag, description and
|
|
77
|
-
* result summary so the parent model can act on the completion without extra
|
|
78
|
-
* tool calls.
|
|
79
|
-
*/
|
|
80
|
-
export function buildTaskNotification(job: BackgroundJobRecord): string {
|
|
81
|
-
const stateLabel =
|
|
82
|
-
job.state === 'completed' ? 'completed' : job.state === 'error' ? 'failed' : job.state
|
|
83
|
-
const timeout = job.timedOut ? ' (timed out)' : ''
|
|
84
|
-
// The `Result:` marker makes the child output origin explicit in the parent
|
|
85
|
-
// context — the summary text came from the CHILD's output, not the parent's
|
|
86
|
-
// own context (Themis mitigation, Phase 5).
|
|
87
|
-
const summary = job.resultSummary
|
|
88
|
-
? ` — Result: ${truncate(job.resultSummary, NOTIFICATION_TEXT_MAX)}`
|
|
89
|
-
: ''
|
|
90
|
-
return [
|
|
91
|
-
'<task-notification>',
|
|
92
|
-
`<task id="${job.taskID}" alias="${job.alias}" agent="${job.agent}" state="${job.state}">`,
|
|
93
|
-
`<summary>Delegation [${job.alias}] (${job.agent}) ${stateLabel}${timeout}: ${truncate(job.description, NOTIFICATION_TEXT_MAX)}${summary}</summary>`,
|
|
94
|
-
`<read>Read the full report with pantheon_delegation_read({ id: "${job.alias}" }).</read>`,
|
|
95
|
-
'</task>',
|
|
96
|
-
'</task-notification>',
|
|
97
|
-
].join('\n')
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// ─── Notifier (queue + flush) ──────────────────────────────────────────
|
|
101
|
-
|
|
102
|
-
/** Per-parent queue bound — a full queue drops new entries, never grows unbounded. */
|
|
103
|
-
const MAX_PER_PARENT = 10
|
|
104
|
-
|
|
105
|
-
function isTextPartLike(value: unknown): value is { type: 'text'; text: string } {
|
|
106
|
-
if (!value || typeof value !== 'object') return false
|
|
107
|
-
const part = value as { type?: unknown; text?: unknown }
|
|
108
|
-
return part.type === 'text' && typeof part.text === 'string'
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* In-memory completion notification queue, keyed by parent session ID.
|
|
113
|
-
* FIFO per parent, bounded (MAX_PER_PARENT — anti-spam philosophy, matching
|
|
114
|
-
* pantheon-hooks' CHAT_REMINDER_MAX). Delivered by `flushQueue` into the
|
|
115
|
-
* parent's next chat.message; delivered notifications are marked sent.
|
|
116
|
-
*/
|
|
117
|
-
export class DelegationNotifier {
|
|
118
|
-
private readonly queue = new Map<string, QueuedNotification[]>()
|
|
119
|
-
private readonly sent = new Map<string, number>()
|
|
120
|
-
private readonly warn: (message: string) => void
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* @param logger Optional injected logger (testable). Defaults to
|
|
124
|
-
* `console.warn` with the `[pantheon-delegation]` prefix, matching the
|
|
125
|
-
* plugin's structured-log style.
|
|
126
|
-
*/
|
|
127
|
-
constructor(logger?: { warn: (message: string) => void }) {
|
|
128
|
-
this.warn =
|
|
129
|
-
logger?.warn ?? ((message: string) => console.warn(`[pantheon-delegation] ${message}`))
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/** Notifications awaiting delivery for a parent session. */
|
|
133
|
-
pendingCount(parentSessionID: string): number {
|
|
134
|
-
return this.queue.get(parentSessionID)?.length ?? 0
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/** Whether any notifications await delivery for a parent session. */
|
|
138
|
-
hasPending(parentSessionID: string): boolean {
|
|
139
|
-
return this.pendingCount(parentSessionID) > 0
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/** Notifications already injected into a parent's chat.message output. */
|
|
143
|
-
sentCount(parentSessionID: string): number {
|
|
144
|
-
return this.sent.get(parentSessionID) ?? 0
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Queue a completion notification for a parent session. The channel: there
|
|
149
|
-
* is NO client push — the notification is delivered by the next
|
|
150
|
-
* `flushQueue` for that parent. Returns false when the queue is full.
|
|
151
|
-
*/
|
|
152
|
-
queueNotification(parentSessionID: string, text: string): boolean {
|
|
153
|
-
const entries = this.queue.get(parentSessionID) ?? []
|
|
154
|
-
if (entries.length >= MAX_PER_PARENT) {
|
|
155
|
-
this.warn(
|
|
156
|
-
`notification dropped for parent session ${parentSessionID}: ` +
|
|
157
|
-
`queue full (${MAX_PER_PARENT} pending)`,
|
|
158
|
-
)
|
|
159
|
-
return false
|
|
160
|
-
}
|
|
161
|
-
entries.push({ text, at: Date.now() })
|
|
162
|
-
this.queue.set(parentSessionID, entries)
|
|
163
|
-
return true
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/** Build + queue the notification for a terminal job. False when full. */
|
|
167
|
-
notifyParent(job: BackgroundJobRecord): boolean {
|
|
168
|
-
return this.queueNotification(job.parentSessionID, buildTaskNotification(job))
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Inject all pending notifications for a parent into a `chat.message` output
|
|
173
|
-
* (prepend onto the first text part — matching the pantheon-hooks reminder
|
|
174
|
-
* injection pattern; with no text part the block is unshifted as a new one).
|
|
175
|
-
* Delivered entries are marked sent. Returns how many were delivered (0 when
|
|
176
|
-
* nothing was pending).
|
|
177
|
-
*/
|
|
178
|
-
flushQueue(parentSessionID: string, output: { parts: unknown[] }): number {
|
|
179
|
-
const entries = this.queue.get(parentSessionID)
|
|
180
|
-
if (entries === undefined || entries.length === 0) return 0
|
|
181
|
-
this.queue.delete(parentSessionID)
|
|
182
|
-
|
|
183
|
-
const block = entries.map((entry) => entry.text).join('\n')
|
|
184
|
-
const firstText = output.parts.find(isTextPartLike)
|
|
185
|
-
if (firstText !== undefined) {
|
|
186
|
-
firstText.text = `${block}\n\n${firstText.text}`
|
|
187
|
-
} else {
|
|
188
|
-
output.parts.unshift({ type: 'text', text: block })
|
|
189
|
-
}
|
|
190
|
-
this.sent.set(parentSessionID, (this.sent.get(parentSessionID) ?? 0) + entries.length)
|
|
191
|
-
return entries.length
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
57
|
// ─── Event → finalize wiring ───────────────────────────────────────────
|
|
196
58
|
|
|
197
59
|
function extractErrorMessage(error: unknown): string {
|
|
@@ -209,8 +71,8 @@ function extractErrorMessage(error: unknown): string {
|
|
|
209
71
|
* - `session.error` on a board job → finalize as error (message extracted);
|
|
210
72
|
* - any other event, a missing session ID, or an unknown session → no-op.
|
|
211
73
|
*
|
|
212
|
-
* Finalize transitions the board, which fires `onTerminal`
|
|
213
|
-
*
|
|
74
|
+
* Finalize transitions the board, which fires `onTerminal` (the plugin's
|
|
75
|
+
* file-only audit log — no chat delivery; see module header). The board's
|
|
214
76
|
* same-terminal idempotency makes repeated idles harmless (finalize rewrites
|
|
215
77
|
* the report but never transitions a terminal job to a different state).
|
|
216
78
|
*
|