@gavin-hu/my-pi-agent 0.19.0 → 0.20.0

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 (158) hide show
  1. package/README.md +17 -11
  2. package/README.zh-CN.md +13 -8
  3. package/docs/extensions/README.md +1 -1
  4. package/docs/extensions/board.md +286 -123
  5. package/docs/extensions/bytefly.md +381 -223
  6. package/docs/extensions/file-browse.md +14 -7
  7. package/docs/extensions/job.md +1 -2
  8. package/docs/extensions/plan.md +56 -11
  9. package/docs/extensions/session-bus.md +23 -10
  10. package/docs/extensions/team.md +45 -17
  11. package/docs/extensions/wechat.md +2 -1
  12. package/docs/extensions/worktree.md +30 -15
  13. package/extensions/board/commands.ts +23 -5
  14. package/extensions/board/format.ts +107 -9
  15. package/extensions/board/join.ts +1 -1
  16. package/extensions/board/page.ts +170 -94
  17. package/extensions/board/result.ts +9 -0
  18. package/extensions/board/schema.ts +22 -7
  19. package/extensions/board/seat.ts +9 -10
  20. package/extensions/board/snapshot.ts +1 -0
  21. package/extensions/board/tools.ts +186 -21
  22. package/extensions/board/transport/router.ts +27 -108
  23. package/extensions/board/transport/server.ts +18 -29
  24. package/extensions/board/types.ts +67 -0
  25. package/extensions/board/view/client.ts +23 -13
  26. package/extensions/board/view/dialog.ts +43 -0
  27. package/extensions/board/view/keys.ts +28 -24
  28. package/extensions/board/view/live.ts +5 -171
  29. package/extensions/board/view/names.ts +91 -0
  30. package/extensions/board/view/page.ts +48 -88
  31. package/extensions/board/view/render.ts +213 -190
  32. package/extensions/board/view/styles.ts +65 -48
  33. package/extensions/board/view/team.ts +63 -41
  34. package/extensions/bytefly/footer/budget.ts +6 -5
  35. package/extensions/bytefly/footer/collect/entries.ts +12 -4
  36. package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
  37. package/extensions/bytefly/footer/config.ts +19 -8
  38. package/extensions/bytefly/footer/price/price.ts +30 -8
  39. package/extensions/bytefly/footer/register.ts +5 -5
  40. package/extensions/bytefly/footer/render/format.ts +18 -0
  41. package/extensions/bytefly/footer/render/lines.ts +23 -7
  42. package/extensions/bytefly/footer/render/segments.ts +117 -31
  43. package/extensions/bytefly/footer/types.ts +29 -5
  44. package/extensions/bytefly/footer/unpriced.ts +42 -17
  45. package/extensions/bytefly/panel/commands.ts +28 -1
  46. package/extensions/bytefly/panel/filter.ts +68 -0
  47. package/extensions/bytefly/panel/format.ts +16 -15
  48. package/extensions/bytefly/panel/join.ts +56 -13
  49. package/extensions/bytefly/panel/prices.ts +43 -0
  50. package/extensions/bytefly/panel/settings.ts +72 -23
  51. package/extensions/bytefly/panel/tui/entries.ts +26 -8
  52. package/extensions/bytefly/panel/tui/frame.ts +130 -93
  53. package/extensions/bytefly/panel/tui/keys.ts +352 -0
  54. package/extensions/bytefly/panel/tui/list-view.ts +25 -46
  55. package/extensions/bytefly/panel/tui/panes.ts +76 -0
  56. package/extensions/bytefly/panel/tui/section-view.ts +126 -0
  57. package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
  58. package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
  59. package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
  60. package/extensions/bytefly/panel/tui.ts +306 -259
  61. package/extensions/check/commands.ts +2 -1
  62. package/extensions/file-browse/files/classify.ts +136 -0
  63. package/extensions/file-browse/files/files.ts +8 -547
  64. package/extensions/file-browse/files/index.ts +90 -0
  65. package/extensions/file-browse/files/list.ts +261 -0
  66. package/extensions/file-browse/files/tree.ts +101 -0
  67. package/extensions/file-browse/transport/router.ts +0 -2
  68. package/extensions/file-browse/view/emit.ts +51 -0
  69. package/extensions/file-browse/view/html.ts +6 -7
  70. package/extensions/file-browse/view/markdown.ts +11 -13
  71. package/extensions/file-browse/view/raw-html.ts +18 -13
  72. package/extensions/job/runtime.ts +4 -4
  73. package/extensions/plan/feedback.ts +40 -16
  74. package/extensions/plan/format.ts +10 -3
  75. package/extensions/plan/index.ts +17 -1
  76. package/extensions/plan/plan-files.ts +88 -0
  77. package/extensions/plan/plans.ts +19 -23
  78. package/extensions/plan/policy.ts +10 -5
  79. package/extensions/plan/runtime.ts +31 -2
  80. package/extensions/plan/todos.ts +51 -3
  81. package/extensions/plan/tools/exit.ts +10 -1
  82. package/extensions/plan/tools/todo-seed.ts +4 -2
  83. package/extensions/plan/tools/write.ts +12 -1
  84. package/extensions/plan/types.ts +15 -0
  85. package/extensions/plan/web-blocks.ts +89 -0
  86. package/extensions/plan/web-chrome.ts +3 -5
  87. package/extensions/plan/web-client.ts +179 -330
  88. package/extensions/plan/web-collab.ts +3 -3
  89. package/extensions/plan/web-comments.ts +18 -8
  90. package/extensions/plan/web-composer.ts +80 -0
  91. package/extensions/plan/web-draft.ts +40 -0
  92. package/extensions/plan/web-keys.ts +134 -0
  93. package/extensions/plan/web-outline.ts +192 -0
  94. package/extensions/plan/web-page.ts +4 -1
  95. package/extensions/plan/web-plan.ts +150 -0
  96. package/extensions/plan/web-router.ts +0 -1
  97. package/extensions/plan/web-settle.ts +194 -0
  98. package/extensions/rewind/commands/review.ts +2 -1
  99. package/extensions/session-bus/rows.ts +7 -3
  100. package/extensions/session-bus/runtime.ts +100 -38
  101. package/extensions/session-bus/store.ts +14 -2
  102. package/extensions/session-bus/tui.ts +1 -1
  103. package/extensions/session-bus/types.ts +10 -1
  104. package/extensions/subagent/commands.ts +2 -1
  105. package/extensions/team/commands.ts +41 -8
  106. package/extensions/team/format.ts +14 -0
  107. package/extensions/team/index.ts +2 -1
  108. package/extensions/team/tools.ts +30 -9
  109. package/extensions/team/types.ts +17 -0
  110. package/extensions/todo/index.ts +4 -1
  111. package/extensions/todo/nudge.ts +8 -2
  112. package/extensions/wechat/bridge.ts +43 -172
  113. package/extensions/wechat/intake.ts +245 -0
  114. package/extensions/worktree/commands.ts +24 -17
  115. package/extensions/worktree/exit.ts +12 -6
  116. package/extensions/worktree/git.ts +22 -14
  117. package/extensions/worktree/guard.ts +15 -285
  118. package/extensions/worktree/handoff.ts +10 -2
  119. package/extensions/worktree/index.ts +2 -1
  120. package/extensions/worktree/lifecycle.ts +2 -0
  121. package/extensions/worktree/registry.ts +20 -11
  122. package/extensions/worktree/root-tools.ts +13 -3
  123. package/extensions/worktree/runtime.ts +8 -2
  124. package/extensions/worktree/screen.ts +15 -8
  125. package/extensions/worktree/shell.ts +303 -0
  126. package/extensions/worktree/state.ts +1 -1
  127. package/extensions/worktree/status.ts +42 -9
  128. package/extensions/worktree/tools.ts +19 -13
  129. package/lib/README.md +6 -5
  130. package/lib/board/card.ts +192 -3
  131. package/lib/board/links.ts +26 -2
  132. package/lib/board/page-pointer.ts +86 -32
  133. package/lib/board/round-text.ts +11 -2
  134. package/lib/board/round.ts +25 -20
  135. package/lib/board/rules.ts +144 -11
  136. package/lib/board/store.ts +239 -52
  137. package/lib/board/view.ts +94 -10
  138. package/lib/format.ts +22 -0
  139. package/lib/http/serve.ts +3 -0
  140. package/lib/http/sink.ts +34 -9
  141. package/lib/plan-entry.ts +11 -0
  142. package/lib/policy.ts +14 -1
  143. package/lib/session-roster.ts +74 -3
  144. package/lib/spawn.ts +37 -3
  145. package/lib/stray-fields.ts +45 -0
  146. package/lib/tool-names.ts +13 -0
  147. package/lib/tui/glyphs.ts +2 -3
  148. package/lib/web/README.md +52 -2
  149. package/lib/web/client-runtime.ts +123 -0
  150. package/lib/web/index.ts +4 -2
  151. package/lib/web/markup.ts +88 -1
  152. package/lib/web/names.ts +42 -0
  153. package/package.json +1 -1
  154. package/extensions/board/view/actions.ts +0 -306
  155. package/extensions/board/writes.ts +0 -99
  156. package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
  157. package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
  158. package/extensions/job/process.ts +0 -35
@@ -30,35 +30,32 @@ ${WEB_COMPONENTS_CSS}
30
30
  .board-progress{margin:0}
31
31
  .board-picker{display:none;margin-bottom:var(--space-3)}
32
32
 
33
- /* The new card is a kit dialog, so the board is what the page opens on. The form is
34
- the panel's own column: its body scrolls and its actions stay pinned. */
35
- .board-composer{display:flex;flex-direction:column;min-height:0}
36
- /* One column, label above the box: the dialog is 640px wide, and the 2fr/1fr pair it
37
- inherited from the drawer left the path hints 197px and their label inside the box. */
38
- .board-composer__fields{display:flex;flex-direction:column;gap:var(--space-3)}
39
- .board-composer__field{display:flex;flex-direction:column;gap:var(--space-1);min-width:0}
40
- .board-composer__label{display:flex;align-items:baseline;justify-content:space-between;gap:var(--space-2);font:var(--fw-medium) var(--fs-xs) var(--font-ui);color:var(--muted)}
41
- .board-composer__count{font:var(--fs-xs) var(--font-mono);color:var(--dim)}
42
- /* A select inside a field: the native caret is the affordance, so it keeps it, and the
43
- field's own surface is what it sits on rather than the platform's white box. */
44
- .board-composer__select{background:transparent;color:var(--text);cursor:pointer}
45
- .board-composer__fields .bf-field{min-width:0}
46
- /* The title is the one thing this dialog exists to ask for, so it leads. */
47
- .board-composer__field--title .bf-field{padding:var(--space-2) var(--space-3)}
48
- .board-composer__field--title .bf-input{font-size:var(--fs-md)}
49
-
50
33
  /* The main pane is this page's only child of the shell, so it grows to fill the
51
34
  column and the footer stays at the bottom however short the board is. */
52
35
  .board-main{flex:1 1 auto}
53
36
  /* One panel per tab; the shell's main pane is the only scroll box. */
54
37
  .board-panel{display:flex;flex-direction:column;min-width:0}
55
38
  .board-panel[hidden]{display:none}
56
- .board-banner{margin:0 0 var(--space-4)}
57
39
  .board-no-cards{max-width:32rem;margin:auto;padding:var(--space-8) var(--space-6)}
58
40
 
59
- /* Lanes: four trays of cards, each with a head that sticks while the pane scrolls. */
60
- .board-lanes{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--space-4);align-items:start}
61
- .board-lane{display:flex;flex-direction:column;gap:var(--space-2);min-width:0;padding:0 var(--space-2) var(--space-3);border:1px solid var(--border);border-radius:var(--radius-lg);background:var(--surface)}
41
+ /* Lanes: trays of cards, each with a head that sticks while the pane scrolls. A lane is
42
+ sized by the work it holds rather than by its share of the row, so one holding nothing
43
+ narrows to a rail and hands the width to the lanes carrying cards. */
44
+ .board-lanes{display:flex;flex-wrap:nowrap;gap:var(--space-4);align-items:start}
45
+ .board-lane{--lane-count:0;display:flex;flex:1 1 0;flex-grow:calc(1 + var(--lane-count));flex-direction:column;gap:var(--space-2);min-width:0;padding:0 var(--space-2) var(--space-3);border:1px solid var(--border);border-radius:var(--radius-lg);background:var(--surface)}
46
+ /* The lane count is a count, not a design token: the client sets it from the snapshot and
47
+ declares its default here, so a lane whose count never arrived is an ordinary lane
48
+ rather than a variable nobody defines. */
49
+ /* A lane's share of the row follows the work it holds — two cards earn more width than
50
+ one — and the floor is the narrowest a tray is still a tray at. It is set on the two
51
+ states that draw one, so a rail keeps its own width, and both breakpoints below
52
+ replace the basis, so the floor only ever applies to the row it was chosen for:
53
+ four lanes at 1180px are 60rem, which is inside the row before the wrap. */
54
+ .board-lane[data-empty="false"][data-folded="false"]{min-width:15rem}
55
+ /* The rail: a dot, a word, and a count — the fact that the lane is showing nothing, not a
56
+ tray. A lane is a rail when it holds no cards and when it is folded, because both say
57
+ the same thing: there is nothing here to read. */
58
+ .board-lane[data-empty="true"],.board-lane[data-folded="true"]{flex:0 0 8.5rem}
62
59
  .board-lane__head{position:sticky;top:0;z-index:1;display:flex;align-items:center;gap:var(--space-2);margin:0 calc(-1 * var(--space-2)) var(--space-1);padding:var(--space-2) var(--space-2);background:var(--surface);border-bottom:1px solid var(--border);border-radius:var(--radius-lg) var(--radius-lg) 0 0;font:var(--fw-semibold) var(--fs-xs) var(--font-ui);letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}
63
60
  .board-lane__toggle{appearance:none;display:flex;align-items:center;gap:var(--space-2);border:0;background:none;padding:0;font:inherit;letter-spacing:inherit;text-transform:inherit;color:inherit;cursor:pointer}
64
61
  .board-lane__toggle:hover{color:var(--accent)}
@@ -69,7 +66,6 @@ ${WEB_COMPONENTS_CSS}
69
66
  .board-lane__dot{width:var(--space-2);height:var(--space-2);border-radius:var(--radius-pill);background:var(--muted)}
70
67
  .board-lane[data-status="doing"] .board-lane__dot{background:var(--accent)}
71
68
  .board-lane[data-status="blocked"] .board-lane__dot{background:var(--danger)}
72
- .board-lane[data-status="done"]{background:var(--canvas)}
73
69
  .board-lane[data-status="done"] .board-lane__dot{background:var(--success)}
74
70
  .board-lane__list{display:flex;flex-direction:column;gap:var(--space-3);margin:0;padding:0;list-style:none}
75
71
  .board-lane__empty{margin:0;font:var(--fs-sm) var(--font-ui);color:var(--dim)}
@@ -89,10 +85,9 @@ ${WEB_COMPONENTS_CSS}
89
85
  .board-card[data-status="done"] .bf-chip--accent{color:var(--muted);border-color:var(--border)}
90
86
  .board-card__meta{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-2);margin:0;font:var(--fs-xs) var(--font-ui);color:var(--muted)}
91
87
  .board-card__paths{display:flex;flex-wrap:wrap;gap:var(--space-1);margin:0;padding:0;list-style:none}
92
- .board-card__reason{margin:0;font:var(--fs-xs) var(--font-ui);color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
88
+ .board-card__reason{margin:0;font:var(--fs-sm)/var(--lh-snug) var(--font-ui);color:var(--muted);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;overflow-wrap:anywhere}
93
89
  .board-card__reason--blocked{color:var(--danger)}
94
90
  .board-card__reason--unplanned{color:var(--warn)}
95
- .board-card__actions{display:flex;flex-wrap:wrap;gap:var(--space-2);margin-top:var(--space-1)}
96
91
 
97
92
  /* The Team tab: a queue and a roster side by side, then one quiet strip. */
98
93
  .board-team{display:grid;gap:var(--space-5);min-width:0}
@@ -137,56 +132,78 @@ ${WEB_COMPONENTS_CSS}
137
132
  .board-team__dot{width:8px;height:8px;border-radius:var(--radius-pill);background:var(--muted);flex:0 0 auto;align-self:center}
138
133
  .board-team__mate[data-state="live"] .board-team__dot{background:var(--success)}
139
134
  .board-team__mate[data-state="quiet"] .board-team__dot{background:var(--warn)}
135
+ /* A dead session is the one state the page states outright: solid, and in the refusal colour. */
136
+ .board-team__mate[data-state="gone"] .board-team__dot{background:var(--danger)}
140
137
  /* A record that is not there is a hollow ring, never a colour that claims it is. */
141
138
  .board-team__mate[data-state="absent"] .board-team__dot,.board-team__mate[data-state="unknown"] .board-team__dot{background:transparent;box-shadow:inset 0 0 0 1px var(--border-strong)}
142
139
  .board-team__name{font:var(--fw-medium) var(--fs-md) var(--font-ui);color:var(--text)}
143
140
  .board-team__mets{flex:1 0 100%;margin:0;color:var(--muted);font:var(--fs-xs) var(--font-ui)}
144
141
  .board-team__empty{margin:0;color:var(--muted);font:var(--fs-sm) var(--font-ui)}
145
142
  /* Done stays quiet; its ids are buttons only because they open their card. */
146
- .board-team__done,.board-done{margin:0;font:var(--fs-sm) var(--font-ui);color:var(--muted);display:flex;flex-wrap:wrap;align-items:baseline;gap:var(--space-2)}
143
+ .board-team__done{margin:0;font:var(--fs-sm) var(--font-ui);color:var(--muted);display:flex;flex-wrap:wrap;align-items:baseline;gap:var(--space-2)}
147
144
  /* An id in the Done strip is a button, so it wears the only affordance it has: a
148
145
  dotted line, visible before the pointer arrives. */
149
146
  .board-team__id{appearance:none;border:0;background:none;padding:0;margin:0;cursor:pointer;font:var(--fs-sm) var(--font-mono);color:var(--muted);text-decoration:underline dotted;text-underline-offset:3px}
150
147
  .board-team__id:hover{color:var(--accent);text-decoration:underline}
151
148
 
152
- /* The card dialog: a meta line, the notes as a timeline, and the actions at its foot. */
153
- .board-dialog__meta,.board-dialog__owner{margin:0 0 var(--space-2);font:var(--fs-sm) var(--font-ui);color:var(--muted)}
154
- .board-dialog__plan{margin:0 0 var(--space-2);font:var(--fs-xs) var(--font-mono);color:var(--dim);word-break:break-all}
155
- /* What the card waits for is a fact about its place in the work, not a record of it. */
156
- .board-dialog__depends{margin:0 0 var(--space-2);font:var(--fs-xs) var(--font-mono);color:var(--muted);word-break:break-all}
157
- .board-dialog__notes{display:flex;flex-direction:column;gap:var(--space-3);margin:0 0 var(--space-3);padding:0;list-style:none;max-height:16rem;overflow:auto}
149
+ /* The card dialog: the record of one card, in two named sections. Nothing here scrolls on
150
+ its own — the kit's body is the one scrollbar, so a reader is never inside a scroll
151
+ inside a scroll. */
152
+ /* A little taller than the kit's default: this dialog is the only place a whole card —
153
+ its facts, its plan, and every note it kept — can be read. */
154
+ .board-dialog__panel{width:min(720px,100%);max-height:84vh}
155
+ /* A long title wraps beside its id instead of pushing the close button off the head. */
156
+ .board-dialog__head{flex-wrap:wrap;align-items:baseline;row-gap:var(--space-1)}
157
+ .board-dialog__head h2{flex:1 1 12ch;min-width:0;font-size:var(--fs-lg);line-height:var(--lh-tight);overflow-wrap:anywhere}
158
+ .board-dialog__id{font:var(--fs-sm) var(--font-mono);color:var(--muted)}
159
+ .board-dialog__body{display:flex;flex-direction:column;gap:var(--space-4);padding-bottom:var(--space-4)}
160
+ .board-dialog__section{display:flex;flex-direction:column;gap:var(--space-2);min-width:0}
161
+ /* The heading is a label, not a headline: the title above it is the only headline here. */
162
+ .board-dialog__heading{display:flex;align-items:baseline;gap:var(--space-1);margin:0;padding-bottom:var(--space-1);border-bottom:1px solid var(--border);font:var(--fw-medium) var(--fs-xs) var(--font-ui);letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}
163
+ .board-dialog__count{font:var(--fs-xs) var(--font-mono);letter-spacing:0;text-transform:none;color:var(--dim)}
164
+ /* The ladder: labels size to the longest one, values take the width that is left, and
165
+ every row starts at the same column so the eye can run down the facts. */
166
+ .board-dialog__facts{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:var(--space-1) var(--space-3);margin:0;align-items:baseline}
167
+ .board-dialog__facts dt{font:var(--fs-xs) var(--font-ui);letter-spacing:.04em;text-transform:uppercase;color:var(--muted)}
168
+ .board-dialog__facts dd{margin:0;min-width:0;font:var(--fs-sm) var(--font-ui);color:var(--text)}
169
+ /* A path is a value like any other, but a long one elides on its single line and keeps the
170
+ whole of itself on the hover title, so a 90-character plan cannot shred the ladder. */
171
+ .board-dialog__value{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
172
+ /* A plan path and the ids a card waits for are code, and a reader copies them. */
173
+ .board-dialog__plan{font-family:var(--font-mono)}
174
+ .board-dialog__paths{display:flex;flex-wrap:wrap;gap:var(--space-1) var(--space-2);margin:0;padding:0;list-style:none;min-width:0}
175
+ .board-dialog__paths li{min-width:0;max-width:100%}
176
+ /* A path is code, not a label: the owner is a chip of prose, a path is something you type. */
177
+ .board-dialog__path{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:bottom;font-family:var(--font-mono)}
178
+ .board-dialog__notes{display:flex;flex-direction:column;gap:var(--space-3);margin:0;padding:0;list-style:none}
158
179
  /* A rule between notes: wrapped prose used to run one note into the next. */
159
180
  .board-dialog__note{padding-top:var(--space-3);border-top:1px solid var(--border)}
160
181
  .board-dialog__note:first-child{padding-top:0;border-top:0}
161
- .board-dialog__note-head{display:flex;align-items:baseline;justify-content:space-between;gap:var(--space-2);margin:0 0 2px}
162
- .board-dialog__note-by{font:var(--fw-medium) var(--fs-xs) var(--font-ui);color:var(--text)}
163
- .board-dialog__note-at{font:var(--fs-xs) var(--font-mono);color:var(--muted)}
164
- .board-dialog__note-text{margin:0;font:var(--fs-sm)/var(--lh-normal) var(--font-ui);color:var(--text);overflow-wrap:anywhere}
165
- .board-dialog__move{display:flex;align-items:center;gap:var(--space-2);margin-right:auto}
166
- /* Assigning records an owner the page cannot tell anybody about, so it sits with the
167
- other card actions and takes a name from the roster. */
168
- .board-dialog__assign{display:flex;align-items:center;gap:var(--space-2);margin-right:auto}
169
- .board-dialog__assign-select{width:auto;min-width:8rem;border:1px solid var(--border);border-radius:var(--radius-md);background:var(--canvas);padding:2px var(--space-2);cursor:pointer}
170
- .board-dialog__label{font:var(--fs-xs) var(--font-ui);color:var(--muted)}
182
+ /* The sentence is the record, so it reads at body size on a measure, not the full 720px. */
183
+ .board-dialog__note-text{margin:0 0 var(--space-1);max-width:68ch;font:var(--fs-base)/var(--lh-relaxed) var(--font-ui);color:var(--text);overflow-wrap:anywhere;white-space:pre-wrap}
184
+ /* Who and when is the footnote of the note above it. */
185
+ .board-dialog__note-foot{display:flex;align-items:baseline;gap:var(--space-2);margin:0;max-width:68ch}
186
+ .board-dialog__note-by{font:var(--fw-medium) var(--fs-xs) var(--font-ui);color:var(--muted)}
187
+ .board-dialog__note-at{font:var(--fs-xs) var(--font-mono);color:var(--dim)}
171
188
  .board-dialog__state{display:inline-flex;align-items:center;gap:var(--space-2);font:var(--fs-xs) var(--font-ui);letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}
172
189
  .board-dialog__state::before{content:"";width:var(--space-2);height:var(--space-2);border-radius:var(--radius-pill);background:var(--muted)}
173
190
  .board-dialog__state[data-status="doing"]::before{background:var(--accent)}
174
191
  .board-dialog__state[data-status="blocked"]::before{background:var(--danger)}
175
192
  .board-dialog__state[data-status="done"]::before{background:var(--success)}
176
- .board-dialog__note-composer{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:var(--space-2);padding:0;border:0;background:none}
177
- .board-dialog__note-composer .bf-field{margin:0}
178
- .board-dialog__note-composer .bf-composer__row{margin:0}
179
- .board-confirm__body{margin:0}
180
193
  .board-skeleton{height:4.5rem;border:1px dashed var(--border);border-radius:var(--radius-md);background:var(--surface)}
181
194
 
182
195
  @media (max-width:1180px){
183
- .board-lanes{grid-template-columns:repeat(2,minmax(0,1fr))}
196
+ .board-lanes{flex-wrap:wrap}
197
+ .board-lane{flex:1 1 calc(50% - var(--space-4))}
184
198
  .board-progress{display:none}
185
199
  /* Two panels need a wide window; below this the queue comes first, the roster after. */
186
200
  .board-team__grid{grid-template-columns:minmax(0,1fr)}
187
201
  }
188
202
  @media (max-width:860px){
189
- .board-lanes{grid-template-columns:minmax(0,1fr)}
203
+ .board-lanes{flex-direction:column;align-items:stretch}
204
+ .board-lane{flex:1 1 auto}
205
+ /* One lane at a time, so a rail buys nothing: the shown lane takes the column. */
206
+ .board-lane[data-empty="true"],.board-lane[data-folded="true"]{flex:1 1 auto}
190
207
  .board-lane[hidden]{display:none}
191
208
  .board-picker{display:flex}
192
209
  .board-toolbar{gap:var(--space-2)}
@@ -205,7 +222,7 @@ ${WEB_COMPONENTS_CSS}
205
222
  .board-lanes,.board-team__grid{grid-template-columns:minmax(0,1fr)}
206
223
  .board-card{break-inside:avoid}
207
224
  /* The disclosure affordance goes; the lane's name stays with its count. */
208
- .board-card__actions,.board-composer,.board-filter,.board-picker,.board-done{display:none!important}
225
+ .board-filter,.board-picker{display:none!important}
209
226
  .board-lane__toggle::after{content:""}
210
227
  }
211
228
  `;
@@ -21,12 +21,11 @@ export const BOARD_TEAM_JS = String.raw`(function () {
21
21
  var board = window.bfBoard || (window.bfBoard = {});
22
22
  var el = board.el;
23
23
  var chip = board.chip;
24
- var actionButton = board.actionButton;
25
24
  var ago = board.ago;
26
25
  var plural = board.plural;
27
26
 
28
27
  /** The word a teammate's state is read as; never a colour alone. */
29
- var STATE_WORD = { live: "live", quiet: "quiet", absent: "no record", unknown: "\u2014" };
28
+ var STATE_WORD = { live: "live", quiet: "quiet", gone: "gone", absent: "no record", unknown: "\u2014" };
30
29
 
31
30
  /**
32
31
  * A panel: its heading row, then its body.
@@ -35,9 +34,9 @@ export const BOARD_TEAM_JS = String.raw`(function () {
35
34
  * reader can tell "nothing here" from "nothing matches".
36
35
  */
37
36
  function panel(text, count, total) {
38
- var node = el("section", "board-team__group");
37
+ var node = el("section", N.teamGroup);
39
38
  node.setAttribute("aria-label", text);
40
- var caption = el("h2", "board-team__caption", text);
39
+ var caption = el("h2", N.teamCaption, text);
41
40
  caption.appendChild(chip(total !== undefined && total !== count ? count + "/" + total : String(count)));
42
41
  node.appendChild(caption);
43
42
  return node;
@@ -54,7 +53,18 @@ export const BOARD_TEAM_JS = String.raw`(function () {
54
53
  * one row, and the strongest reason is the one it keeps: a blocker outranks silence,
55
54
  * which outranks a missing plan, which outranks nobody holding it.
56
55
  */
57
- var KIND_RANK = { blocked: 0, waiting: 1, idle: 2, unplanned: 3, unassigned: 4 };
56
+ var KIND_RANK = { blocked: 0, gone: 1, waiting: 2, idle: 3, unplanned: 4, unassigned: 5 };
57
+
58
+ /** The labels of the sessions whose process is gone, in the roster's own spelling. */
59
+ function goneLabels(view) {
60
+ var roster = view.teammates;
61
+ var gone = {};
62
+ if (roster === undefined) return gone;
63
+ for (var i = 0; i < roster.length; i++) {
64
+ if (roster[i].state === "gone") gone[roster[i].label.toLowerCase()] = roster[i].label;
65
+ }
66
+ return gone;
67
+ }
58
68
 
59
69
  function needs(view) {
60
70
  var round = view.round;
@@ -74,10 +84,22 @@ export const BOARD_TEAM_JS = String.raw`(function () {
74
84
  }
75
85
  }
76
86
  }
87
+ // A blocked card in this queue is a person's decision: the reason the move recorded
88
+ // comes first, and the last note is only the fallback for a block nobody explained.
77
89
  add(round.blocked, function (card) {
90
+ if (card.blockedBy) return card.blockedBy;
78
91
  var last = card.notes.length > 0 ? card.notes[card.notes.length - 1] : undefined;
79
92
  return last ? last.text : "blocked";
80
93
  }, "blocked");
94
+ // A dead owner is a fact, not an inference from silence: the card is anyone's now.
95
+ var gone = goneLabels(view);
96
+ add(
97
+ round.owners.reduce(function (cards, group) {
98
+ return gone[group.label.toLowerCase()] === undefined ? cards : cards.concat(group.cards);
99
+ }, []),
100
+ function (card) { return (card.owner ? card.owner.label : "its owner") + " is not running — the card is free"; },
101
+ "gone",
102
+ );
81
103
  add(round.idle, function (card) { return "no touch for " + ago(card.updatedAt); }, "idle");
82
104
  // The board card says the same words for the same fact: one voice for one state.
83
105
  add(round.unplanned, function () { return "no plan recorded"; }, "unplanned");
@@ -85,8 +107,16 @@ export const BOARD_TEAM_JS = String.raw`(function () {
85
107
  // An order outranks nobody holding it: the claim would be refused, so the queue says
86
108
  // what it waits for instead of offering a button the rules would turn down.
87
109
  add(
88
- round.unassigned.filter(function (card) { return board.blockersOf(card, view).length > 0; }),
89
- function (card) { return "waits on " + board.waitingText(card, view); },
110
+ round.unassigned.filter(function (card) { return card.waits !== undefined; }),
111
+ function (card) { return "waits on " + card.waits; },
112
+ "waiting",
113
+ );
114
+ // An offer outranks plain silence: the lead has already tried to hand this card out,
115
+ // and the age is the difference between nobody having looked and nobody taking it. A
116
+ // card that waits keeps that reason, because the claim would be refused either way.
117
+ add(
118
+ round.unassigned.filter(function (card) { return card.offeredAt !== undefined; }),
119
+ function (card) { return "offered " + ago(card.offeredAt) + " ago — nobody took it"; },
90
120
  "waiting",
91
121
  );
92
122
  return entries;
@@ -95,42 +125,32 @@ export const BOARD_TEAM_JS = String.raw`(function () {
95
125
  /** One decision: the card, why it is here, who holds it, and the way out. */
96
126
  function decision(entry, view) {
97
127
  var card = entry.card;
98
- var node = el("article", "board-team__decision");
128
+ var node = el("article", N.teamDecision);
99
129
  node.setAttribute("data-kind", entry.kind);
100
130
 
101
- var open = el("button", "board-team__open");
131
+ var open = el("button", N.teamOpen);
102
132
  open.type = "button";
103
133
  open.setAttribute("aria-haspopup", "dialog");
104
- open.appendChild(el("span", "board-team__ref", "#" + card.id));
105
- var title = el("span", "board-team__title", card.title);
134
+ open.appendChild(el("span", N.teamRef, "#" + card.id));
135
+ var title = el("span", N.teamTitle, card.title);
106
136
  // A clamped title has no other way to be read, so it carries its own tooltip.
107
137
  title.title = card.title;
108
138
  open.appendChild(title);
109
139
  open.addEventListener("click", function () { board.openCard(card.id); });
110
140
  node.appendChild(open);
111
141
 
112
- var foot = el("div", "board-team__foot");
142
+ var foot = el("div", N.teamFoot);
113
143
  if (card.owner) foot.appendChild(chip(card.owner.label, "accent"));
114
- // The same permission table the card face and the dialog ask: a row offers the way out
115
- // that this reader may actually take, and a card that waits offers none because a
116
- // claim on it would be refused.
117
- if (board.may(card, view, "reclaim")) {
118
- var reclaim = actionButton("Reclaim", function () { board.act("reclaim", card); });
119
- // Six identical buttons read as six identical buttons; the card is the name.
120
- reclaim.setAttribute("aria-label", "Reclaim #" + card.id);
121
- foot.appendChild(reclaim);
122
- } else if (board.may(card, view, "claim")) {
123
- var claim = actionButton("Claim", function () { board.act("claim", card); });
124
- claim.setAttribute("aria-label", "Claim #" + card.id);
125
- foot.appendChild(claim);
126
- }
144
+ // No button: this page cannot claim or reclaim, and a page that offered either would
145
+ // be teaching a verb the server refuses. The row is a reason to pick a card up in the
146
+ // terminal, which is where a claim is made.
127
147
  node.appendChild(foot);
128
148
 
129
149
  // The reason gets the row's whole width and two lines; the tooltip carries the
130
150
  // rest, because this sentence is the lead's whole reason to look at the row.
131
- var why = el("p", "board-team__why", entry.why);
151
+ var why = el("p", N.teamWhy, entry.why);
132
152
  why.title = entry.why;
133
- if (entry.kind === "blocked") why.className = "board-team__why board-team__why--blocked";
153
+ if (entry.kind === "blocked") why.className = N.teamWhy + " " + N.teamWhyBlocked;
134
154
  node.appendChild(why);
135
155
  return node;
136
156
  }
@@ -175,9 +195,11 @@ export const BOARD_TEAM_JS = String.raw`(function () {
175
195
  function rank(mate) {
176
196
  if (mate.you) return -1;
177
197
  if (mate.state === "live") return 0;
178
- if (mate.state === "quiet") return 1;
179
- if (mate.state === "absent") return 2;
180
- return 3;
198
+ // A crash outranks a stale heartbeat: one is news, the other may be a long turn.
199
+ if (mate.state === "gone") return 1;
200
+ if (mate.state === "quiet") return 2;
201
+ if (mate.state === "absent") return 3;
202
+ return 4;
181
203
  }
182
204
  rows.sort(function (a, b) { return rank(a) - rank(b) || a.label.localeCompare(b.label); });
183
205
  return rows;
@@ -192,14 +214,14 @@ export const BOARD_TEAM_JS = String.raw`(function () {
192
214
 
193
215
  /** One teammate: who, how they are, and what they hold. Not a button — nothing to open. */
194
216
  function mate(mate) {
195
- var node = el("li", "board-team__mate");
217
+ var node = el("li", N.teamMate);
196
218
  node.setAttribute("data-state", mate.state);
197
219
  if (mate.you) node.setAttribute("data-you", "1");
198
220
 
199
- var dot = el("span", "board-team__dot");
221
+ var dot = el("span", N.teamDot);
200
222
  dot.setAttribute("aria-hidden", "true");
201
223
  node.appendChild(dot);
202
- node.appendChild(el("span", "board-team__name", mate.label));
224
+ node.appendChild(el("span", N.teamName, mate.label));
203
225
  if (mate.you) node.appendChild(chip("you"));
204
226
  // A live peer whose watch is off will read a handoff at its next turn instead of being
205
227
  // woken for it, which is the one thing a lead can still act on.
@@ -211,7 +233,7 @@ export const BOARD_TEAM_JS = String.raw`(function () {
211
233
  if (mate.mail > 0) node.appendChild(chip(plural(mate.mail, "mail"), "accent"));
212
234
 
213
235
  // "quiet" alone does not tell a lead whether to reclaim; the heartbeat age does.
214
- var mets = el("p", "board-team__mets");
236
+ var mets = el("p", N.teamMets);
215
237
  if (mate.state === "quiet" && mate.seen !== undefined) {
216
238
  mets.appendChild(document.createTextNode("quiet for "));
217
239
  mets.appendChild(stamped(ago(mate.seen), mate.seen));
@@ -246,15 +268,15 @@ export const BOARD_TEAM_JS = String.raw`(function () {
246
268
  var filtering = query !== "";
247
269
  var noMatch = "no card here matches \"" + query + "\"";
248
270
 
249
- var grid = el("div", "board-team__grid");
271
+ var grid = el("div", N.teamGrid);
250
272
 
251
273
  var all = needs(view);
252
274
  var decisions = all.filter(function (entry) { return keep(entry.card); });
253
275
  var queue = panel("Needs a decision", decisions.length, all.length);
254
276
  if (decisions.length === 0) {
255
- queue.appendChild(el("p", "board-team__empty", filtering ? noMatch : "nothing needs you: no blockers, nothing idle, every card planned and held"));
277
+ queue.appendChild(el("p", N.teamEmpty, filtering ? noMatch : "nothing needs you: no blockers, nothing idle, every card planned and held"));
256
278
  } else {
257
- var list = el("div", "board-team__queue");
279
+ var list = el("div", N.teamQueue);
258
280
  for (var i = 0; i < decisions.length; i++) list.appendChild(decision(decisions[i], view));
259
281
  queue.appendChild(list);
260
282
  }
@@ -270,9 +292,9 @@ export const BOARD_TEAM_JS = String.raw`(function () {
270
292
  });
271
293
  var people = panel("Teammates", shown.length, everyone.length);
272
294
  if (shown.length === 0) {
273
- people.appendChild(el("p", "board-team__empty", filtering ? noMatch : roster === null ? "session-bus is not loaded here" : "nobody is here yet"));
295
+ people.appendChild(el("p", N.teamEmpty, filtering ? noMatch : roster === null ? "session-bus is not loaded here" : "nobody is here yet"));
274
296
  } else {
275
- var rosterList = el("ul", "board-team__roster");
297
+ var rosterList = el("ul", N.teamRoster);
276
298
  for (var j = 0; j < shown.length; j++) rosterList.appendChild(mate(shown[j]));
277
299
  people.appendChild(rosterList);
278
300
  }
@@ -280,7 +302,7 @@ export const BOARD_TEAM_JS = String.raw`(function () {
280
302
  root.appendChild(grid);
281
303
 
282
304
  var done = round.done.filter(keep);
283
- var strip = el("p", "board-team__done");
305
+ var strip = el("p", N.teamDone);
284
306
  if (done.length === 0) {
285
307
  // While filtering, a third "no card here matches" line adds nothing; the two
286
308
  // panels above already said it.
@@ -290,7 +312,7 @@ export const BOARD_TEAM_JS = String.raw`(function () {
290
312
  strip.appendChild(document.createTextNode("Done \u00B7 "));
291
313
  for (var m = 0; m < done.length; m++) {
292
314
  (function (card) {
293
- var id = el("button", "board-team__id", "#" + card.id);
315
+ var id = el("button", N.teamId, "#" + card.id);
294
316
  id.type = "button";
295
317
  id.setAttribute("aria-haspopup", "dialog");
296
318
  id.addEventListener("click", function () { board.openCard(card.id); });
@@ -7,12 +7,13 @@
7
7
  * whether the threshold has been crossed and what the one notice says.
8
8
  *
9
9
  * Pure: the caller has the priced spend and the currency, this shapes the answer.
10
- * The amount formatting reuses the footer's own `formatCost`, so the notice and the
11
- * status bar cannot disagree about precision or about a sub-minor-unit amount.
10
+ * The amount formatting is the footer's own — `formatCost` for what was spent,
11
+ * `formatLimit` for the ceiling it is held against — so the notice and the status
12
+ * bar cannot disagree about either figure.
12
13
  */
13
14
 
14
15
  import { noticeMessage, type NoticeMessage } from "../../../lib/notice.ts";
15
- import { formatCost } from "./render/format.ts";
16
+ import { formatCost, formatLimit } from "./render/format.ts";
16
17
  import type { Currency } from "./price/types.ts";
17
18
 
18
19
  /** Where the session stands against its budget. */
@@ -41,7 +42,7 @@ export function budgetState(limit: number, spent: number): BudgetState {
41
42
  */
42
43
  export function budgetNotice(state: BudgetState, currency: Currency): NoticeMessage {
43
44
  const spent = formatCost(state.spent, currency);
44
- const limit = formatCost(state.limit, currency);
45
+ const limit = formatLimit(state.limit, currency);
45
46
  return noticeMessage({
46
47
  content:
47
48
  `Session spend reached the configured budget: ${spent} of ${limit}. ` +
@@ -60,5 +61,5 @@ export function budgetNotice(state: BudgetState, currency: Currency): NoticeMess
60
61
 
61
62
  /** The one-line body an OS notification carries. */
62
63
  export function budgetBody(state: BudgetState, currency: Currency): string {
63
- return `Session budget reached · ${formatCost(state.spent, currency)} of ${formatCost(state.limit, currency)}`;
64
+ return `Session budget reached · ${formatCost(state.spent, currency)} of ${formatLimit(state.limit, currency)}`;
64
65
  }
@@ -50,6 +50,17 @@ interface UsageSource {
50
50
  * not say. A `model_change` entry, and an assistant call that names its model,
51
51
  * both move the model in effect for the entries that follow.
52
52
  */
53
+ /**
54
+ * The share of a call's prompt that came from the cache, as a percentage.
55
+ *
56
+ * One body, because two callers ask the same question: the collector for a live session,
57
+ * and the chrome capture, whose frame must print the same number the footer would.
58
+ */
59
+ export function cacheHitRateOf(input: number, cacheRead: number, cacheWrite: number): number | null {
60
+ const prompt = input + cacheRead + cacheWrite;
61
+ return prompt > 0 ? (cacheRead / prompt) * 100 : null;
62
+ }
63
+
53
64
  export function usageRecords(
54
65
  entries: Iterable<unknown>,
55
66
  active: { provider: string; model: string } | undefined,
@@ -89,10 +100,7 @@ export function usageRecords(
89
100
  isAssistant: source.isAssistant,
90
101
  });
91
102
 
92
- if (source.isAssistant) {
93
- const prompt = input + cacheRead + cacheWrite;
94
- cacheHitRate = prompt > 0 ? (cacheRead / prompt) * 100 : null;
95
- }
103
+ if (source.isAssistant) cacheHitRate = cacheHitRateOf(input, cacheRead, cacheWrite);
96
104
  }
97
105
 
98
106
  return { records, cacheHitRate };
@@ -18,7 +18,7 @@ import type { DelegationLedger } from "../delegation.ts";
18
18
  import { createPricer } from "../price/price.ts";
19
19
  import type { ByteflyConfig } from "../price/types.ts";
20
20
  import { zeroUsage } from "../../../../lib/usage.ts";
21
- import type { ContextInfo, FooterData, PricedUsage, StatusSnapshot, UsageTotals } from "../types.ts";
21
+ import type { ContextInfo, FooterData, PricedUsage, StatusSnapshot } from "../types.ts";
22
22
  import { usageRecords } from "./entries.ts";
23
23
 
24
24
  /**
@@ -42,22 +42,10 @@ export interface SnapshotReader {
42
42
  peek(): PricedUsage | undefined;
43
43
  }
44
44
 
45
- /** The five counters, without the unpriced count that only the notice reads. */
46
- function countsOf(priced: PricedUsage): UsageTotals {
47
- return {
48
- input: priced.input,
49
- output: priced.output,
50
- cacheRead: priced.cacheRead,
51
- cacheWrite: priced.cacheWrite,
52
- cost: priced.cost,
53
- };
54
- }
55
-
56
45
  export function createSnapshotReader(config: ByteflyConfig, delegation: DelegationLedger): SnapshotReader {
57
46
  const price = createPricer(config);
58
47
  let key: string | null = null;
59
- let usage: PricedUsage = { ...zeroUsage(), unpriced: 0 };
60
- let usageCounts: UsageTotals = zeroUsage();
48
+ let usage: PricedUsage = { ...zeroUsage(), unpriced: 0, unpricedModels: [] };
61
49
  let delegationTotals = { runs: 0, usage: zeroUsage() };
62
50
  let cacheHitRate: number | null = null;
63
51
  let context: ContextInfo = { tokens: null, contextWindow: 0, percent: null };
@@ -73,11 +61,13 @@ export function createSnapshotReader(config: ByteflyConfig, delegation: Delegati
73
61
  const active = current ? { provider: current.provider, model: current.id } : undefined;
74
62
  const scanned = usageRecords(session.getEntries(), active);
75
63
  usage = price(scanned.records);
76
- usageCounts = countsOf(usage);
77
64
  cacheHitRate = scanned.cacheHitRate;
78
65
  // The child runs are priced by the same pricer, so a currency or rate
79
66
  // change moves both numbers together — and neither is folded into the other.
80
- delegationTotals = { runs: delegation.runs(), usage: countsOf(price(delegation.records())) };
67
+ // A child run's unpriced records are its own: the ledger keeps one amount per run,
68
+ // and nothing here reports them separately.
69
+ const { unpriced: _childUnpriced, unpricedModels: _childModels, ...childUsage } = price(delegation.records());
70
+ delegationTotals = { runs: delegation.runs(), usage: childUsage };
81
71
 
82
72
  const usageInfo = ctx.getContextUsage();
83
73
  context = {
@@ -105,8 +95,9 @@ export function createSnapshotReader(config: ByteflyConfig, delegation: Delegati
105
95
  model,
106
96
  thinkingLevel: ctx.thinkingLevel ?? null,
107
97
  context,
108
- usage: usageCounts,
98
+ usage,
109
99
  currency: config.currency,
100
+ budget: config.budget,
110
101
  cacheHitRate,
111
102
  providerCount: footerData.getAvailableProviderCount(),
112
103
  delegation: delegationTotals,
@@ -9,32 +9,43 @@
9
9
 
10
10
  import { GLYPHS, SEPARATORS, STATUS_KEYS } from "../../../lib/tui/glyphs.ts";
11
11
 
12
+ /**
13
+ * Status keys that are alerts rather than modes, and so are never dropped.
14
+ * A mode can be read again from the session; a failure cannot be re-derived once
15
+ * its chip is off the bar. Annotated rather than inferred so the list can be read
16
+ * against an arbitrary key from `getExtensionStatuses()`.
17
+ */
18
+ const ALERT_STATUS_KEYS: readonly string[] = [STATUS_KEYS.jobsFailure];
19
+
12
20
  export const CONFIG = {
13
21
  /** Context percentage thresholds. */
14
22
  thresholds: { warn: 70, danger: 90 },
15
23
  /** The block counts the context gauge steps down through. */
16
24
  gauge: { widths: [10, 5, 3, 0] as const },
17
- /**
18
- * Separators printed before grouped and other segments. The group divider is
19
- * the footer's own solid `│`, not the shared `╎`: that glyph is the transcript
20
- * rail (and `chrome.rail`'s default), and a status line is not a rail.
21
- */
22
- separators: { group: " │ ", item: SEPARATORS.item },
25
+ /** Separator printed before every segment but the first in its zone. */
26
+ separators: { item: SEPARATORS.item },
23
27
  icons: {
24
28
  branch: GLYPHS.branch,
25
29
  detached: GLYPHS.detached,
26
30
  worktree: GLYPHS.worktree,
31
+ board: GLYPHS.board,
27
32
  browse: GLYPHS.browse,
28
33
  delegation: GLYPHS.delegation,
29
34
  },
30
35
  /** Status key the worktree extension uses; routed to line 1's right zone. */
31
36
  worktreeStatusKey: STATUS_KEYS.worktree,
37
+ /** Status key the board extension publishes its page port under. */
38
+ boardStatusKey: STATUS_KEYS.board,
32
39
  /** Status key the file-browse extension uses; routed to line 1's right zone. */
33
40
  browseStatusKey: STATUS_KEYS.browse,
34
41
  /** Status key whose ` · detail` suffix (the plan file name) is dropped. */
35
42
  planStatusKey: STATUS_KEYS.planMode,
36
- /** Status key treated as an alert: the one that sorts to the front of the slot. */
37
- jobsFailureStatusKey: STATUS_KEYS.jobsFailure,
43
+ /**
44
+ * Status keys that are alerts rather than modes, and so are never dropped.
45
+ * A mode can be read again from the session; a failure cannot be re-derived
46
+ * once its chip is off the bar.
47
+ */
48
+ alertStatusKeys: ALERT_STATUS_KEYS,
38
49
  labels: { noModel: "no model", detached: "detached" },
39
50
  /** Minimum gap kept between the left and right zones. */
40
51
  minGap: 2,