pantheon-opencode 1.3.5 → 1.3.7

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.
Files changed (71) hide show
  1. package/README.md +33 -1
  2. package/package.json +3 -3
  3. package/scripts/_pantheon_paths.py +0 -1
  4. package/scripts/build-agents-md.mjs +4 -2
  5. package/scripts/code_mode_server.py +307 -21
  6. package/scripts/doctor.mjs +26 -13
  7. package/scripts/hooks/format-multi-language.sh +6 -3
  8. package/scripts/hooks/log-session-start.sh +6 -0
  9. package/scripts/hooks/on-subagent-delegation-start.sh +22 -6
  10. package/scripts/hooks/validate-post-conditions.sh +1 -1
  11. package/scripts/hooks/validate-tool-safety.sh +5 -2
  12. package/scripts/install/agents-md.mjs +3 -1
  13. package/scripts/install/cli-ui.mjs +24 -4
  14. package/scripts/install/interactive.mjs +18 -7
  15. package/scripts/install/migrate-config.mjs +13 -19
  16. package/scripts/install/migrate.mjs +8 -5
  17. package/scripts/install/model-picker.mjs +4 -1
  18. package/scripts/install/opencode.mjs +2 -0
  19. package/scripts/install/shared.mjs +6 -4
  20. package/scripts/install/state.mjs +9 -6
  21. package/scripts/mcp_persistence_server.py +23 -22
  22. package/scripts/mcp_resources_server.py +60 -1
  23. package/scripts/memory_mcp_server.py +80 -4
  24. package/scripts/postinstall.mjs +27 -25
  25. package/scripts/prune.mjs +1 -1
  26. package/scripts/redaction-gate.mjs +3 -1
  27. package/scripts/sync-tui.mjs +148 -0
  28. package/scripts/uninstall.mjs +0 -2
  29. package/scripts/validation-policy.mjs +6 -1
  30. package/scripts/version-check.mjs +9 -4
  31. package/src/agents/aphrodite.md +1 -1
  32. package/src/agents/demeter.md +1 -1
  33. package/src/agents/hephaestus.md +1 -1
  34. package/src/agents/hermes.md +1 -1
  35. package/src/agents/themis.md +4 -0
  36. package/src/mcp/code_mode_server.py +304 -18
  37. package/src/mcp/eval_store.py +233 -0
  38. package/src/mcp/mcp_persistence_server.py +23 -22
  39. package/src/mcp/mcp_resources_server.py +58 -0
  40. package/src/mcp/memory_mcp_server.py +80 -3
  41. package/src/pantheon/auto-wake.ts +2 -4
  42. package/src/pantheon/background-job-board.ts +17 -4
  43. package/src/pantheon/comment-checker.ts +48 -0
  44. package/src/pantheon/delegation-classifier.ts +307 -0
  45. package/src/pantheon/delegation-enforce.ts +21 -21
  46. package/src/pantheon/delegation-finalize.ts +44 -4
  47. package/src/pantheon/delegation-notify.ts +97 -0
  48. package/src/pantheon/delegation.ts +413 -23
  49. package/src/pantheon/dispatch-guard.ts +2 -0
  50. package/src/pantheon/file-persistence.ts +7 -3
  51. package/src/pantheon/permission-eval.ts +284 -0
  52. package/src/pantheon/permission-globs.ts +86 -0
  53. package/src/pantheon/presets.d.mts +41 -0
  54. package/src/pantheon/presets.mjs +134 -0
  55. package/src/pantheon/retry-policy.ts +212 -0
  56. package/src/pantheon/session-guard.ts +1 -3
  57. package/src/pantheon/step-cap.ts +110 -0
  58. package/src/pantheon/tui-version-check.ts +72 -0
  59. package/src/pantheon/vision.ts +7 -5
  60. package/src/pantheon/zeus-delegate-with-retry.ts +81 -12
  61. package/src/plugin.ts +57 -3
  62. package/src/plugins/hook-runner.ts +1 -1
  63. package/src/plugins/pantheon-hooks.ts +1 -1
  64. package/src/plugins/tui/dist/tui.d.ts +1 -1
  65. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  66. package/src/plugins/tui/dist/tui.js +19 -14
  67. package/src/plugins/tui/dist/tui.tsx +52 -18
  68. package/src/plugins/tui/package.json +1 -1
  69. package/src/plugins/tui/src/index.tsx +106 -24
  70. package/src/plugins/tui/tsdown.config.ts +1 -1
  71. package/src/routing.yml +42 -0
@@ -22,7 +22,7 @@
22
22
  * is returned as a structured HookResult so the opencode session can never be
23
23
  * taken down by the security layer.
24
24
  */
25
- import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
25
+ import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process'
26
26
  import path from 'node:path'
27
27
  import type { Readable } from 'node:stream'
28
28
  import { fileURLToPath } from 'node:url'
@@ -192,8 +192,8 @@ import {
192
192
  enqueueChatReminder,
193
193
  pendingChatReminders,
194
194
  } from '../pantheon/chat-reminders.ts'
195
- import { type HookPayload, type HookResult, runHook } from './hook-runner.ts'
196
195
  import { pantheonPluginOnce } from '../pantheon/plugin-once.ts'
196
+ import { type HookPayload, type HookResult, runHook } from './hook-runner.ts'
197
197
 
198
198
  /** Tools that represent a subagent delegation (opencode `task` tool etc.). */
199
199
  const DELEGATION_TOOL_RE = /^(task|.*delegate.*|.*subagent.*)$/i
@@ -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' | 'cancelled';
14
+ state: 'running' | 'completed' | 'error' | 'startup_failed' | 'startup_unknown' | 'cancelled';
15
15
  /** Epoch ms of the `Started` header. */
16
16
  startedAt: number;
17
17
  /** Epoch ms of the `Finalized` header — null while still running. */
@@ -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;;;;;;iBA8GmB,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;iBAS5C,wBAAwB,OAAO;;iBAoB/B,uBAAuB;;;;;;;iBAWvB,4BACd,mBAAmB,mBACnB,eAAe,wBACd;;KA4DS;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;;cAmcI,QAAQ;EAAoB"}
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"}
@@ -455,10 +455,10 @@ function parseConfig(raw) {
455
455
  if (!toml?.parse) return defaultConfig();
456
456
  const root = asTable(toml.parse(raw));
457
457
  const cfg = defaultConfig();
458
- const ui = asTable(root["ui"]);
459
- cfg.showBars = bool(ui["show_bars"], cfg.showBars);
460
- cfg.showStatus = bool(ui["show_status"], cfg.showStatus);
461
- const rawWidth = ui["bar_width"];
458
+ const ui = asTable(root.ui);
459
+ cfg.showBars = bool(ui.show_bars, cfg.showBars);
460
+ cfg.showStatus = bool(ui.show_status, cfg.showStatus);
461
+ const rawWidth = ui.bar_width;
462
462
  if (typeof rawWidth === "number" && Number.isFinite(rawWidth) && rawWidth >= 1) cfg.barWidth = Math.min(40, Math.floor(rawWidth));
463
463
  for (const id of [
464
464
  "anthropic",
@@ -467,14 +467,14 @@ function parseConfig(raw) {
467
467
  ]) {
468
468
  const t = asTable(root[id]);
469
469
  const p = cfg.providers[id];
470
- p.enabled = bool(t["enabled"], p.enabled);
471
- p.show["5h"] = bool(t["show_5h"], p.show["5h"]);
472
- p.show["7d"] = bool(t["show_7d"], p.show["7d"]);
473
- p.show["1m"] = bool(t["show_1m"], p.show["1m"]);
474
- p.show.model = bool(t["show_model"], p.show.model);
475
- const credentialsPath = str(t["credentials_path"]);
470
+ p.enabled = bool(t.enabled, p.enabled);
471
+ p.show["5h"] = bool(t.show_5h, p.show["5h"]);
472
+ p.show["7d"] = bool(t.show_7d, p.show["7d"]);
473
+ p.show["1m"] = bool(t.show_1m, p.show["1m"]);
474
+ p.show.model = bool(t.show_model, p.show.model);
475
+ const credentialsPath = str(t.credentials_path);
476
476
  if (credentialsPath !== void 0) p.credentialsPath = credentialsPath;
477
- const codexAuthPath = str(t["codex_auth_path"]);
477
+ const codexAuthPath = str(t.codex_auth_path);
478
478
  if (codexAuthPath !== void 0) p.codexAuthPath = codexAuthPath;
479
479
  }
480
480
  return cfg;
@@ -765,7 +765,7 @@ function parseDelegationMarkdown(raw, fileAlias, sessionID = "") {
765
765
  const startedAt = started !== void 0 ? Date.parse(started) : NaN;
766
766
  if (agent === void 0 || state === void 0 || Number.isNaN(startedAt)) return null;
767
767
  const normalized = state.toLowerCase();
768
- if (normalized !== "running" && normalized !== "completed" && normalized !== "error" && normalized !== "cancelled") return null;
768
+ if (normalized !== "running" && normalized !== "completed" && normalized !== "error" && normalized !== "startup_failed" && normalized !== "startup_unknown" && normalized !== "cancelled") return null;
769
769
  const finalizedAt = finalized !== void 0 ? Date.parse(finalized) : NaN;
770
770
  return {
771
771
  alias: title ?? (fileAlias !== void 0 ? stripMdSuffix(fileAlias) : "unknown"),
@@ -898,7 +898,7 @@ function delegationElapsed(entry, now) {
898
898
  * state machine testable without booting OpenCode's renderer. */
899
899
  function delegationActivity(entry) {
900
900
  if (entry.state === "completed") return "completed";
901
- if (entry.state === "error") return "error";
901
+ if (entry.state === "error" || entry.state === "startup_failed" || entry.state === "startup_unknown") return "error";
902
902
  if (entry.state === "cancelled") return "cancelled";
903
903
  if (entry.read) return "reading";
904
904
  if (entry.alias.startsWith("live-") && entry.taskID === void 0) return "delegating";
@@ -959,7 +959,12 @@ function mergeChildDelegationSources(children, live) {
959
959
  }
960
960
  const existing = result[index];
961
961
  if (existing === void 0) continue;
962
- if (existing.source === "md" && existing.state !== "running") continue;
962
+ if (existing.state !== "running") {
963
+ if (liveEntry.alias !== null && existing.alias !== incoming.alias) existing.alias = incoming.alias;
964
+ if (incoming.agent !== "agent" && existing.agent !== incoming.agent) existing.agent = incoming.agent;
965
+ if (incoming.read && !existing.read) existing.read = true;
966
+ continue;
967
+ }
963
968
  result[index] = {
964
969
  ...existing,
965
970
  alias: liveEntry.alias !== null ? incoming.alias : existing.alias,
@@ -677,25 +677,25 @@ function parseConfig(raw: string): UsageBarConfig {
677
677
  const root = asTable(toml.parse(raw))
678
678
  const cfg = defaultConfig()
679
679
 
680
- const ui = asTable(root['ui'])
681
- cfg.showBars = bool(ui['show_bars'], cfg.showBars)
682
- cfg.showStatus = bool(ui['show_status'], cfg.showStatus)
683
- const rawWidth = ui['bar_width']
680
+ const ui = asTable(root.ui)
681
+ cfg.showBars = bool(ui.show_bars, cfg.showBars)
682
+ cfg.showStatus = bool(ui.show_status, cfg.showStatus)
683
+ const rawWidth = ui.bar_width
684
684
  if (typeof rawWidth === 'number' && Number.isFinite(rawWidth) && rawWidth >= 1)
685
685
  cfg.barWidth = Math.min(40, Math.floor(rawWidth))
686
686
 
687
687
  for (const id of ['anthropic', 'openai', 'opencodego'] as ProviderId[]) {
688
688
  const t = asTable(root[id])
689
689
  const p = cfg.providers[id]
690
- p.enabled = bool(t['enabled'], p.enabled)
691
- p.show['5h'] = bool(t['show_5h'], p.show['5h'])
692
- p.show['7d'] = bool(t['show_7d'], p.show['7d'])
693
- p.show['1m'] = bool(t['show_1m'], p.show['1m'])
694
- p.show.model = bool(t['show_model'], p.show.model)
690
+ p.enabled = bool(t.enabled, p.enabled)
691
+ p.show['5h'] = bool(t.show_5h, p.show['5h'])
692
+ p.show['7d'] = bool(t.show_7d, p.show['7d'])
693
+ p.show['1m'] = bool(t.show_1m, p.show['1m'])
694
+ p.show.model = bool(t.show_model, p.show.model)
695
695
  // Assign only when present — keeps `exactOptionalPropertyTypes` happy.
696
- const credentialsPath = str(t['credentials_path'])
696
+ const credentialsPath = str(t.credentials_path)
697
697
  if (credentialsPath !== undefined) p.credentialsPath = credentialsPath
698
- const codexAuthPath = str(t['codex_auth_path'])
698
+ const codexAuthPath = str(t.codex_auth_path)
699
699
  if (codexAuthPath !== undefined) p.codexAuthPath = codexAuthPath
700
700
  }
701
701
  return cfg
@@ -942,7 +942,7 @@ export type DelegationEntry = {
942
942
  taskID?: string
943
943
  /** Agent name, e.g. "apollo". */
944
944
  agent: string
945
- state: 'running' | 'completed' | 'error' | 'cancelled'
945
+ state: 'running' | 'completed' | 'error' | 'startup_failed' | 'startup_unknown' | 'cancelled'
946
946
  /** Epoch ms of the `Started` header. */
947
947
  startedAt: number
948
948
  /** Epoch ms of the `Finalized` header — null while still running. */
@@ -1052,6 +1052,8 @@ export function parseDelegationMarkdown(
1052
1052
  normalized !== 'running' &&
1053
1053
  normalized !== 'completed' &&
1054
1054
  normalized !== 'error' &&
1055
+ normalized !== 'startup_failed' &&
1056
+ normalized !== 'startup_unknown' &&
1055
1057
  normalized !== 'cancelled'
1056
1058
  ) {
1057
1059
  return null
@@ -1221,7 +1223,12 @@ export type DelegationActivity =
1221
1223
 
1222
1224
  export function delegationActivity(entry: DelegationEntry): DelegationActivity {
1223
1225
  if (entry.state === 'completed') return 'completed'
1224
- if (entry.state === 'error') return 'error'
1226
+ if (
1227
+ entry.state === 'error' ||
1228
+ entry.state === 'startup_failed' ||
1229
+ entry.state === 'startup_unknown'
1230
+ )
1231
+ return 'error'
1225
1232
  if (entry.state === 'cancelled') return 'cancelled'
1226
1233
  if (entry.read) return 'reading'
1227
1234
  if (entry.alias.startsWith('live-') && entry.taskID === undefined) return 'delegating'
@@ -1274,7 +1281,12 @@ export function mergeChildDelegationSources(
1274
1281
  })
1275
1282
 
1276
1283
  for (const liveEntry of live) {
1277
- if (liveEntry.alias === null && liveEntry.taskID === null && Date.now() - liveEntry.startedAt > 30_000) continue
1284
+ if (
1285
+ liveEntry.alias === null &&
1286
+ liveEntry.taskID === null &&
1287
+ Date.now() - liveEntry.startedAt > 30_000
1288
+ )
1289
+ continue
1278
1290
  const incoming = toDelegationEntry(liveEntry)
1279
1291
  const index =
1280
1292
  (incoming.taskID !== undefined ? byTask.get(incoming.taskID) : undefined) ??
@@ -1292,8 +1304,26 @@ export function mergeChildDelegationSources(
1292
1304
 
1293
1305
  const existing = result[index]
1294
1306
  if (existing === undefined) continue
1295
- const finalized = existing.source === 'md' && existing.state !== 'running'
1296
- if (finalized) continue
1307
+ // Any terminal state is authoritative — a child session that went idle
1308
+ // correctly derives 'completed' via childStatusToState before the MD
1309
+ // report is written. The live tool-part channel stays 'running' until
1310
+ // pantheon_delegation_read fires, so allowing it to overwrite a
1311
+ // terminal state would flip completed → running (the bug this fixes).
1312
+ //
1313
+ // When the existing entry IS terminal, we still absorb live metadata
1314
+ // (alias, agent, read) but preserve the terminal state.
1315
+ if (existing.state !== 'running') {
1316
+ if (liveEntry.alias !== null && existing.alias !== incoming.alias) {
1317
+ existing.alias = incoming.alias
1318
+ }
1319
+ if (incoming.agent !== 'agent' && existing.agent !== incoming.agent) {
1320
+ existing.agent = incoming.agent
1321
+ }
1322
+ if (incoming.read && !existing.read) {
1323
+ existing.read = true
1324
+ }
1325
+ continue
1326
+ }
1297
1327
  result[index] = {
1298
1328
  ...existing,
1299
1329
  alias: liveEntry.alias !== null ? incoming.alias : existing.alias,
@@ -2220,7 +2250,9 @@ function View(props: {
2220
2250
  }
2221
2251
  const withStatus = children.map((c) => ({ ...c, status: resolveStatus(c.id) }))
2222
2252
  const childEntries = childrenToDelegationEntries(withStatus, md, now())
2223
- for (const [k, v] of props.liveStore.map) if (v.alias === null && v.taskID === null && Date.now() - v.startedAt > 30_000) props.liveStore.map.delete(k)
2253
+ for (const [k, v] of props.liveStore.map)
2254
+ if (v.alias === null && v.taskID === null && Date.now() - v.startedAt > 30_000)
2255
+ props.liveStore.map.delete(k)
2224
2256
  // The event channel is optimistic: it renders a job even while the
2225
2257
  // child session/report is still being created. Filter by parent so a
2226
2258
  // different focused session never leaks rows into this sidebar.
@@ -2488,7 +2520,9 @@ const tui: TuiPlugin = (api, _options, _meta) => {
2488
2520
  order: 900,
2489
2521
  slots: {
2490
2522
  sidebar_content(_ctx, props) {
2491
- return <View api={api} sessionID={props.session_id} version={version()} liveStore={liveStore} />
2523
+ return (
2524
+ <View api={api} sessionID={props.session_id} version={version()} liveStore={liveStore} />
2525
+ )
2492
2526
  },
2493
2527
  },
2494
2528
  })
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pantheon-tui",
3
3
  "type": "module",
4
- "version": "1.3.5",
4
+ "version": "1.3.7",
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": {
@@ -677,25 +677,25 @@ function parseConfig(raw: string): UsageBarConfig {
677
677
  const root = asTable(toml.parse(raw))
678
678
  const cfg = defaultConfig()
679
679
 
680
- const ui = asTable(root['ui'])
681
- cfg.showBars = bool(ui['show_bars'], cfg.showBars)
682
- cfg.showStatus = bool(ui['show_status'], cfg.showStatus)
683
- const rawWidth = ui['bar_width']
680
+ const ui = asTable(root.ui)
681
+ cfg.showBars = bool(ui.show_bars, cfg.showBars)
682
+ cfg.showStatus = bool(ui.show_status, cfg.showStatus)
683
+ const rawWidth = ui.bar_width
684
684
  if (typeof rawWidth === 'number' && Number.isFinite(rawWidth) && rawWidth >= 1)
685
685
  cfg.barWidth = Math.min(40, Math.floor(rawWidth))
686
686
 
687
687
  for (const id of ['anthropic', 'openai', 'opencodego'] as ProviderId[]) {
688
688
  const t = asTable(root[id])
689
689
  const p = cfg.providers[id]
690
- p.enabled = bool(t['enabled'], p.enabled)
691
- p.show['5h'] = bool(t['show_5h'], p.show['5h'])
692
- p.show['7d'] = bool(t['show_7d'], p.show['7d'])
693
- p.show['1m'] = bool(t['show_1m'], p.show['1m'])
694
- p.show.model = bool(t['show_model'], p.show.model)
690
+ p.enabled = bool(t.enabled, p.enabled)
691
+ p.show['5h'] = bool(t.show_5h, p.show['5h'])
692
+ p.show['7d'] = bool(t.show_7d, p.show['7d'])
693
+ p.show['1m'] = bool(t.show_1m, p.show['1m'])
694
+ p.show.model = bool(t.show_model, p.show.model)
695
695
  // Assign only when present — keeps `exactOptionalPropertyTypes` happy.
696
- const credentialsPath = str(t['credentials_path'])
696
+ const credentialsPath = str(t.credentials_path)
697
697
  if (credentialsPath !== undefined) p.credentialsPath = credentialsPath
698
- const codexAuthPath = str(t['codex_auth_path'])
698
+ const codexAuthPath = str(t.codex_auth_path)
699
699
  if (codexAuthPath !== undefined) p.codexAuthPath = codexAuthPath
700
700
  }
701
701
  return cfg
@@ -942,7 +942,14 @@ export type DelegationEntry = {
942
942
  taskID?: string
943
943
  /** Agent name, e.g. "apollo". */
944
944
  agent: string
945
- state: 'running' | 'completed' | 'error' | 'cancelled'
945
+ state:
946
+ | 'running'
947
+ | 'completed'
948
+ | 'error'
949
+ | 'startup_failed'
950
+ | 'startup_unknown'
951
+ | 'cancelled'
952
+ | 'stale-running'
946
953
  /** Epoch ms of the `Started` header. */
947
954
  startedAt: number
948
955
  /** Epoch ms of the `Finalized` header — null while still running. */
@@ -1048,10 +1055,14 @@ export function parseDelegationMarkdown(
1048
1055
  const startedAt = started !== undefined ? Date.parse(started) : NaN
1049
1056
  if (agent === undefined || state === undefined || Number.isNaN(startedAt)) return null
1050
1057
  const normalized = state.toLowerCase()
1058
+ // Running state should only come from live channels, never from persisted
1059
+ // reports. A stale "running" MD artifact contaminates the panel — skip it.
1060
+ if (normalized === 'running') return null
1051
1061
  if (
1052
- normalized !== 'running' &&
1053
1062
  normalized !== 'completed' &&
1054
1063
  normalized !== 'error' &&
1064
+ normalized !== 'startup_failed' &&
1065
+ normalized !== 'startup_unknown' &&
1055
1066
  normalized !== 'cancelled'
1056
1067
  ) {
1057
1068
  return null
@@ -1169,8 +1180,8 @@ export function tuiLogPath(projectRoot: string): string {
1169
1180
  * falling back to startedAt, descending). Shared by the md reader and
1170
1181
  * mergeDelegationSources. */
1171
1182
  export function compareDelegationEntries(a: DelegationEntry, b: DelegationEntry): number {
1172
- const aRun = a.state === 'running' ? 1 : 0
1173
- const bRun = b.state === 'running' ? 1 : 0
1183
+ const aRun = a.state === 'running' || a.state === 'stale-running' ? 1 : 0
1184
+ const bRun = b.state === 'running' || b.state === 'stale-running' ? 1 : 0
1174
1185
  if (aRun !== bRun) return bRun - aRun
1175
1186
  return (b.updatedAt ?? b.startedAt) - (a.updatedAt ?? a.startedAt)
1176
1187
  }
@@ -1182,12 +1193,45 @@ export function compareDelegationEntries(a: DelegationEntry, b: DelegationEntry)
1182
1193
  export function visibleDelegationList(
1183
1194
  all: readonly DelegationEntry[],
1184
1195
  maxTerminal = 8,
1196
+ now = Date.now(),
1197
+ staleThresholdMs = 30 * 60 * 1000, // 30 minutes
1185
1198
  ): DelegationEntry[] {
1186
- const running = all.filter((d) => d.state === 'running')
1199
+ const running = all
1200
+ .filter((d) => d.state === 'running')
1201
+ .map((d) => markStaleIfRunning(d, now, staleThresholdMs))
1187
1202
  const terminal = all.filter((d) => d.state !== 'running').slice(0, maxTerminal)
1188
1203
  return [...running, ...terminal]
1189
1204
  }
1190
1205
 
1206
+ /** Default stale-running threshold: 30 minutes. */
1207
+ export const STALE_RUNNING_THRESHOLD_MS = 30 * 60 * 1000
1208
+
1209
+ /** Idle silence window: if no updatedAt change in this window, the entry is
1210
+ * considered stale. Combined with the stale-running threshold to produce the
1211
+ * display-only `stale-running` state. */
1212
+ export const IDLE_SILENCE_MS = 60 * 1000
1213
+
1214
+ /**
1215
+ * Mark a running entry as `stale-running` if it has been running longer than
1216
+ * the threshold AND has no recent activity (no `updatedAt` change in the last
1217
+ * `IDLE_SILENCE_MS`). This is DISPLAY-ONLY — the board state is unchanged.
1218
+ *
1219
+ * A `stale-running` entry renders with a warning indicator but the underlying
1220
+ * delegation is still treated as running by the backend.
1221
+ */
1222
+ export function markStaleIfRunning(
1223
+ entry: DelegationEntry,
1224
+ now: number,
1225
+ thresholdMs = STALE_RUNNING_THRESHOLD_MS,
1226
+ ): DelegationEntry {
1227
+ if (entry.state !== 'running') return entry
1228
+ const elapsed = now - entry.startedAt
1229
+ if (elapsed < thresholdMs) return entry
1230
+ // If updatedAt exists and is recent, entry is still active — not stale.
1231
+ if (entry.updatedAt !== null && now - entry.updatedAt < IDLE_SILENCE_MS) return entry
1232
+ return { ...entry, state: 'stale-running' as const }
1233
+ }
1234
+
1191
1235
  /** Compact elapsed-time label: "5m 12s", "1h 30m", "2d 4h" — ticks every
1192
1236
  * second for running jobs. */
1193
1237
  export function fmtElapsed(ms: number): string {
@@ -1205,7 +1249,8 @@ export function fmtElapsed(ms: number): string {
1205
1249
  /** Elapsed label for one entry: running → ticks `now - startedAt`, terminal
1206
1250
  * → fixed `updatedAt - startedAt` (em dash when no finalized timestamp). */
1207
1251
  export function delegationElapsed(entry: DelegationEntry, now: number): string {
1208
- if (entry.state === 'running') return fmtElapsed(now - entry.startedAt)
1252
+ if (entry.state === 'running' || entry.state === 'stale-running')
1253
+ return fmtElapsed(now - entry.startedAt)
1209
1254
  return entry.updatedAt !== null ? fmtElapsed(entry.updatedAt - entry.startedAt) : '\u2014'
1210
1255
  }
1211
1256
 
@@ -1221,7 +1266,12 @@ export type DelegationActivity =
1221
1266
 
1222
1267
  export function delegationActivity(entry: DelegationEntry): DelegationActivity {
1223
1268
  if (entry.state === 'completed') return 'completed'
1224
- if (entry.state === 'error') return 'error'
1269
+ if (
1270
+ entry.state === 'error' ||
1271
+ entry.state === 'startup_failed' ||
1272
+ entry.state === 'startup_unknown'
1273
+ )
1274
+ return 'error'
1225
1275
  if (entry.state === 'cancelled') return 'cancelled'
1226
1276
  if (entry.read) return 'reading'
1227
1277
  if (entry.alias.startsWith('live-') && entry.taskID === undefined) return 'delegating'
@@ -1274,7 +1324,12 @@ export function mergeChildDelegationSources(
1274
1324
  })
1275
1325
 
1276
1326
  for (const liveEntry of live) {
1277
- if (liveEntry.alias === null && liveEntry.taskID === null && Date.now() - liveEntry.startedAt > 30_000) continue
1327
+ if (
1328
+ liveEntry.alias === null &&
1329
+ liveEntry.taskID === null &&
1330
+ Date.now() - liveEntry.startedAt > 30_000
1331
+ )
1332
+ continue
1278
1333
  const incoming = toDelegationEntry(liveEntry)
1279
1334
  const index =
1280
1335
  (incoming.taskID !== undefined ? byTask.get(incoming.taskID) : undefined) ??
@@ -1292,8 +1347,26 @@ export function mergeChildDelegationSources(
1292
1347
 
1293
1348
  const existing = result[index]
1294
1349
  if (existing === undefined) continue
1295
- const finalized = existing.source === 'md' && existing.state !== 'running'
1296
- if (finalized) continue
1350
+ // Any terminal state is authoritative — a child session that went idle
1351
+ // correctly derives 'completed' via childStatusToState before the MD
1352
+ // report is written. The live tool-part channel stays 'running' until
1353
+ // pantheon_delegation_read fires, so allowing it to overwrite a
1354
+ // terminal state would flip completed → running (the bug this fixes).
1355
+ //
1356
+ // When the existing entry IS terminal, we still absorb live metadata
1357
+ // (alias, agent, read) but preserve the terminal state.
1358
+ if (existing.state !== 'running') {
1359
+ if (liveEntry.alias !== null && existing.alias !== incoming.alias) {
1360
+ existing.alias = incoming.alias
1361
+ }
1362
+ if (incoming.agent !== 'agent' && existing.agent !== incoming.agent) {
1363
+ existing.agent = incoming.agent
1364
+ }
1365
+ if (incoming.read && !existing.read) {
1366
+ existing.read = true
1367
+ }
1368
+ continue
1369
+ }
1297
1370
  result[index] = {
1298
1371
  ...existing,
1299
1372
  alias: liveEntry.alias !== null ? incoming.alias : existing.alias,
@@ -1844,7 +1917,9 @@ export function childrenToDelegationEntries(
1844
1917
  agent: mdEntry?.agent ?? child.agent ?? 'agent',
1845
1918
  state,
1846
1919
  startedAt: mdEntry?.startedAt ?? child.time?.created ?? now,
1847
- updatedAt: mdEntry?.updatedAt ?? (state === 'running' ? null : (child.time?.updated ?? null)),
1920
+ updatedAt:
1921
+ mdEntry?.updatedAt ??
1922
+ (state === 'running' || state === 'stale-running' ? null : (child.time?.updated ?? null)),
1848
1923
  timedOut: mdEntry?.timedOut ?? false,
1849
1924
  description:
1850
1925
  mdEntry !== undefined && mdEntry.description !== ''
@@ -1978,6 +2053,8 @@ function DelegationRow(props: {
1978
2053
  switch (props.job.state) {
1979
2054
  case 'running':
1980
2055
  return t.warning
2056
+ case 'stale-running':
2057
+ return t.error // warning color for stale delegations
1981
2058
  case 'completed':
1982
2059
  return t.success
1983
2060
  case 'error':
@@ -1989,6 +2066,7 @@ function DelegationRow(props: {
1989
2066
 
1990
2067
  const marker = createMemo(() => {
1991
2068
  if (props.job.state === 'running') return `${delegationSpinnerFrame(props.animationNow)} `
2069
+ if (props.job.state === 'stale-running') return '\u26a0 ' // warning triangle
1992
2070
  switch (props.job.state) {
1993
2071
  case 'completed':
1994
2072
  return '\u2713 '
@@ -2220,7 +2298,9 @@ function View(props: {
2220
2298
  }
2221
2299
  const withStatus = children.map((c) => ({ ...c, status: resolveStatus(c.id) }))
2222
2300
  const childEntries = childrenToDelegationEntries(withStatus, md, now())
2223
- for (const [k, v] of props.liveStore.map) if (v.alias === null && v.taskID === null && Date.now() - v.startedAt > 30_000) props.liveStore.map.delete(k)
2301
+ for (const [k, v] of props.liveStore.map)
2302
+ if (v.alias === null && v.taskID === null && Date.now() - v.startedAt > 30_000)
2303
+ props.liveStore.map.delete(k)
2224
2304
  // The event channel is optimistic: it renders a job even while the
2225
2305
  // child session/report is still being created. Filter by parent so a
2226
2306
  // different focused session never leaks rows into this sidebar.
@@ -2488,7 +2568,9 @@ const tui: TuiPlugin = (api, _options, _meta) => {
2488
2568
  order: 900,
2489
2569
  slots: {
2490
2570
  sidebar_content(_ctx, props) {
2491
- return <View api={api} sessionID={props.session_id} version={version()} liveStore={liveStore} />
2571
+ return (
2572
+ <View api={api} sessionID={props.session_id} version={version()} liveStore={liveStore} />
2573
+ )
2492
2574
  },
2493
2575
  },
2494
2576
  })
@@ -1,5 +1,5 @@
1
- import { defineConfig } from 'tsdown'
2
1
  import solid from 'rolldown-plugin-solid'
2
+ import { defineConfig } from 'tsdown'
3
3
 
4
4
  export default defineConfig({
5
5
  entry: { tui: 'src/index.tsx', server: 'src/server.ts' },
package/src/routing.yml CHANGED
@@ -151,6 +151,7 @@ presets:
151
151
  agents:
152
152
  zeus:
153
153
  role: Central orchestrator
154
+ max_steps: 40 # R4: step budget — at max_steps the agent is forced to summarize-and-stop. Tracker is permanent-per-process (no reset wiring).
154
155
  reasoning_effort: medium
155
156
  user_invocable: true
156
157
  can_delegate: true
@@ -189,6 +190,7 @@ agents:
189
190
  - incremental-implementation
190
191
  athena:
191
192
  role: Strategic planner
193
+ max_steps: 15 # R4: step budget — at max_steps the agent is forced to summarize-and-stop
192
194
  reasoning_effort: high
193
195
  user_invocable: true
194
196
  can_delegate: true
@@ -208,6 +210,7 @@ agents:
208
210
  - memory-bank
209
211
  apollo:
210
212
  role: Investigation scout
213
+ max_steps: 25 # R4: step budget — at max_steps the agent is forced to summarize-and-stop
211
214
  reasoning_effort: low
212
215
  user_invocable: false
213
216
  can_delegate: false
@@ -225,6 +228,7 @@ agents:
225
228
  - auto-continue
226
229
  hermes:
227
230
  role: Backend specialist
231
+ max_steps: 60 # R4: step budget — at max_steps the agent is forced to summarize-and-stop
228
232
  reasoning_effort: medium
229
233
  user_invocable: true
230
234
  can_delegate: true
@@ -247,6 +251,7 @@ agents:
247
251
  - incremental-implementation
248
252
  aphrodite:
249
253
  role: Frontend specialist
254
+ max_steps: 60 # R4: step budget — at max_steps the agent is forced to summarize-and-stop
250
255
  reasoning_effort: medium
251
256
  user_invocable: true
252
257
  can_delegate: false
@@ -267,6 +272,7 @@ agents:
267
272
  - file-prompts
268
273
  demeter:
269
274
  role: Database specialist
275
+ max_steps: 50 # R4: step budget — at max_steps the agent is forced to summarize-and-stop
270
276
  reasoning_effort: medium
271
277
  user_invocable: true
272
278
  can_delegate: false
@@ -286,6 +292,7 @@ agents:
286
292
  - incremental-implementation
287
293
  themis:
288
294
  role: Quality gate
295
+ max_steps: 25 # R4: step budget — at max_steps the agent is forced to summarize-and-stop
289
296
  reasoning_effort: high
290
297
  user_invocable: true
291
298
  can_delegate: false
@@ -307,6 +314,7 @@ agents:
307
314
  - context-compression
308
315
  prometheus:
309
316
  role: Infrastructure specialist
317
+ max_steps: 30 # R4: step budget — at max_steps the agent is forced to summarize-and-stop
310
318
  reasoning_effort: medium
311
319
  user_invocable: true
312
320
  can_delegate: false
@@ -325,6 +333,7 @@ agents:
325
333
  - incremental-implementation
326
334
  hephaestus:
327
335
  role: AI pipelines
336
+ max_steps: 50 # R4: step budget — at max_steps the agent is forced to summarize-and-stop
328
337
  reasoning_effort: medium
329
338
  user_invocable: true
330
339
  can_delegate: false
@@ -343,6 +352,7 @@ agents:
343
352
  - auto-continue
344
353
  nyx:
345
354
  role: Observability
355
+ max_steps: 25 # R4: step budget — at max_steps the agent is forced to summarize-and-stop
346
356
  reasoning_effort: low
347
357
  user_invocable: true
348
358
  can_delegate: false
@@ -361,6 +371,7 @@ agents:
361
371
  - auto-continue
362
372
  gaia:
363
373
  role: Remote sensing domain specialist
374
+ max_steps: 25 # R4: step budget — at max_steps the agent is forced to summarize-and-stop
364
375
  reasoning_effort: low
365
376
  user_invocable: false
366
377
  can_delegate: false
@@ -375,6 +386,7 @@ agents:
375
386
  - auto-continue
376
387
  iris:
377
388
  role: GitHub operations
389
+ max_steps: 20 # R4: step budget — at max_steps the agent is forced to summarize-and-stop
378
390
  reasoning_effort: low
379
391
  user_invocable: true
380
392
  can_delegate: false
@@ -392,6 +404,7 @@ agents:
392
404
  - artifact-management
393
405
  mnemosyne:
394
406
  role: Memory bank
407
+ max_steps: 20 # R4: step budget — at max_steps the agent is forced to summarize-and-stop
395
408
  reasoning_effort: low
396
409
  user_invocable: true
397
410
  can_delegate: false
@@ -415,6 +428,7 @@ agents:
415
428
  - session-goal
416
429
  talos:
417
430
  role: Hotfix agent
431
+ max_steps: 15 # R4: step budget — at max_steps the agent is forced to summarize-and-stop
418
432
  reasoning_effort: low
419
433
  user_invocable: true
420
434
  can_delegate: false
@@ -573,6 +587,34 @@ background_delegation:
573
587
  read_only_agents: [apollo, gaia]
574
588
  session_max: 3
575
589
  retry_count: 1
590
+ # ── R1: Per-error-type retry + provider cooldown (LiteLLM pattern) ──────
591
+ # retry_policy: max retries per error class before escalating. Exponential
592
+ # backoff (base 1s, doubling, capped 30s) applies between retries. auth
593
+ # errors are NEVER retried (0) — a credential problem will not fix itself.
594
+ # Enforcement: zeus-delegate-with-retry (manual waves) + RetryPolicyEngine.
595
+ retry_policy:
596
+ auth: 0
597
+ rate_limit: 3
598
+ timeout: 2
599
+ other: 1
600
+ # cooldown: after `allowed_fails` consecutive failures for a provider, skip
601
+ # that provider for `cooldown_time_seconds`. A success resets the counter.
602
+ # In-memory tracker (no Redis) — process-wide, shared across retry helpers.
603
+ cooldown:
604
+ allowed_fails: 3
605
+ cooldown_time_seconds: 60
606
+ # ── O5: permission.task — glob rules for which subagents an agent may
607
+ # invoke (last matching rule wins; deny removes the agent from the delegate
608
+ # tool description entirely — prevents circular delegation at the
609
+ # permission layer). Default "*": allow keeps the existing runtime matrix
610
+ # (zeus → anyone; athena/hermes → apollo only). Example deny-first config:
611
+ # permission:
612
+ # task:
613
+ # "*": deny
614
+ # "orchestrator-*": allow
615
+ permission:
616
+ task:
617
+ "*": allow
576
618
  todo_enforcer:
577
619
  enabled: true
578
620
  cooldown_base_ms: 5000 # base backoff between injections