pantheon-opencode 1.5.0-beta.7 → 1.5.0-beta.9

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.
@@ -1,91 +1,238 @@
1
1
  /**
2
2
  * File-based persistence adapter for the BackgroundJobBoard.
3
3
  *
4
- * Stores all jobs in a single `state.json` file using atomic writes
5
- * (write to `state.json.tmp` then rename) to prevent partial writes
6
- * from crashes.
4
+ * Stores all jobs in a single `state.json` file. Writes are crash-safe:
5
+ * the payload is written to a UNIQUE temp file, fsynced, then renamed over
6
+ * the destination. Writes to the same path are serialized through an
7
+ * in-process queue so two logical writers (plugin × native) cannot
8
+ * interleave and corrupt the JSON or race the rename.
9
+ *
10
+ * Reads are corruption-tolerant: a malformed `state.json` is moved aside to
11
+ * `<state>.corrupt-<ts>`, a clear log is emitted, and whatever complete
12
+ * records can be salvaged from the array are returned — never a throw, so
13
+ * callers (delete/prune/recover) cannot get stuck in a retry loop.
7
14
  *
8
15
  * @module file-persistence
9
16
  */
10
17
 
11
- import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
12
- import { dirname } from 'node:path'
18
+ import { mkdir, open, readFile, rename, unlink } from 'node:fs/promises'
19
+ import { dirname, resolve } from 'node:path'
13
20
 
14
21
  import type { BackgroundJobRecord, PersistenceAdapter } from './background-job-board.ts'
22
+ import { salvageRecords } from './file-persistence-salvage.ts'
23
+ import { createPantheonLogger } from './logger.ts'
24
+
25
+ export { salvageRecords } from './file-persistence-salvage.ts'
26
+
27
+ const log = createPantheonLogger({ module: 'FilePersistence' })
15
28
 
16
29
  /** Default path for the state file. */
17
30
  const DEFAULT_STATE_PATH = '.pantheon/board/state.json'
18
31
 
32
+ /** Max rename attempts after an ENOENT before giving up (dir/tmp recreated each try). */
33
+ const RENAME_MAX_ATTEMPTS = 3
34
+
35
+ /** Minimal file handle surface used to fsync the temp file before rename. */
36
+ export interface PersistenceFileHandle {
37
+ writeFile(data: string, encoding: 'utf-8'): Promise<void>
38
+ sync(): Promise<void>
39
+ close(): Promise<void>
40
+ }
41
+
42
+ /**
43
+ * Filesystem operations used by the adapter. Injectable so tests can fault
44
+ * inject a failing `rename` (ENOENT) deterministically.
45
+ */
46
+ export interface PersistenceFsOps {
47
+ mkdir(dir: string): Promise<void>
48
+ readFile(path: string, encoding: 'utf-8'): Promise<string>
49
+ rename(from: string, to: string): Promise<void>
50
+ openForWrite(path: string): Promise<PersistenceFileHandle>
51
+ unlink(path: string): Promise<void>
52
+ }
53
+
54
+ /** Real Node.js filesystem implementation (the default). */
55
+ const defaultFsOps: PersistenceFsOps = {
56
+ mkdir: async (dir) => {
57
+ await mkdir(dir, { recursive: true })
58
+ },
59
+ readFile: (path, encoding) => readFile(path, encoding),
60
+ rename: (from, to) => rename(from, to),
61
+ openForWrite: (path) => open(path, 'w'),
62
+ unlink: (path) => unlink(path),
63
+ }
64
+
65
+ function isEnoent(err: unknown): boolean {
66
+ return err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT'
67
+ }
68
+
69
+ /**
70
+ * In-process write queues keyed by absolute state path. Shared across
71
+ * adapter INSTANCES so a plugin adapter and a native adapter pointing at the
72
+ * same file serialize their read-modify-write cycles.
73
+ */
74
+ const writeQueues = new Map<string, Promise<unknown>>()
75
+
76
+ function enqueueWrite<T>(key: string, task: () => Promise<T>): Promise<T> {
77
+ const previous = writeQueues.get(key) ?? Promise.resolve()
78
+ const run = previous.then(task, task)
79
+ writeQueues.set(
80
+ key,
81
+ run.then(
82
+ () => undefined,
83
+ () => undefined,
84
+ ),
85
+ )
86
+ return run
87
+ }
88
+
19
89
  /**
20
90
  * File-based persistence adapter.
21
91
  *
22
- * All jobs are stored in a single JSON array in `statePath`.
23
- * Writes are atomic: content is written to a `.tmp` file, then renamed.
24
- *
25
- * The board's in-memory `this.jobs` Map is the source of truth —
26
- * this file is a crash-recovery snapshot. `deleteJob()` removes the
27
- * record from the snapshot so pruned/reconciled jobs don't resurface
28
- * after a restart.
92
+ * All jobs are stored in a single JSON array in `statePath`. Writes are
93
+ * atomic and serialized (unique tmp + fsync + rename, with ENOENT retry).
94
+ * Corrupt state is backed up and salvaged instead of throwing.
29
95
  */
30
96
  export class FilePersistenceAdapter implements PersistenceAdapter {
31
97
  private readonly statePath: string
32
98
  private readonly dir: string
99
+ private readonly ops: PersistenceFsOps
100
+ private readonly lockKey: string
101
+ private tmpCounter = 0
33
102
 
34
103
  /**
35
104
  * @param statePath Path to the state JSON file (default: `.pantheon/board/state.json`)
105
+ * @param ops Filesystem operations (defaults to real Node.js fs; injectable for tests)
36
106
  */
37
- constructor(statePath?: string) {
107
+ constructor(statePath?: string, ops: PersistenceFsOps = defaultFsOps) {
38
108
  this.statePath = statePath ?? DEFAULT_STATE_PATH
39
109
  this.dir = dirname(this.statePath)
110
+ this.ops = ops
111
+ this.lockKey = resolve(this.statePath)
40
112
  }
41
113
 
42
114
  /** Ensure the parent directory exists. */
43
115
  private async ensureDir(): Promise<void> {
44
- await mkdir(this.dir, { recursive: true })
116
+ await this.ops.mkdir(this.dir)
45
117
  }
46
118
 
47
- /** Path to the atomic-write temp file. */
48
- private get tmpPath(): string {
49
- return this.statePath + '.tmp'
119
+ /** Unique temp path per write — prevents two writers racing the same tmp. */
120
+ private makeTmpPath(): string {
121
+ this.tmpCounter += 1
122
+ const unique = `${process.pid}.${this.tmpCounter}.${Math.random().toString(36).slice(2, 8)}`
123
+ return `${this.statePath}.${unique}.tmp`
50
124
  }
51
125
 
52
126
  /**
53
127
  * Read all jobs from the state file.
54
- * Returns `null` if the file doesn't exist (first run / no jobs yet).
128
+ *
129
+ * Return contract distinguishes three failure modes:
130
+ * - absent file (ENOENT) → `[]` (first run / no jobs yet);
131
+ * - corrupt content → quarantine + `salvageRecords()` (best-effort recovery);
132
+ * - non-ENOENT IO error (e.g. EIO) → `null`, signalling the read failed.
133
+ *
134
+ * Callers doing read-modify-write MUST abort on `null` so a failed read
135
+ * cannot overwrite the on-disk state with a partial snapshot.
55
136
  */
56
137
  private async readState(): Promise<BackgroundJobRecord[] | null> {
138
+ let content: string
57
139
  try {
58
- const content = await readFile(this.statePath, 'utf-8')
59
- return JSON.parse(content) as BackgroundJobRecord[]
140
+ content = await this.ops.readFile(this.statePath, 'utf-8')
60
141
  } catch (err: unknown) {
61
- if (
62
- err instanceof Error &&
63
- 'code' in err &&
64
- (err as NodeJS.ErrnoException).code === 'ENOENT'
65
- ) {
66
- return null
142
+ if (isEnoent(err)) return []
143
+ log.error(`[FilePersistence] Failed to read ${this.statePath}:`, err)
144
+ return null
145
+ }
146
+
147
+ try {
148
+ const parsed: unknown = JSON.parse(content)
149
+ if (!Array.isArray(parsed)) {
150
+ throw new Error('state.json is not a JSON array')
67
151
  }
68
- throw err
152
+ return parsed as BackgroundJobRecord[]
153
+ } catch (err: unknown) {
154
+ await this.quarantineCorrupt(err)
155
+ const salvaged = salvageRecords(content)
156
+ log.warn(
157
+ `[FilePersistence] Corrupt ${this.statePath} — recovered ${salvaged.length} record(s)`,
158
+ )
159
+ return salvaged
160
+ }
161
+ }
162
+
163
+ /** Move a corrupt state.json aside so the next write starts from a clean file. */
164
+ private async quarantineCorrupt(cause: unknown): Promise<void> {
165
+ const backupPath = `${this.statePath}.corrupt-${Date.now()}`
166
+ try {
167
+ await this.ops.rename(this.statePath, backupPath)
168
+ log.error(`[FilePersistence] Corrupt ${this.statePath} backed up to ${backupPath}:`, cause)
169
+ } catch (err: unknown) {
170
+ log.error(
171
+ `[FilePersistence] Could not back up corrupt ${this.statePath} — continuing with salvaged state:`,
172
+ err,
173
+ )
69
174
  }
70
175
  }
71
176
 
72
- /** Write the full state array atomically (tmp + rename). */
177
+ /** Write the full state array: unique tmp + fsync + rename (with ENOENT retry). */
73
178
  private async writeState(records: BackgroundJobRecord[]): Promise<void> {
74
- await this.ensureDir()
75
179
  const content = JSON.stringify(records, null, 2)
76
- await writeFile(this.tmpPath, content, 'utf-8')
77
- await rename(this.tmpPath, this.statePath)
180
+ const tmpPath = this.makeTmpPath()
181
+ let lastError: unknown
182
+
183
+ for (let attempt = 1; attempt <= RENAME_MAX_ATTEMPTS; attempt++) {
184
+ try {
185
+ await this.ensureDir()
186
+ const handle = await this.ops.openForWrite(tmpPath)
187
+ try {
188
+ await handle.writeFile(content, 'utf-8')
189
+ await handle.sync()
190
+ } finally {
191
+ await handle.close()
192
+ }
193
+ await this.ops.rename(tmpPath, this.statePath)
194
+ return
195
+ } catch (err: unknown) {
196
+ lastError = err
197
+ if (!isEnoent(err)) break
198
+ log.warn(
199
+ `[FilePersistence] rename ENOENT for ${this.statePath} ` +
200
+ `(attempt ${attempt}/${RENAME_MAX_ATTEMPTS}) — recreating dir and retrying`,
201
+ )
202
+ await this.safeUnlink(tmpPath)
203
+ }
204
+ }
205
+
206
+ await this.safeUnlink(tmpPath)
207
+ throw lastError instanceof Error ? lastError : new Error(String(lastError))
78
208
  }
79
209
 
80
- async saveJob(record: BackgroundJobRecord): Promise<void> {
81
- const records = (await this.readState()) ?? []
82
- const idx = records.findIndex((r) => r.taskID === record.taskID)
83
- if (idx >= 0) {
84
- records[idx] = record
85
- } else {
86
- records.push(record)
210
+ private async safeUnlink(path: string): Promise<void> {
211
+ try {
212
+ await this.ops.unlink(path)
213
+ } catch {
214
+ // Best-effort temp cleanup — absent file is fine.
87
215
  }
88
- await this.writeState(records)
216
+ }
217
+
218
+ async saveJob(record: BackgroundJobRecord): Promise<void> {
219
+ await enqueueWrite(this.lockKey, async () => {
220
+ const records = await this.readState()
221
+ if (records === null) {
222
+ log.error(
223
+ `[FilePersistence] Aborting saveJob(${record.taskID}): could not read ` +
224
+ `${this.statePath} — refusing to overwrite state with a partial snapshot`,
225
+ )
226
+ return
227
+ }
228
+ const idx = records.findIndex((r) => r.taskID === record.taskID)
229
+ if (idx >= 0) {
230
+ records[idx] = record
231
+ } else {
232
+ records.push(record)
233
+ }
234
+ await this.writeState(records)
235
+ })
89
236
  }
90
237
 
91
238
  async loadAllJobs(): Promise<BackgroundJobRecord[]> {
@@ -93,11 +240,13 @@ export class FilePersistenceAdapter implements PersistenceAdapter {
93
240
  }
94
241
 
95
242
  async deleteJob(taskID: string): Promise<void> {
96
- const records = await this.readState()
97
- if (!records) return
98
- const idx = records.findIndex((r) => r.taskID === taskID)
99
- if (idx < 0) return
100
- records.splice(idx, 1)
101
- await this.writeState(records)
243
+ await enqueueWrite(this.lockKey, async () => {
244
+ const records = await this.readState()
245
+ if (!records) return
246
+ const idx = records.findIndex((r) => r.taskID === taskID)
247
+ if (idx < 0) return
248
+ records.splice(idx, 1)
249
+ await this.writeState(records)
250
+ })
102
251
  }
103
252
  }
@@ -171,7 +171,7 @@ type LiveDelegationEntry = {
171
171
  partID: string;
172
172
  /** Parent session the delegation was launched from. */
173
173
  sessionID: string;
174
- tool: 'pantheon_delegate' | 'pantheon_delegation_read';
174
+ tool: 'pantheon_delegate' | 'pantheon_delegation_read' | 'task';
175
175
  /** Agent name (from the delegate input args). */
176
176
  agent: string;
177
177
  description: string;
@@ -190,7 +190,7 @@ type ParsedDelegationToolPart = {
190
190
  callID: string;
191
191
  partID: string;
192
192
  sessionID: string;
193
- tool: 'pantheon_delegate' | 'pantheon_delegation_read';
193
+ tool: 'pantheon_delegate' | 'pantheon_delegation_read' | 'task';
194
194
  /** null for read parts (no agent arg — the id targets an existing job). */
195
195
  agent: string | null;
196
196
  description: string;
@@ -202,7 +202,9 @@ type ParsedDelegationToolPart = {
202
202
  };
203
203
  /** Extract the tool name + args from a `message.part.updated` part and
204
204
  * reduce it to what the panel needs. Returns null for anything that is
205
- * not a pantheon delegation tool part (or is missing its callID). */
205
+ * not a pantheon delegation tool part, the native `task` subagent tool
206
+ * (same parentID === caller mechanism — its children render `[native]`),
207
+ * or is missing its callID. */
206
208
  declare function parseDelegationToolPart(part: DelegationToolPart, now?: number): ParsedDelegationToolPart | null;
207
209
  /** Apply one tool part to the live map. Returns true when the map changed.
208
210
  * Pure w.r.t. I/O — only mutates `map`. */
@@ -210,11 +212,14 @@ declare function reduceDelegationToolPart(map: Map<string, LiveDelegationEntry>,
210
212
  /** Remove a live entry by part id (message.part.removed) or call id.
211
213
  * Returns true when something was removed. */
212
214
  declare function removeDelegationEntry(map: Map<string, LiveDelegationEntry>, partIDOrCallID: string): boolean;
213
- /** Collect pantheon delegation tool parts from a session's messages.
215
+ /** Collect pantheon delegation + native task tool parts from a session's messages.
214
216
  * Messages may carry their parts inline (duck-typed `msg.parts`); when
215
217
  * they don't, the optional `getParts(messageID)` callback is used (the TUI
216
- * SDK exposes `api.state.part(messageID)`). Pure w.r.t. I/O — used by the
217
- * mount re-scan to re-seed the live map after compaction/attach. */
218
+ * SDK exposes `api.state.part(messageID)`). The native `task` tool spawns a
219
+ * child session with parentID = caller — the same mechanism as
220
+ * pantheon_delegate — so its parts feed the live-map as the native signal
221
+ * (rows render `[native]` via the children channel). Pure w.r.t. I/O — used
222
+ * by the mount re-scan to re-seed the live map after compaction/attach. */
218
223
  declare function collectDelegationToolParts(messages: readonly {
219
224
  id?: string;
220
225
  parts?: unknown[];
@@ -318,6 +323,18 @@ declare function childStatusToState(status: string | undefined): 'running' | 'co
318
323
  * panel renders the tag with a distinct style so native task() children are
319
324
  * visually separable from pantheon_delegate jobs. */
320
325
  declare function delegationTag(entry: DelegationEntry): string;
326
+ /** Split a display list into native task() rows vs pantheon_delegate rows.
327
+ * Native = source 'children-only' (no board report); everything else counts
328
+ * as pantheon. Pure — powers the header breakdown + the hooks.log line. */
329
+ declare function countDelegationSources(entries: readonly DelegationEntry[]): {
330
+ native: number;
331
+ pantheon: number;
332
+ total: number;
333
+ };
334
+ /** Diagnostic hooks.log line for a panel re-fetch, with the children
335
+ * breakdown (pantheon = children WITH a board report, native = children
336
+ * WITHOUT one). Pure — the View logs the returned string verbatim. */
337
+ declare function formatPanelLogLine(children: number, pantheon: number, native: number, md: number, events: number): string;
321
338
  /** Turn child sessions (PRIMARY) enriched with md reports into the display
322
339
  * list. One entry per child id (duplicates across re-fetches collapse).
323
340
  * The md report is matched by `Task ID` (== child.id) and supplies alias,
@@ -326,10 +343,14 @@ declare function delegationTag(entry: DelegationEntry): string;
326
343
  * itself (fallback 'agent'), state derived from its status, startedAt from
327
344
  * time.created. A report-less child is a NATIVE task() child (every
328
345
  * child of the current session — pantheon_delegate OR the native `task()`
329
- * tool — carries parentID = caller), so it gets source 'children-only'
330
- * and the `[native]` tag instead of a board alias.
346
+ * tool — carries parentID = caller), so it gets source 'children-only',
347
+ * the internal alias 'native-task' and the `[native]` tag instead of a
348
+ * board alias. The 'task nativa' description fallback keeps the row
349
+ * non-empty when the child carries no title.
331
350
  * Terminal md state wins over the derived state; a running md defers to
332
- * the child's live status. Sorted running-first (compareDelegationEntries).
351
+ * the child's live status. A running child is NEVER archived — it always
352
+ * lands in the active split (splitDelegationList active = running/retry).
353
+ * Sorted running-first (compareDelegationEntries).
333
354
  * Pure — no I/O. */
334
355
  declare function childrenToDelegationEntries(children: readonly ChildDelegationLike[] | undefined, md: readonly DelegationEntry[], now?: number): DelegationEntry[];
335
356
  /** Navigate the TUI to a child session (click/Enter on a delegation row).
@@ -356,5 +377,5 @@ declare const plugin: TuiPluginModule & {
356
377
  setup: () => Promise<void>;
357
378
  };
358
379
  //#endregion
359
- export { ChildDelegationLike, DelegationActivity, DelegationEntry, DelegationToolPart, IDLE_SILENCE_MS, LiveDelegationEntry, LiveDelegationStore, ParsedDelegationToolPart, STALE_RUNNING_THRESHOLD_MS, ToolActivity, TuiSessionSources, buildChildrenPath, childStatusToState, childrenToDelegationEntries, collectDelegationToolParts, compareDelegationEntries, plugin as default, delegationActivity, delegationActivityLabel, delegationElapsed, delegationSpinnerFrame, delegationTag, extractToolActivity, fmtElapsed, isValidSessionId, latestToolActivityFor, markStaleIfRunning, mergeChildDelegationSources, mergeDelegationSources, navigateToDelegationSession, panelLogDir, parseDelegationMarkdown, parseDelegationToolPart, readAllDelegationEntries, readDelegationEntries, reduceDelegationToolPart, removeDelegationEntry, resolveCurrentSessionID, resolveDelegationsDir, safeSessionPath, seedLiveDelegationMap, splitDelegationList, toDelegationEntry, trackToolActivity, tuiLogPath, visibleDelegationList };
380
+ export { ChildDelegationLike, DelegationActivity, DelegationEntry, DelegationToolPart, IDLE_SILENCE_MS, LiveDelegationEntry, LiveDelegationStore, ParsedDelegationToolPart, STALE_RUNNING_THRESHOLD_MS, ToolActivity, TuiSessionSources, buildChildrenPath, childStatusToState, childrenToDelegationEntries, collectDelegationToolParts, compareDelegationEntries, countDelegationSources, plugin as default, delegationActivity, delegationActivityLabel, delegationElapsed, delegationSpinnerFrame, delegationTag, extractToolActivity, fmtElapsed, formatPanelLogLine, isValidSessionId, latestToolActivityFor, markStaleIfRunning, mergeChildDelegationSources, mergeDelegationSources, navigateToDelegationSession, panelLogDir, parseDelegationMarkdown, parseDelegationToolPart, readAllDelegationEntries, readDelegationEntries, reduceDelegationToolPart, removeDelegationEntry, resolveCurrentSessionID, resolveDelegationsDir, safeSessionPath, seedLiveDelegationMap, splitDelegationList, toDelegationEntry, trackToolActivity, tuiLogPath, visibleDelegationList };
360
381
  //# sourceMappingURL=tui.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"tui.d.ts","names":[],"sources":["../src/index.tsx"],"mappings":";;KAo7BY;;EAEV;;EAEA;;;;EAIA;;EAEA;EACA;;EAUA;;EAEA;EACA;EACA;;EAEA;;;;EAIA;;;;;;;;;;;iBAsBc,wBACd,aACA,oBACA,qBACC;;;;;;iBAkHmB,sBAAsB,cAAc,QAAQ;;;;;;;;iBAwC5C,yBAAyB,eAAe,QAAQ;;;;;;;;iBAWtD,sBACd;EAAS;EAAoB;eAC7B;;;;;;iBAYc,YAAY;;;iBAMZ,WAAW;;;;iBAOX,yBAAyB,GAAG,iBAAiB,GAAG;;;;;iBAahD,oBACd,cAAc,mBACd,oBACA,cACA;EACG,QAAQ;EAAmB,QAAQ;EAAmB,UAAU;;;;;iBAerD,sBACd,cAAc,mBACd,sBACA,cACA,4BACC;;cAMU;;;;cAKA;;;;;;;;;iBAUG,mBACd,OAAO,iBACP,aACA,uBACC;;;iBAWa,WAAW;;;iBAcX,kBAAkB,OAAO,iBAAiB;;;KAQ9C;iBAQI,mBAAmB,OAAO,kBAAkB;iBAc5C,wBAAwB,OAAO;;iBAqB/B,uBAAuB;KAY3B;EACV;EACA;EACA;;;;;iBA0Bc,oBACd;EACE;EACA;EACA;EACA;IAAU;IAAiB,QAAQ;;GAErC;EACG;EAAmB,UAAU;;;iBAYlB,kBACd,KAAK,YAAY,eACjB,mBACA,UAAU;;iBAWI,sBACd,KAAK,YAAY,eACjB,+BACA,cACA,iBACC;;;;;;;iBAca,4BACd,mBAAmB,mBACnB,eAAe,wBACd;;KAmFS;EACV;EACA;EACA;EACA;EACA;EACA;IACE;IACA,QAAQ;IACR;IACA;IACA;MAAS;MAAgB;;;;;KAKjB;;EAEV;;EAEA;;EAEA;EACA;;EAEA;EACA;;EAEA;;EAEA;EACA;EACA;EACA;;EAEA;;;KAIU;EACV;EACA;EACA;EACA;;EAEA;EACA;EACA;EACA;EACA;EACA;EACA;;;;;iBAac,wBACd,MAAM,oBACN,eACC;;;iBA+Ea,yBACd,KAAK,YAAY,sBACjB,MAAM,oBACN;;;iBA+Fc,sBACd,KAAK,YAAY,sBACjB;;;;;;iBAiBc,2BACd;EAAqB;EAAa;iBAClC,YAAY,uDACX;;;;;iBA2Ba,sBACd,KAAK,YAAY,sBACjB,gBAAgB,sBAChB;;;iBAWc,kBAAkB,MAAM,sBAAsB;;;;;;iBAqB9C,uBACd,eAAe,uBACf,aAAa,oBACZ;;;;;;;;;iBA8Da,iBAAiB,cAAc;;;;KAOnC;;EAEV;EACA;;IAEE;MAAU;;;IAEV;MAAU;QAAY;QAAe,SAAS;;;;;;;;;;iBASlC,wBAAwB,SAAS;;;;;;;;iBAmBjC,gBAAgB;EAAgB;IAAQ;;;;;;;iBASxC,kBAAkB;EAAkC;IAAQ;;;;KAKhE;;EAEV;;EAEA;;;EAGA;;;EAGA;EACA;IACE;IACA;;;;;;;iBAQY,mBAAmB;;;;;;;iBAYnB,cAAc,OAAO;;;;;;;;;;;;;;iBAiBrB,4BACd,mBAAmB,mCACnB,aAAa,mBACb,eACC;;;;;;iBA0Ca,4BACd;EAAS,YAAY,cAAc,SAAS;eAC5C;;;;;KAiMU;EACV,KAAK,YAAY;;EAEjB;;EAEA;;cA0fI,QAAQ;EAAoB;EAAY,aAAa"}
1
+ {"version":3,"file":"tui.d.ts","names":[],"sources":["../src/index.tsx"],"mappings":";;KAu7BY;;EAEV;;EAEA;;;;EAIA;;EAEA;EACA;;EAUA;;EAEA;EACA;EACA;;EAEA;;;;EAIA;;;;;;;;;;;iBAsBc,wBACd,aACA,oBACA,qBACC;;;;;;iBAkHmB,sBAAsB,cAAc,QAAQ;;;;;;;;iBAwC5C,yBAAyB,eAAe,QAAQ;;;;;;;;iBAWtD,sBACd;EAAS;EAAoB;eAC7B;;;;;;iBAYc,YAAY;;;iBAMZ,WAAW;;;;iBAOX,yBAAyB,GAAG,iBAAiB,GAAG;;;;;iBAahD,oBACd,cAAc,mBACd,oBACA,cACA;EACG,QAAQ;EAAmB,QAAQ;EAAmB,UAAU;;;;;iBAerD,sBACd,cAAc,mBACd,sBACA,cACA,4BACC;;cAMU;;;;cAKA;;;;;;;;;iBAUG,mBACd,OAAO,iBACP,aACA,uBACC;;;iBAWa,WAAW;;;iBAcX,kBAAkB,OAAO,iBAAiB;;;KAQ9C;iBAQI,mBAAmB,OAAO,kBAAkB;iBAc5C,wBAAwB,OAAO;;iBAqB/B,uBAAuB;KAY3B;EACV;EACA;EACA;;;;;iBA0Bc,oBACd;EACE;EACA;EACA;EACA;IAAU;IAAiB,QAAQ;;GAErC;EACG;EAAmB,UAAU;;;iBAYlB,kBACd,KAAK,YAAY,eACjB,mBACA,UAAU;;iBAWI,sBACd,KAAK,YAAY,eACjB,+BACA,cACA,iBACC;;;;;;;iBAca,4BACd,mBAAmB,mBACnB,eAAe,wBACd;;KAqFS;EACV;EACA;EACA;EACA;EACA;EACA;IACE;IACA,QAAQ;IACR;IACA;IACA;MAAS;MAAgB;;;;;KAKjB;;EAEV;;EAEA;;EAEA;EACA;;EAEA;EACA;;EAEA;;EAEA;EACA;EACA;EACA;;EAEA;;;KAIU;EACV;EACA;EACA;EACA;;EAEA;EACA;EACA;EACA;EACA;EACA;EACA;;;;;;;iBAec,wBACd,MAAM,oBACN,eACC;;;iBAgGa,yBACd,KAAK,YAAY,sBACjB,MAAM,oBACN;;;iBAiGc,sBACd,KAAK,YAAY,sBACjB;;;;;;;;;iBAoBc,2BACd;EAAqB;EAAa;iBAClC,YAAY,uDACX;;;;;iBA6Ba,sBACd,KAAK,YAAY,sBACjB,gBAAgB,sBAChB;;;iBAWc,kBAAkB,MAAM,sBAAsB;;;;;;iBAqB9C,uBACd,eAAe,uBACf,aAAa,oBACZ;;;;;;;;;iBA8Da,iBAAiB,cAAc;;;;KAOnC;;EAEV;EACA;;IAEE;MAAU;;;IAEV;MAAU;QAAY;QAAe,SAAS;;;;;;;;;;iBASlC,wBAAwB,SAAS;;;;;;;;iBAmBjC,gBAAgB;EAAgB;IAAQ;;;;;;;iBASxC,kBAAkB;EAAkC;IAAQ;;;;KAKhE;;EAEV;;EAEA;;;EAGA;;;EAGA;EACA;IACE;IACA;;;;;;;iBAQY,mBAAmB;;;;;;;iBAYnB,cAAc,OAAO;;;;iBAOrB,uBAAuB,kBAAkB;EACvD;EACA;EACA;;;;;iBAUc,mBACd,kBACA,kBACA,gBACA,YACA;;;;;;;;;;;;;;;;;;iBAsBc,4BACd,mBAAmB,mCACnB,aAAa,mBACb,eACC;;;;;;iBA4Ca,4BACd;EAAS,YAAY,cAAc,SAAS;eAC5C;;;;;KAiMU;EACV,KAAK,YAAY;;EAEjB;;EAEA;;cAugBI,QAAQ;EAAoB;EAAY,aAAa"}
@@ -164,7 +164,7 @@ async function detectVersion(api) {
164
164
  if (ver) return ver;
165
165
  }
166
166
  } catch {}
167
- return "1.5.0-beta.7";
167
+ return "1.5.0-beta.9";
168
168
  }
169
169
  /**
170
170
  * usage-bar — AI subscription usage gauge for the opencode TUI.
@@ -1095,10 +1095,12 @@ const DELEGATE_TASKID_PATTERN = /\(task\s+([a-z0-9_]+)\)/i;
1095
1095
  const READ_ALIAS_PATTERN = /^[a-z]{2,8}-\d+$/i;
1096
1096
  /** Extract the tool name + args from a `message.part.updated` part and
1097
1097
  * reduce it to what the panel needs. Returns null for anything that is
1098
- * not a pantheon delegation tool part (or is missing its callID). */
1098
+ * not a pantheon delegation tool part, the native `task` subagent tool
1099
+ * (same parentID === caller mechanism — its children render `[native]`),
1100
+ * or is missing its callID. */
1099
1101
  function parseDelegationToolPart(part, now = Date.now()) {
1100
1102
  if (part.type !== "tool") return null;
1101
- if (part.tool !== "pantheon_delegate" && part.tool !== "pantheon_delegation_read") return null;
1103
+ if (part.tool !== "pantheon_delegate" && part.tool !== "pantheon_delegation_read" && part.tool !== "task") return null;
1102
1104
  const callID = part.callID;
1103
1105
  if (callID === void 0 || callID === "") return null;
1104
1106
  const sessionID = part.sessionID ?? "";
@@ -1130,8 +1132,8 @@ function parseDelegationToolPart(part, now = Date.now()) {
1130
1132
  endAt
1131
1133
  };
1132
1134
  }
1133
- const agent = typeof input.agent === "string" ? input.agent : "agent";
1134
- const description = typeof input.description === "string" ? input.description : "";
1135
+ const agent = typeof input.agent === "string" ? input.agent : typeof input.subagent_type === "string" ? input.subagent_type : "agent";
1136
+ const description = typeof input.description === "string" ? input.description : typeof input.prompt === "string" ? input.prompt.slice(0, 120) : "";
1135
1137
  let alias = null;
1136
1138
  let taskID = null;
1137
1139
  if (status === "completed") {
@@ -1143,7 +1145,7 @@ function parseDelegationToolPart(part, now = Date.now()) {
1143
1145
  callID,
1144
1146
  partID: part.id ?? "",
1145
1147
  sessionID,
1146
- tool: "pantheon_delegate",
1148
+ tool: part.tool === "task" ? "task" : "pantheon_delegate",
1147
1149
  agent,
1148
1150
  description,
1149
1151
  status,
@@ -1192,7 +1194,7 @@ function reduceDelegationToolPart(map, part, now = Date.now()) {
1192
1194
  callID: parsed.callID,
1193
1195
  partID: parsed.partID,
1194
1196
  sessionID: parsed.sessionID,
1195
- tool: "pantheon_delegate",
1197
+ tool: parsed.tool,
1196
1198
  agent: parsed.agent ?? "agent",
1197
1199
  description: parsed.description,
1198
1200
  alias: existing?.alias ?? null,
@@ -1209,7 +1211,7 @@ function reduceDelegationToolPart(map, part, now = Date.now()) {
1209
1211
  callID: parsed.callID,
1210
1212
  partID: parsed.partID,
1211
1213
  sessionID: parsed.sessionID,
1212
- tool: "pantheon_delegate",
1214
+ tool: parsed.tool,
1213
1215
  agent: parsed.agent ?? "agent",
1214
1216
  description: parsed.description,
1215
1217
  alias: parsed.alias,
@@ -1254,11 +1256,14 @@ function removeDelegationEntry(map, partIDOrCallID) {
1254
1256
  }
1255
1257
  return false;
1256
1258
  }
1257
- /** Collect pantheon delegation tool parts from a session's messages.
1259
+ /** Collect pantheon delegation + native task tool parts from a session's messages.
1258
1260
  * Messages may carry their parts inline (duck-typed `msg.parts`); when
1259
1261
  * they don't, the optional `getParts(messageID)` callback is used (the TUI
1260
- * SDK exposes `api.state.part(messageID)`). Pure w.r.t. I/O — used by the
1261
- * mount re-scan to re-seed the live map after compaction/attach. */
1262
+ * SDK exposes `api.state.part(messageID)`). The native `task` tool spawns a
1263
+ * child session with parentID = caller — the same mechanism as
1264
+ * pantheon_delegate — so its parts feed the live-map as the native signal
1265
+ * (rows render `[native]` via the children channel). Pure w.r.t. I/O — used
1266
+ * by the mount re-scan to re-seed the live map after compaction/attach. */
1262
1267
  function collectDelegationToolParts(messages, getParts) {
1263
1268
  const out = [];
1264
1269
  for (const msg of messages ?? []) {
@@ -1268,7 +1273,7 @@ function collectDelegationToolParts(messages, getParts) {
1268
1273
  if (!parts) continue;
1269
1274
  for (const raw of parts) {
1270
1275
  const part = raw;
1271
- if (part?.type === "tool" && (part.tool === "pantheon_delegate" || part.tool === "pantheon_delegation_read")) out.push(part);
1276
+ if (part?.type === "tool" && (part.tool === "pantheon_delegate" || part.tool === "pantheon_delegation_read" || part.tool === "task")) out.push(part);
1272
1277
  }
1273
1278
  }
1274
1279
  return out;
@@ -1393,6 +1398,24 @@ function childStatusToState(status) {
1393
1398
  function delegationTag(entry) {
1394
1399
  return entry.source === "children-only" ? "[native]" : `[pantheon:${entry.alias}]`;
1395
1400
  }
1401
+ /** Split a display list into native task() rows vs pantheon_delegate rows.
1402
+ * Native = source 'children-only' (no board report); everything else counts
1403
+ * as pantheon. Pure — powers the header breakdown + the hooks.log line. */
1404
+ function countDelegationSources(entries) {
1405
+ let native = 0;
1406
+ for (const e of entries) if (e.source === "children-only") native++;
1407
+ return {
1408
+ native,
1409
+ pantheon: entries.length - native,
1410
+ total: entries.length
1411
+ };
1412
+ }
1413
+ /** Diagnostic hooks.log line for a panel re-fetch, with the children
1414
+ * breakdown (pantheon = children WITH a board report, native = children
1415
+ * WITHOUT one). Pure — the View logs the returned string verbatim. */
1416
+ function formatPanelLogLine(children, pantheon, native, md, events) {
1417
+ return `panel: children=${children}(pantheon=${pantheon} native=${native}) md=${md} events=${events}`;
1418
+ }
1396
1419
  /** Turn child sessions (PRIMARY) enriched with md reports into the display
1397
1420
  * list. One entry per child id (duplicates across re-fetches collapse).
1398
1421
  * The md report is matched by `Task ID` (== child.id) and supplies alias,
@@ -1401,10 +1424,14 @@ function delegationTag(entry) {
1401
1424
  * itself (fallback 'agent'), state derived from its status, startedAt from
1402
1425
  * time.created. A report-less child is a NATIVE task() child (every
1403
1426
  * child of the current session — pantheon_delegate OR the native `task()`
1404
- * tool — carries parentID = caller), so it gets source 'children-only'
1405
- * and the `[native]` tag instead of a board alias.
1427
+ * tool — carries parentID = caller), so it gets source 'children-only',
1428
+ * the internal alias 'native-task' and the `[native]` tag instead of a
1429
+ * board alias. The 'task nativa' description fallback keeps the row
1430
+ * non-empty when the child carries no title.
1406
1431
  * Terminal md state wins over the derived state; a running md defers to
1407
- * the child's live status. Sorted running-first (compareDelegationEntries).
1432
+ * the child's live status. A running child is NEVER archived — it always
1433
+ * lands in the active split (splitDelegationList active = running/retry).
1434
+ * Sorted running-first (compareDelegationEntries).
1408
1435
  * Pure — no I/O. */
1409
1436
  function childrenToDelegationEntries(children, md, now = Date.now()) {
1410
1437
  const byTaskID = /* @__PURE__ */ new Map();
@@ -1417,7 +1444,7 @@ function childrenToDelegationEntries(children, md, now = Date.now()) {
1417
1444
  const mdEntry = byTaskID.get(child.id);
1418
1445
  const state = mdEntry !== void 0 && mdEntry.state !== "running" ? mdEntry.state : childStatusToState(child.status);
1419
1446
  out.push({
1420
- alias: mdEntry?.alias ?? "task",
1447
+ alias: mdEntry?.alias ?? "native-task",
1421
1448
  sessionID: mdEntry?.sessionID ?? "",
1422
1449
  taskID: child.id,
1423
1450
  agent: mdEntry?.agent ?? child.agent ?? "agent",
@@ -1425,7 +1452,7 @@ function childrenToDelegationEntries(children, md, now = Date.now()) {
1425
1452
  startedAt: mdEntry?.startedAt ?? child.time?.created ?? now,
1426
1453
  updatedAt: mdEntry?.updatedAt ?? (state === "running" || state === "stale-running" ? null : child.time?.updated ?? null),
1427
1454
  timedOut: mdEntry?.timedOut ?? false,
1428
- description: mdEntry !== void 0 && mdEntry.description !== "" ? mdEntry.description : child.title ?? "",
1455
+ description: mdEntry !== void 0 && mdEntry.description !== "" ? mdEntry.description : child.title !== void 0 && child.title !== "" ? child.title : "task nativa",
1429
1456
  source: mdEntry !== void 0 ? "md" : "children-only"
1430
1457
  });
1431
1458
  }
@@ -1549,7 +1576,7 @@ function DelegationRow(props) {
1549
1576
  },
1550
1577
  children: (a) => (() => {
1551
1578
  var _el$19 = createElement("text");
1552
- insert(_el$19, () => ` \u21b3 ${a().tool}${a().summary !== "" ? " " + a().summary : ""}`);
1579
+ insert(_el$19, () => ` \u21b3 ${a().tool}${a().summary !== "" ? ` ${a().summary}` : ""}`);
1553
1580
  effect((_$p) => setProp(_el$19, "fg", theme().textMuted, _$p));
1554
1581
  return _el$19;
1555
1582
  })()
@@ -1647,7 +1674,7 @@ function View(props) {
1647
1674
  });
1648
1675
  if (sessionID === null) {
1649
1676
  setChildDelegations(md);
1650
- panelLog.info(`panel: children=0 md=${md.length} events=${eventRefreshCount} (no sessionID — history shown)`);
1677
+ panelLog.info(`${formatPanelLogLine(0, 0, 0, md.length, eventRefreshCount)} (no sessionID — history shown)`);
1651
1678
  return;
1652
1679
  }
1653
1680
  let children = [];
@@ -1674,7 +1701,8 @@ function View(props) {
1674
1701
  for (const [k, v] of props.liveStore.map) if (v.alias === null && v.taskID === null && Date.now() - v.startedAt > 3e4) props.liveStore.map.delete(k);
1675
1702
  const liveEntries = [...props.liveStore.map.values()].filter((entry) => entry.sessionID === sessionID);
1676
1703
  setChildDelegations(mergeChildDelegationSources(childEntries, liveEntries));
1677
- panelLog.info(`panel: children=${children.length} md=${md.length} events=${eventRefreshCount}`);
1704
+ const counts = countDelegationSources(childEntries);
1705
+ panelLog.info(formatPanelLogLine(children.length, counts.pantheon, counts.native, md.length, eventRefreshCount));
1678
1706
  } finally {
1679
1707
  delegationsInflight = null;
1680
1708
  }
@@ -1683,6 +1711,7 @@ function View(props) {
1683
1711
  };
1684
1712
  const delegationSplit = createMemo(() => splitDelegationList(childDelegations()));
1685
1713
  const visibleDelegations = createMemo(() => [...delegationSplit().active, ...delegationSplit().recent]);
1714
+ const delegationCounts = createMemo(() => countDelegationSources(visibleDelegations()));
1686
1715
  const [archivedPage, setArchivedPage] = createSignal(archivedUiState.page);
1687
1716
  const clampArchivedPage = (page, pages) => {
1688
1717
  const clamped = Math.max(0, Math.min(page, Math.max(0, pages - 1)));
@@ -1852,7 +1881,7 @@ function View(props) {
1852
1881
  setProp(_el$28, "onMouseDown", () => setShowDelegations((x) => !x));
1853
1882
  setProp(_el$29, "attributes", 1);
1854
1883
  insert(_el$29, () => `${showDelegations() ? "▼" : "▶"} Delegations`);
1855
- insert(_el$30, () => ` (${String(visibleDelegations().length)})`);
1884
+ insert(_el$30, () => ` (${String(delegationCounts().native)} native + ${String(delegationCounts().pantheon)} pantheon)`);
1856
1885
  insert(_el$22, createComponent(Show, {
1857
1886
  get when() {
1858
1887
  return showDelegations();
@@ -2033,6 +2062,6 @@ const plugin = {
2033
2062
  setup: async () => {}
2034
2063
  };
2035
2064
  //#endregion
2036
- export { IDLE_SILENCE_MS, STALE_RUNNING_THRESHOLD_MS, buildChildrenPath, childStatusToState, childrenToDelegationEntries, collectDelegationToolParts, compareDelegationEntries, plugin as default, delegationActivity, delegationActivityLabel, delegationElapsed, delegationSpinnerFrame, delegationTag, extractToolActivity, fmtElapsed, isValidSessionId, latestToolActivityFor, markStaleIfRunning, mergeChildDelegationSources, mergeDelegationSources, navigateToDelegationSession, panelLogDir, parseDelegationMarkdown, parseDelegationToolPart, readAllDelegationEntries, readDelegationEntries, reduceDelegationToolPart, removeDelegationEntry, resolveCurrentSessionID, resolveDelegationsDir, safeSessionPath, seedLiveDelegationMap, splitDelegationList, toDelegationEntry, trackToolActivity, tuiLogPath, visibleDelegationList };
2065
+ export { IDLE_SILENCE_MS, STALE_RUNNING_THRESHOLD_MS, buildChildrenPath, childStatusToState, childrenToDelegationEntries, collectDelegationToolParts, compareDelegationEntries, countDelegationSources, plugin as default, delegationActivity, delegationActivityLabel, delegationElapsed, delegationSpinnerFrame, delegationTag, extractToolActivity, fmtElapsed, formatPanelLogLine, isValidSessionId, latestToolActivityFor, markStaleIfRunning, mergeChildDelegationSources, mergeDelegationSources, navigateToDelegationSession, panelLogDir, parseDelegationMarkdown, parseDelegationToolPart, readAllDelegationEntries, readDelegationEntries, reduceDelegationToolPart, removeDelegationEntry, resolveCurrentSessionID, resolveDelegationsDir, safeSessionPath, seedLiveDelegationMap, splitDelegationList, toDelegationEntry, trackToolActivity, tuiLogPath, visibleDelegationList };
2037
2066
 
2038
2067
  //# sourceMappingURL=tui.js.map