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.
@@ -79,7 +79,7 @@
79
79
  * PANTHEON_TOASTS=council → {errors, council}
80
80
  * PANTHEON_TOASTS=all → {errors, delegations, council}
81
81
  * Unknown values fall back to the default. The gate controls the TUI
82
- * display + chat-reminder fallback ONLY — every fired toast is also
82
+ * display only — every fired toast is also
83
83
  * recorded to the structured log + hooks.log (script 'toast', level
84
84
  * 'info') so the toast trail stays auditable at any setting.
85
85
  * - Severity priority (2026-08-06): errors/blockers — hook failures
@@ -96,15 +96,9 @@
96
96
  * One group buffer at a time (groups rarely overlap); a task call more
97
97
  * than OLYMPIANS_DETECT_WINDOW_MS after the previous one starts a fresh
98
98
  * group.
99
- * - session.idle flush (2026-08-06): the `event` hook additionally handles
100
- * `session.idle`. When the orchestrator goes idle, pending completion
101
- * reminders are flushed as ONE aggregated chat reminder (individual
102
- * "✅ <agent> concluiu" lines collapse into "✅ N agentes concluídos (...)")
103
- * and re-armed with a fresh timestamp — so the completion summary
104
- * survives until the next user message (delivered via chat.message) instead
105
- * of expiring after CHAT_REMINDER_TTL_MS while the user reads the reply.
106
- * Partial groups (some members completed, some never reported) and
107
- * interrupted council counters are also flushed/reset here.
99
+ * - session.idle handling (2026-08-06): the `event` hook additionally handles
100
+ * `session.idle` to reset partial aggregation state and interrupted council
101
+ * counters. Completion visibility remains in the board and TUI only.
108
102
  * - Council notifications (new category 2026-08-06): a `task` whose prompt/
109
103
  * description matches "council"/"conselho"/"synthesi" is treated as council
110
104
  * activity — "🏛️ Council: especialistas consultados" on start and
@@ -122,11 +116,8 @@
122
116
  * with wrk"). The upstream fix (server-side workspace tagging) only exists
123
117
  * in NEWER opencode versions. THEREFORE on 1.18.13 the toast path below is
124
118
  * a documented TUI no-op and agent-lifecycle feedback is surfaced via the
125
- * oh-my-openagent fallback pattern: a `chat.message` hook injects queued
126
- * signals (see pendingChatReminders / enqueueChatReminder below) as ONE
127
- * <system-reminder> text part into the next user message. Toasts stay in
128
- * place for future opencode versions that render them — upgrade opencode
129
- * for real TUI toasts.
119
+ * no transcript fallback is used. Toasts stay in place for future opencode
120
+ * versions that render them — upgrade opencode for real TUI toasts.
130
121
  * - tool.execute.after payload fix (P1-1 follow-up, 2026-08-06): the after
131
122
  * hook receives `(input, output)` where `output` = {title, output,
132
123
  * metadata} — the REAL tool result. It is now merged into the payload
@@ -185,13 +176,6 @@
185
176
  import { appendFile, mkdir } from 'node:fs/promises'
186
177
  import { join } from 'node:path'
187
178
  import type { Plugin, PluginInput } from '@opencode-ai/plugin'
188
- import type { Part } from '@opencode-ai/sdk'
189
- import {
190
- CHAT_REMINDER_TTL_MS,
191
- drainFreshChatReminders,
192
- enqueueChatReminder,
193
- pendingChatReminders,
194
- } from '../pantheon/chat-reminders.ts'
195
179
  import { pantheonPluginOnce } from '../pantheon/plugin-once.ts'
196
180
  import { type HookPayload, type HookResult, runHook } from './hook-runner.ts'
197
181
 
@@ -231,7 +215,7 @@ type ToastCategory = 'errors' | 'delegations' | 'council'
231
215
  * of enabled categories; every notification declares its category and is
232
216
  * skipped unless enabled. "off" = empty set, "all" = every category. Unknown
233
217
  * values fall back to the default. The gate controls the TUI display +
234
- * chat-reminder fallback ONLY — the structured log + hooks.log channels
218
+ * display only — the structured log + hooks.log channels
235
219
  * always write (every fired toast is recorded via reportFailure with script
236
220
  * 'toast').
237
221
  */
@@ -427,26 +411,8 @@ const BACKGROUND_DISPATCH_TEXT_RE =
427
411
  const TASK_ID_TEXT_RE = /\btask\s+id="(ses_[^"]+)"/i
428
412
 
429
413
  /**
430
- * 1.18.13 chat.message fallback buffer (oh-my-openagent pattern — see header):
431
- * opencode 1.18.13 drops tui.toast.show events (untagged), so every signal
432
- * that would fire a toast is ALSO queued here and injected into the next user
433
- * message by the 'chat.message' hook as a single <system-reminder> text part.
434
- *
435
- * The buffer itself lives in the SHARED chat-reminders.ts module: this plugin
436
- * file must export EXACTLY ONE function-valued export (legacy loader —
437
- * see the IMPORTANT note at the top), so the enqueue API cannot be exported
438
- * from here. Importing the shared module instead gives src/plugin.ts (a
439
- * separate plugin file, loaded via plain dynamic import) the SAME buffer
440
- * instance — release-134 Phase 4 (compaction-assert.ts) enqueues
441
- * post-compaction state re-assertions here and this hook delivers them.
442
- *
443
- * Bounded: at most CHAT_REMINDER_MAX entries, each expiring
444
- * CHAT_REMINDER_TTL_MS after its tool event. Consumed (and cleared) by the
445
- * chat.message hook. Full-buffer enqueues are SKIPPED, never unbounded — the
446
- * olympians/3-in-6s aggregates still cover delegation groups, matching the
447
- * "throttled toasts are skipped, never backlogged" anti-spam philosophy.
448
- * session.idle flushes the buffer into ONE fresh aggregated entry (see
449
- * flushIdleReminders) so completions survive until the next user message.
414
+ * Delegation lifecycle state is intentionally not mirrored into the transcript.
415
+ * The board/list/read tools and TUI toasts are the supported visibility channels.
450
416
  */
451
417
 
452
418
  /** Variants accepted by the opencode TUI showToast (TuiShowToastData.body.variant). */
@@ -462,11 +428,6 @@ type ToastOptions = {
462
428
  dedupeKey?: string
463
429
  /** Optional TUI toast headline (opencode showToast body.title). */
464
430
  title?: string
465
- /**
466
- * Optional text for the 1.18.13 chat.message <system-reminder> fallback.
467
- * Defaults to `message` when omitted — the reminder mirrors the toast.
468
- */
469
- reminder?: string
470
431
  }
471
432
 
472
433
  /** Logging context threaded through safeRun — provided by the plugin input. */
@@ -757,10 +718,6 @@ async function notifyToast(ctx: HookContext, opts: ToastOptions): Promise<void>
757
718
  toastShown.add(opts.dedupeKey)
758
719
  }
759
720
  if (!toastCategoryEnabled(opts.category)) return
760
- // 1.18.13 fallback: queue the same signal for the chat.message hook (the
761
- // TUI drops tui.toast.show on 1.18.13 — see header). Enqueued even if
762
- // showToast fails, so headless/no-TUI servers still surface feedback.
763
- enqueueChatReminder(opts.reminder ?? opts.message)
764
721
  await ctx.client.tui.showToast({
765
722
  body: {
766
723
  ...(opts.title !== undefined ? { title: opts.title } : {}),
@@ -907,7 +864,7 @@ async function agentFailedToast(
907
864
  ): Promise<void> {
908
865
  const raw = result?.output ?? result?.title ?? ''
909
866
  // Fix 1: the failure reason echoes tool RESULT content — mask any token so
910
- // the toast/chat-reminder/hooks.log trail never exposes a raw secret.
867
+ // the toast/hooks.log trail never exposes a raw secret.
911
868
  const reason = maskSecret(
912
869
  trim(typeof raw === 'string' && raw.trim() !== '' ? raw : 'tool failed', 120),
913
870
  )
@@ -1057,40 +1014,7 @@ function onCouncilDone(ctx: HookContext): void {
1057
1014
  }
1058
1015
  }
1059
1016
 
1060
- /**
1061
- * session.idle flush (see header): aggregate any pending chat reminders into
1062
- * ONE fresh <system-reminder> entry (individual ✅ completions collapse into
1063
- * "✅ N agentes concluídos (...)"), report partial groups whose members never
1064
- * all completed, and reset interrupted council counters. The chat.message
1065
- * hook remains the delivery path — the flushed entry has a fresh timestamp so
1066
- * it survives until the next user message instead of expiring while the user
1067
- * reads the reply.
1068
- */
1069
1017
  function flushIdleReminders(ctx: HookContext): void {
1070
- if (ENABLED_TOAST_CATEGORIES.size === 0) {
1071
- pendingChatReminders.length = 0
1072
- activeOlympians = null
1073
- return
1074
- }
1075
- const now = Date.now()
1076
- const fresh = pendingChatReminders.filter((r) => now - r.at <= CHAT_REMINDER_TTL_MS)
1077
- pendingChatReminders.length = 0
1078
-
1079
- // Partial group: some members completed but the group never finished (their
1080
- // completion was suppressed waiting for the aggregate). Report the subset.
1081
- const partialGroup = activeOlympians
1082
- if (partialGroup !== null) {
1083
- const done = [...partialGroup.agents].filter((a) => !partialGroup.pendingCompletions.has(a))
1084
- if (partialGroup.detected && partialGroup.doneCount > 0 && done.length > 0) {
1085
- fresh.push({
1086
- text:
1087
- done.length === 1
1088
- ? `✅ ${done[0]} concluiu`
1089
- : `✅ ${done.length} agentes concluídos (${done.sort().join(', ')})`,
1090
- at: now,
1091
- })
1092
- }
1093
- }
1094
1018
  activeOlympians = null
1095
1019
 
1096
1020
  // Interrupted council counters must not block a later council's verdict —
@@ -1105,34 +1029,12 @@ function flushIdleReminders(ctx: HookContext): void {
1105
1029
  councilStartNotified = false
1106
1030
  }
1107
1031
 
1108
- // Collapse individual ✅ completions into one aggregate line.
1109
- const doneAgents: string[] = []
1110
- const others: string[] = []
1111
- for (const r of fresh) {
1112
- const m = /^✅ (.+?) concluiu$/.exec(r.text)
1113
- if (m?.[1] !== undefined) doneAgents.push(m[1])
1114
- else others.push(r.text)
1115
- }
1116
- const lines: string[] = []
1117
- if (doneAgents.length > 0) {
1118
- lines.push(
1119
- doneAgents.length === 1
1120
- ? `✅ ${doneAgents[0]} concluiu`
1121
- : `✅ ${doneAgents.length} agentes concluídos (${doneAgents.sort().join(', ')})`,
1122
- )
1123
- }
1124
- lines.push(...others)
1125
- if (lines.length === 0) return
1126
-
1127
- // ONE aggregated entry, fresh timestamp — chat.message delivers it next.
1128
- enqueueChatReminder(lines.join('\n'))
1129
- // 1.3.4 dedup: log a SUMMARY here (count only), never the joined body — the
1130
- // SAME lines used to be echoed twice (idle-flush with " | ", chat-reminder
1131
- // delivery with "\n"). The content is logged exactly once, at delivery.
1132
1032
  void reportFailure(
1133
1033
  ctx,
1134
- `[pantheon-hooks:idle-flush] ${fresh.length} reminder(s) flushed into the chat buffer (${lines.length} line(s) after aggregation)`,
1135
- { script: 'idle-flush', count: fresh.length, lineCount: lines.length },
1034
+ '[pantheon-hooks:idle-flush] delegation state reset',
1035
+ {
1036
+ script: 'idle-flush',
1037
+ },
1136
1038
  'info',
1137
1039
  )
1138
1040
  }
@@ -1213,7 +1115,7 @@ const plugin: Plugin = async ({ client, directory }) => {
1213
1115
  // merges the global config (npm-package plugin paths) with the project
1214
1116
  // config (repo source paths), so this module loads from TWO filesystem
1215
1117
  // paths in the same process and the factory would otherwise run twice —
1216
- // every hook (tool.execute.before/after, chat.message, event) firing
1118
+ // every lifecycle hook (tool.execute.before/after and event) firing
1217
1119
  // TWICE. The second invocation becomes a no-op (empty hooks object) so
1218
1120
  // hooks register exactly once.
1219
1121
  if (pantheonPluginOnce('pantheon:hooks')) {
@@ -1439,51 +1341,6 @@ const plugin: Plugin = async ({ client, directory }) => {
1439
1341
  // Never let a hook take down the opencode session.
1440
1342
  }
1441
1343
  },
1442
- // 1.18.13 fallback (Path C, oh-my-openagent pattern): the TUI drops
1443
- // tui.toast.show events on opencode 1.18.13 (untagged — see header), so
1444
- // queued agent-lifecycle signals (delegation start/done, hook failures,
1445
- // olympians/council summaries) are injected here as ONE <system-reminder> text
1446
- // part into the next user message, then cleared.
1447
- // P0 (2026-08-11, live E2E — release blocker): this hook ALSO fires for the
1448
- // child session's client.session.promptAsync (the old "subagent prompts use
1449
- // a different path" claim is FALSE on 1.18.13), where input.messageID is
1450
- // EMPTY/undefined. Injecting there with `?? ''` produced a part opencode's
1451
- // schema rejects (SchemaError: Expected a string starting with "msg", got
1452
- // "") → prompt_async failed → every delegation died in ~20ms. The guard
1453
- // below skips injection ENTIRELY on that path WITHOUT draining the buffer,
1454
- // so the reminder still lands on the parent's next real message.
1455
- 'chat.message': async (input, output) => {
1456
- try {
1457
- if (ENABLED_TOAST_CATEGORIES.size === 0) return
1458
- if (pendingChatReminders.length === 0) return
1459
- // Subagent promptAsync fires have no messageID — injecting any part
1460
- // with an empty messageID crashes the child session (schema reject).
1461
- // Early return BEFORE the buffer drain keeps the reminder queued for
1462
- // the parent's next real (msg_-ID'd) message.
1463
- if (!input.messageID) return
1464
- const body = drainFreshChatReminders()
1465
- if (body === undefined) return
1466
- // Minimal TextPart — the core backfills messageID/sessionID for
1467
- // hook-injected parts (see opencode chat.message trigger pipeline).
1468
- // The guard above guarantees input.messageID is a non-empty string,
1469
- // so no `?? ''` fallback is needed (an explicit "" is schema-rejected).
1470
- output.parts.push({
1471
- id: `prt_${crypto.randomUUID()}`,
1472
- sessionID: input.sessionID,
1473
- messageID: input.messageID,
1474
- type: 'text',
1475
- text: `<system-reminder>\n${body}\n</system-reminder>`,
1476
- } satisfies Part)
1477
- await reportFailure(
1478
- ctx,
1479
- `[pantheon-hooks:chat-reminder] ${body}`,
1480
- { script: 'chat-reminder' },
1481
- 'info',
1482
- )
1483
- } catch {
1484
- // Never let a hook take down the opencode session.
1485
- }
1486
- },
1487
1344
  event: async ({ event: ev }) => {
1488
1345
  try {
1489
1346
  if (ev.type === 'session.created') {
@@ -11,7 +11,7 @@ type DelegationEntry = {
11
11
  taskID?: string;
12
12
  /** Agent name, e.g. "apollo". */
13
13
  agent: string;
14
- state: 'running' | 'completed' | 'error' | 'startup_failed' | 'startup_unknown' | 'cancelled';
14
+ state: 'running' | 'completed' | 'error' | 'startup_failed' | 'startup_unknown' | 'cancelled' | 'stale-running';
15
15
  /** Epoch ms of the `Started` header. */
16
16
  startedAt: number;
17
17
  /** Epoch ms of the `Finalized` header — null while still running. */
@@ -77,7 +77,22 @@ declare function compareDelegationEntries(a: DelegationEntry, b: DelegationEntry
77
77
  * recent terminal reports (capped). Pure — so the history-only panel (no
78
78
  * sessionID) is testable without the TUI runtime. The header count uses the
79
79
  * same "running + recentes" list. */
80
- declare function visibleDelegationList(all: readonly DelegationEntry[], maxTerminal?: number): DelegationEntry[];
80
+ declare function visibleDelegationList(all: readonly DelegationEntry[], maxTerminal?: number, now?: number, staleThresholdMs?: number): DelegationEntry[];
81
+ /** Default stale-running threshold: 30 minutes. */
82
+ declare const STALE_RUNNING_THRESHOLD_MS: number;
83
+ /** Idle silence window: if no updatedAt change in this window, the entry is
84
+ * considered stale. Combined with the stale-running threshold to produce the
85
+ * display-only `stale-running` state. */
86
+ declare const IDLE_SILENCE_MS: number;
87
+ /**
88
+ * Mark a running entry as `stale-running` if it has been running longer than
89
+ * the threshold AND has no recent activity (no `updatedAt` change in the last
90
+ * `IDLE_SILENCE_MS`). This is DISPLAY-ONLY — the board state is unchanged.
91
+ *
92
+ * A `stale-running` entry renders with a warning indicator but the underlying
93
+ * delegation is still treated as running by the backend.
94
+ */
95
+ declare function markStaleIfRunning(entry: DelegationEntry, now: number, thresholdMs?: number): DelegationEntry;
81
96
  /** Compact elapsed-time label: "5m 12s", "1h 30m", "2d 4h" — ticks every
82
97
  * second for running jobs. */
83
98
  declare function fmtElapsed(ms: number): string;
@@ -306,5 +321,5 @@ declare const plugin: TuiPluginModule & {
306
321
  id: string;
307
322
  };
308
323
  //#endregion
309
- export { ChildDelegationLike, DelegationActivity, DelegationEntry, DelegationToolPart, LiveDelegationEntry, LiveDelegationStore, ParsedDelegationToolPart, TuiSessionSources, buildChildrenPath, childStatusToState, childrenToDelegationEntries, collectDelegationToolParts, compareDelegationEntries, plugin as default, delegationActivity, delegationActivityLabel, delegationElapsed, delegationSpinnerFrame, delegationTag, fmtElapsed, isValidSessionId, mergeChildDelegationSources, mergeDelegationSources, navigateToDelegationSession, panelLogDir, parseDelegationMarkdown, parseDelegationToolPart, readAllDelegationEntries, readDelegationEntries, reduceDelegationToolPart, removeDelegationEntry, resolveCurrentSessionID, resolveDelegationsDir, safeSessionPath, seedLiveDelegationMap, toDelegationEntry, tuiLogPath, visibleDelegationList };
324
+ export { ChildDelegationLike, DelegationActivity, DelegationEntry, DelegationToolPart, IDLE_SILENCE_MS, LiveDelegationEntry, LiveDelegationStore, ParsedDelegationToolPart, STALE_RUNNING_THRESHOLD_MS, TuiSessionSources, buildChildrenPath, childStatusToState, childrenToDelegationEntries, collectDelegationToolParts, compareDelegationEntries, plugin as default, delegationActivity, delegationActivityLabel, delegationElapsed, delegationSpinnerFrame, delegationTag, fmtElapsed, isValidSessionId, markStaleIfRunning, mergeChildDelegationSources, mergeDelegationSources, navigateToDelegationSession, panelLogDir, parseDelegationMarkdown, parseDelegationToolPart, readAllDelegationEntries, readDelegationEntries, reduceDelegationToolPart, removeDelegationEntry, resolveCurrentSessionID, resolveDelegationsDir, safeSessionPath, seedLiveDelegationMap, toDelegationEntry, tuiLogPath, visibleDelegationList };
310
325
  //# sourceMappingURL=tui.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"tui.d.ts","names":[],"sources":["../src/index.tsx"],"mappings":";;KAq6BY;;EAEV;;EAEA;;;;EAIA;;EAEA;EACA;;EAEA;;EAEA;EACA;EACA;;EAEA;;;;EAIA;;;;;;;;;;;iBAsBc,wBACd,aACA,oBACA,qBACC;;;;;;iBAgHmB,sBAAsB,cAAc,QAAQ;;;;;;;;iBAwC5C,yBAAyB,eAAe,QAAQ;;;;;;;;iBAWtD,sBACd;EAAS;EAAoB;eAC7B;;;;;;iBAYc,YAAY;;;iBAMZ,WAAW;;;;iBAOX,yBAAyB,GAAG,iBAAiB,GAAG;;;;;iBAWhD,sBACd,cAAc,mBACd,uBACC;;;iBAQa,WAAW;;;iBAcX,kBAAkB,OAAO,iBAAiB;;;KAO9C;iBAQI,mBAAmB,OAAO,kBAAkB;iBAc5C,wBAAwB,OAAO;;iBAoB/B,uBAAuB;;;;;;;iBAWvB,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;;;;;iBASnB,cAAc,OAAO;;;;;;;;;;;;;;iBAiBrB,4BACd,mBAAmB,mCACnB,aAAa,mBACb,eACC;;;;;;iBAwCa,4BACd;EAAS,YAAY,cAAc,SAAS;eAC5C;;;;;KA8KU;EACV,KAAK,YAAY;;EAEjB;;EAEA;;cAucI,QAAQ;EAAoB"}
1
+ {"version":3,"file":"tui.d.ts","names":[],"sources":["../src/index.tsx"],"mappings":";;KAy6BY;;EAEV;;EAEA;;;;EAIA;;EAEA;EACA;;EASA;;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;;;;;iBAWhD,sBACd,cAAc,mBACd,sBACA,cACA,4BACC;;cASU;;;;cAKA;;;;;;;;;iBAUG,mBACd,OAAO,iBACP,aACA,uBACC;;;iBAWa,WAAW;;;iBAcX,kBAAkB,OAAO,iBAAiB;;;KAQ9C;iBAQI,mBAAmB,OAAO,kBAAkB;iBAc5C,wBAAwB,OAAO;;iBAoB/B,uBAAuB;;;;;;;iBAWvB,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;;;;;iBASnB,cAAc,OAAO;;;;;;;;;;;;;;iBAiBrB,4BACd,mBAAmB,mCACnB,aAAa,mBACb,eACC;;;;;;iBA0Ca,4BACd;EAAS,YAAY,cAAc,SAAS;eAC5C;;;;;KAiLU;EACV,KAAK,YAAY;;EAEjB;;EAEA;;cAucI,QAAQ;EAAoB"}
@@ -120,8 +120,14 @@ async function resolvePresetForTui(env, cwd) {
120
120
  }
121
121
  async function detectVersion(api) {
122
122
  try {
123
- const match = (await readFile(fileURLToPath(new URL("../../../../package.json", import.meta.url)), "utf8")).match(/"version":\s*"([^"]+)"/);
124
- if (match?.[1]) return match[1];
123
+ const pkgContent = await readFile(fileURLToPath(new URL("../../package.json", import.meta.url)), "utf8");
124
+ const pkg = JSON.parse(pkgContent);
125
+ if (pkg.version) return pkg.version;
126
+ } catch {}
127
+ try {
128
+ const pkgContent = await readFile(fileURLToPath(new URL("../../../../package.json", import.meta.url)), "utf8");
129
+ const pkg = JSON.parse(pkgContent);
130
+ if (pkg.version) return pkg.version;
125
131
  } catch {}
126
132
  try {
127
133
  const wt = api.state.path?.worktree ?? "";
@@ -765,7 +771,8 @@ function parseDelegationMarkdown(raw, fileAlias, sessionID = "") {
765
771
  const startedAt = started !== void 0 ? Date.parse(started) : NaN;
766
772
  if (agent === void 0 || state === void 0 || Number.isNaN(startedAt)) return null;
767
773
  const normalized = state.toLowerCase();
768
- if (normalized !== "running" && normalized !== "completed" && normalized !== "error" && normalized !== "startup_failed" && normalized !== "startup_unknown" && normalized !== "cancelled") return null;
774
+ if (normalized === "running") return null;
775
+ if (normalized !== "completed" && normalized !== "error" && normalized !== "startup_failed" && normalized !== "startup_unknown" && normalized !== "cancelled") return null;
769
776
  const finalizedAt = finalized !== void 0 ? Date.parse(finalized) : NaN;
770
777
  return {
771
778
  alias: title ?? (fileAlias !== void 0 ? stripMdSuffix(fileAlias) : "unknown"),
@@ -861,8 +868,8 @@ function tuiLogPath(projectRoot) {
861
868
  * falling back to startedAt, descending). Shared by the md reader and
862
869
  * mergeDelegationSources. */
863
870
  function compareDelegationEntries(a, b) {
864
- const aRun = a.state === "running" ? 1 : 0;
865
- const bRun = b.state === "running" ? 1 : 0;
871
+ const aRun = a.state === "running" || a.state === "stale-running" ? 1 : 0;
872
+ const bRun = b.state === "running" || b.state === "stale-running" ? 1 : 0;
866
873
  if (aRun !== bRun) return bRun - aRun;
867
874
  return (b.updatedAt ?? b.startedAt) - (a.updatedAt ?? a.startedAt);
868
875
  }
@@ -870,11 +877,34 @@ function compareDelegationEntries(a, b) {
870
877
  * recent terminal reports (capped). Pure — so the history-only panel (no
871
878
  * sessionID) is testable without the TUI runtime. The header count uses the
872
879
  * same "running + recentes" list. */
873
- function visibleDelegationList(all, maxTerminal = 8) {
874
- const running = all.filter((d) => d.state === "running");
880
+ function visibleDelegationList(all, maxTerminal = 8, now = Date.now(), staleThresholdMs = 1800 * 1e3) {
881
+ const running = all.filter((d) => d.state === "running").map((d) => markStaleIfRunning(d, now, staleThresholdMs));
875
882
  const terminal = all.filter((d) => d.state !== "running").slice(0, maxTerminal);
876
883
  return [...running, ...terminal];
877
884
  }
885
+ /** Default stale-running threshold: 30 minutes. */
886
+ const STALE_RUNNING_THRESHOLD_MS = 1800 * 1e3;
887
+ /** Idle silence window: if no updatedAt change in this window, the entry is
888
+ * considered stale. Combined with the stale-running threshold to produce the
889
+ * display-only `stale-running` state. */
890
+ const IDLE_SILENCE_MS = 60 * 1e3;
891
+ /**
892
+ * Mark a running entry as `stale-running` if it has been running longer than
893
+ * the threshold AND has no recent activity (no `updatedAt` change in the last
894
+ * `IDLE_SILENCE_MS`). This is DISPLAY-ONLY — the board state is unchanged.
895
+ *
896
+ * A `stale-running` entry renders with a warning indicator but the underlying
897
+ * delegation is still treated as running by the backend.
898
+ */
899
+ function markStaleIfRunning(entry, now, thresholdMs = STALE_RUNNING_THRESHOLD_MS) {
900
+ if (entry.state !== "running") return entry;
901
+ if (now - entry.startedAt < thresholdMs) return entry;
902
+ if (entry.updatedAt !== null && now - entry.updatedAt < 6e4) return entry;
903
+ return {
904
+ ...entry,
905
+ state: "stale-running"
906
+ };
907
+ }
878
908
  /** Compact elapsed-time label: "5m 12s", "1h 30m", "2d 4h" — ticks every
879
909
  * second for running jobs. */
880
910
  function fmtElapsed(ms) {
@@ -891,7 +921,7 @@ function fmtElapsed(ms) {
891
921
  /** Elapsed label for one entry: running → ticks `now - startedAt`, terminal
892
922
  * → fixed `updatedAt - startedAt` (em dash when no finalized timestamp). */
893
923
  function delegationElapsed(entry, now) {
894
- if (entry.state === "running") return fmtElapsed(now - entry.startedAt);
924
+ if (entry.state === "running" || entry.state === "stale-running") return fmtElapsed(now - entry.startedAt);
895
925
  return entry.updatedAt !== null ? fmtElapsed(entry.updatedAt - entry.startedAt) : "—";
896
926
  }
897
927
  /** The activity labels shown by the animated row. Keeping this pure makes the
@@ -1318,7 +1348,7 @@ function childrenToDelegationEntries(children, md, now = Date.now()) {
1318
1348
  agent: mdEntry?.agent ?? child.agent ?? "agent",
1319
1349
  state,
1320
1350
  startedAt: mdEntry?.startedAt ?? child.time?.created ?? now,
1321
- updatedAt: mdEntry?.updatedAt ?? (state === "running" ? null : child.time?.updated ?? null),
1351
+ updatedAt: mdEntry?.updatedAt ?? (state === "running" || state === "stale-running" ? null : child.time?.updated ?? null),
1322
1352
  timedOut: mdEntry?.timedOut ?? false,
1323
1353
  description: mdEntry !== void 0 && mdEntry.description !== "" ? mdEntry.description : child.title ?? "",
1324
1354
  source: mdEntry !== void 0 ? "md" : "children-only"
@@ -1399,6 +1429,7 @@ function DelegationRow(props) {
1399
1429
  const t = theme();
1400
1430
  switch (props.job.state) {
1401
1431
  case "running": return t.warning;
1432
+ case "stale-running": return t.error;
1402
1433
  case "completed": return t.success;
1403
1434
  case "error": return t.error;
1404
1435
  default: return t.textMuted;
@@ -1406,6 +1437,7 @@ function DelegationRow(props) {
1406
1437
  });
1407
1438
  const marker = createMemo(() => {
1408
1439
  if (props.job.state === "running") return `${delegationSpinnerFrame(props.animationNow)} `;
1440
+ if (props.job.state === "stale-running") return "⚠ ";
1409
1441
  switch (props.job.state) {
1410
1442
  case "completed": return "✓ ";
1411
1443
  case "error": return "✕ ";
@@ -1842,6 +1874,6 @@ const plugin = {
1842
1874
  tui
1843
1875
  };
1844
1876
  //#endregion
1845
- export { buildChildrenPath, childStatusToState, childrenToDelegationEntries, collectDelegationToolParts, compareDelegationEntries, plugin as default, delegationActivity, delegationActivityLabel, delegationElapsed, delegationSpinnerFrame, delegationTag, fmtElapsed, isValidSessionId, mergeChildDelegationSources, mergeDelegationSources, navigateToDelegationSession, panelLogDir, parseDelegationMarkdown, parseDelegationToolPart, readAllDelegationEntries, readDelegationEntries, reduceDelegationToolPart, removeDelegationEntry, resolveCurrentSessionID, resolveDelegationsDir, safeSessionPath, seedLiveDelegationMap, toDelegationEntry, tuiLogPath, visibleDelegationList };
1877
+ export { IDLE_SILENCE_MS, STALE_RUNNING_THRESHOLD_MS, buildChildrenPath, childStatusToState, childrenToDelegationEntries, collectDelegationToolParts, compareDelegationEntries, plugin as default, delegationActivity, delegationActivityLabel, delegationElapsed, delegationSpinnerFrame, delegationTag, fmtElapsed, isValidSessionId, markStaleIfRunning, mergeChildDelegationSources, mergeDelegationSources, navigateToDelegationSession, panelLogDir, parseDelegationMarkdown, parseDelegationToolPart, readAllDelegationEntries, readDelegationEntries, reduceDelegationToolPart, removeDelegationEntry, resolveCurrentSessionID, resolveDelegationsDir, safeSessionPath, seedLiveDelegationMap, toDelegationEntry, tuiLogPath, visibleDelegationList };
1846
1878
 
1847
1879
  //# sourceMappingURL=tui.js.map
@@ -154,25 +154,29 @@ async function resolvePresetForTui(
154
154
  * Se falhar, retorna null e a View omite a versão.
155
155
  *
156
156
  * Try order:
157
+ * 0a. pantheon-tui/package.json (installed: dist/tui.js → 2 níveis)
158
+ * 0b. pantheon/package.json (dev: src/plugins/tui/dist/tui.tsx → 4 níveis)
157
159
  * 1. api.client.file.read package.json
158
160
  * 2. git describe --tags
159
161
  * 3. opencode --version
160
162
  * 4. null */
161
163
 
162
164
  async function detectVersion(api: TuiPluginApi): Promise<string | null> {
163
- // Try 0: package.json do PACOTE PANTHEON instalado. O TUI é carregado de
164
- // <pacote>/src/plugins/tui/dist/tui.tsx (fix #32 garante esse path) →
165
- // 4 níveis acima (dist → tui → plugins → src → raiz) está o package.json
166
- // do pacote. Funciona em QUALQUER cwd (ex: sandbox sem package.json/git) e
167
- // também em dev (source no repo). Se falhar, cai para os tries abaixo.
165
+ // Try 0a: installed location (2 levels: dist/tui.js → pantheon-tui/package.json)
166
+ try {
167
+ const pkgUrl = new URL('../../package.json', import.meta.url)
168
+ const pkgContent = await readFile(fileURLToPath(pkgUrl), 'utf8')
169
+ const pkg = JSON.parse(pkgContent)
170
+ if (pkg.version) return pkg.version
171
+ } catch {}
172
+
173
+ // Try 0b: dev location (4 levels: src/plugins/tui/dist/tui.tsx → package.json)
168
174
  try {
169
175
  const pkgUrl = new URL('../../../../package.json', import.meta.url)
170
176
  const pkgContent = await readFile(fileURLToPath(pkgUrl), 'utf8')
171
- const match = pkgContent.match(/"version":\s*"([^"]+)"/)
172
- if (match?.[1]) return match[1]
173
- } catch {
174
- /* fall through */
175
- }
177
+ const pkg = JSON.parse(pkgContent)
178
+ if (pkg.version) return pkg.version
179
+ } catch {}
176
180
 
177
181
  // Try 1: package.json do projeto (via worktree path)
178
182
  try {
@@ -942,7 +946,14 @@ export type DelegationEntry = {
942
946
  taskID?: string
943
947
  /** Agent name, e.g. "apollo". */
944
948
  agent: string
945
- state: 'running' | 'completed' | 'error' | 'startup_failed' | 'startup_unknown' | 'cancelled'
949
+ state:
950
+ | 'running'
951
+ | 'completed'
952
+ | 'error'
953
+ | 'startup_failed'
954
+ | 'startup_unknown'
955
+ | 'cancelled'
956
+ | 'stale-running'
946
957
  /** Epoch ms of the `Started` header. */
947
958
  startedAt: number
948
959
  /** Epoch ms of the `Finalized` header — null while still running. */
@@ -1048,8 +1059,10 @@ export function parseDelegationMarkdown(
1048
1059
  const startedAt = started !== undefined ? Date.parse(started) : NaN
1049
1060
  if (agent === undefined || state === undefined || Number.isNaN(startedAt)) return null
1050
1061
  const normalized = state.toLowerCase()
1062
+ // Running state should only come from live channels, never from persisted
1063
+ // reports. A stale "running" MD artifact contaminates the panel — skip it.
1064
+ if (normalized === 'running') return null
1051
1065
  if (
1052
- normalized !== 'running' &&
1053
1066
  normalized !== 'completed' &&
1054
1067
  normalized !== 'error' &&
1055
1068
  normalized !== 'startup_failed' &&
@@ -1171,8 +1184,8 @@ export function tuiLogPath(projectRoot: string): string {
1171
1184
  * falling back to startedAt, descending). Shared by the md reader and
1172
1185
  * mergeDelegationSources. */
1173
1186
  export function compareDelegationEntries(a: DelegationEntry, b: DelegationEntry): number {
1174
- const aRun = a.state === 'running' ? 1 : 0
1175
- const bRun = b.state === 'running' ? 1 : 0
1187
+ const aRun = a.state === 'running' || a.state === 'stale-running' ? 1 : 0
1188
+ const bRun = b.state === 'running' || b.state === 'stale-running' ? 1 : 0
1176
1189
  if (aRun !== bRun) return bRun - aRun
1177
1190
  return (b.updatedAt ?? b.startedAt) - (a.updatedAt ?? a.startedAt)
1178
1191
  }
@@ -1184,12 +1197,45 @@ export function compareDelegationEntries(a: DelegationEntry, b: DelegationEntry)
1184
1197
  export function visibleDelegationList(
1185
1198
  all: readonly DelegationEntry[],
1186
1199
  maxTerminal = 8,
1200
+ now = Date.now(),
1201
+ staleThresholdMs = 30 * 60 * 1000, // 30 minutes
1187
1202
  ): DelegationEntry[] {
1188
- const running = all.filter((d) => d.state === 'running')
1203
+ const running = all
1204
+ .filter((d) => d.state === 'running')
1205
+ .map((d) => markStaleIfRunning(d, now, staleThresholdMs))
1189
1206
  const terminal = all.filter((d) => d.state !== 'running').slice(0, maxTerminal)
1190
1207
  return [...running, ...terminal]
1191
1208
  }
1192
1209
 
1210
+ /** Default stale-running threshold: 30 minutes. */
1211
+ export const STALE_RUNNING_THRESHOLD_MS = 30 * 60 * 1000
1212
+
1213
+ /** Idle silence window: if no updatedAt change in this window, the entry is
1214
+ * considered stale. Combined with the stale-running threshold to produce the
1215
+ * display-only `stale-running` state. */
1216
+ export const IDLE_SILENCE_MS = 60 * 1000
1217
+
1218
+ /**
1219
+ * Mark a running entry as `stale-running` if it has been running longer than
1220
+ * the threshold AND has no recent activity (no `updatedAt` change in the last
1221
+ * `IDLE_SILENCE_MS`). This is DISPLAY-ONLY — the board state is unchanged.
1222
+ *
1223
+ * A `stale-running` entry renders with a warning indicator but the underlying
1224
+ * delegation is still treated as running by the backend.
1225
+ */
1226
+ export function markStaleIfRunning(
1227
+ entry: DelegationEntry,
1228
+ now: number,
1229
+ thresholdMs = STALE_RUNNING_THRESHOLD_MS,
1230
+ ): DelegationEntry {
1231
+ if (entry.state !== 'running') return entry
1232
+ const elapsed = now - entry.startedAt
1233
+ if (elapsed < thresholdMs) return entry
1234
+ // If updatedAt exists and is recent, entry is still active — not stale.
1235
+ if (entry.updatedAt !== null && now - entry.updatedAt < IDLE_SILENCE_MS) return entry
1236
+ return { ...entry, state: 'stale-running' as const }
1237
+ }
1238
+
1193
1239
  /** Compact elapsed-time label: "5m 12s", "1h 30m", "2d 4h" — ticks every
1194
1240
  * second for running jobs. */
1195
1241
  export function fmtElapsed(ms: number): string {
@@ -1207,7 +1253,8 @@ export function fmtElapsed(ms: number): string {
1207
1253
  /** Elapsed label for one entry: running → ticks `now - startedAt`, terminal
1208
1254
  * → fixed `updatedAt - startedAt` (em dash when no finalized timestamp). */
1209
1255
  export function delegationElapsed(entry: DelegationEntry, now: number): string {
1210
- if (entry.state === 'running') return fmtElapsed(now - entry.startedAt)
1256
+ if (entry.state === 'running' || entry.state === 'stale-running')
1257
+ return fmtElapsed(now - entry.startedAt)
1211
1258
  return entry.updatedAt !== null ? fmtElapsed(entry.updatedAt - entry.startedAt) : '\u2014'
1212
1259
  }
1213
1260
 
@@ -1874,7 +1921,9 @@ export function childrenToDelegationEntries(
1874
1921
  agent: mdEntry?.agent ?? child.agent ?? 'agent',
1875
1922
  state,
1876
1923
  startedAt: mdEntry?.startedAt ?? child.time?.created ?? now,
1877
- updatedAt: mdEntry?.updatedAt ?? (state === 'running' ? null : (child.time?.updated ?? null)),
1924
+ updatedAt:
1925
+ mdEntry?.updatedAt ??
1926
+ (state === 'running' || state === 'stale-running' ? null : (child.time?.updated ?? null)),
1878
1927
  timedOut: mdEntry?.timedOut ?? false,
1879
1928
  description:
1880
1929
  mdEntry !== undefined && mdEntry.description !== ''
@@ -2008,6 +2057,8 @@ function DelegationRow(props: {
2008
2057
  switch (props.job.state) {
2009
2058
  case 'running':
2010
2059
  return t.warning
2060
+ case 'stale-running':
2061
+ return t.error // warning color for stale delegations
2011
2062
  case 'completed':
2012
2063
  return t.success
2013
2064
  case 'error':
@@ -2019,6 +2070,7 @@ function DelegationRow(props: {
2019
2070
 
2020
2071
  const marker = createMemo(() => {
2021
2072
  if (props.job.state === 'running') return `${delegationSpinnerFrame(props.animationNow)} `
2073
+ if (props.job.state === 'stale-running') return '\u26a0 ' // warning triangle
2022
2074
  switch (props.job.state) {
2023
2075
  case 'completed':
2024
2076
  return '\u2713 '
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pantheon-tui",
3
- "version": "1.3.2",
3
+ "version": "1.4.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pantheon-tui",
9
- "version": "1.3.2",
9
+ "version": "1.4.1",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@opencode-ai/plugin": ">=1.14.0",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pantheon-tui",
3
3
  "type": "module",
4
- "version": "1.3.7",
4
+ "version": "1.4.1",
5
5
  "description": "Pantheon TUI plugin — sidebar (version, preset, sessions, real-time delegations) + AI subscription usage gauges incl. OpenCode Go/Zen dollar limits (vendored, MIT)",
6
6
  "license": "MIT",
7
7
  "exports": {