@a-t-h-i/bot-lobby 0.3.0 → 0.5.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 (61) hide show
  1. package/README.md +266 -35
  2. package/package.json +1 -1
  3. package/prompts/backend.md +46 -1
  4. package/prompts/designer.md +94 -15
  5. package/prompts/master.md +70 -1
  6. package/prompts/panel.md +39 -0
  7. package/prompts/planner.md +64 -0
  8. package/prompts/qa.md +35 -2
  9. package/prompts/quickfix.md +41 -0
  10. package/prompts/researcher.md +6 -0
  11. package/prompts/reviewer.md +16 -0
  12. package/prompts/scout.md +11 -2
  13. package/prompts/worker.md +35 -2
  14. package/src/desk/client-extension.ts +101 -0
  15. package/src/desk/desk.ts +249 -0
  16. package/src/desk/ipc.ts +178 -0
  17. package/src/desk/session.ts +214 -0
  18. package/src/execution/agent-runner.ts +165 -14
  19. package/src/execution/pi-runner.ts +376 -65
  20. package/src/index.ts +7 -0
  21. package/src/lobby/feed.ts +253 -0
  22. package/src/lobby/issues.ts +227 -0
  23. package/src/lobby/layout.ts +174 -0
  24. package/src/lobby/planner.ts +474 -0
  25. package/src/lobby/quickfix.ts +227 -0
  26. package/src/lobby/runtime.ts +440 -0
  27. package/src/lobby/tabs/home.ts +164 -0
  28. package/src/lobby/tabs/issues.ts +72 -0
  29. package/src/lobby/tabs/metrics.ts +162 -0
  30. package/src/lobby/tabs/plan.ts +160 -0
  31. package/src/lobby/tabs/quickfix.ts +101 -0
  32. package/src/lobby/tabs/tasks.ts +209 -0
  33. package/src/lobby/view.ts +855 -0
  34. package/src/master/master.ts +21 -17
  35. package/src/master/research.ts +8 -7
  36. package/src/pi/activity.ts +165 -0
  37. package/src/pi/commands.ts +46 -59
  38. package/src/pi/events.ts +5 -2
  39. package/src/pi/expressions.ts +43 -12
  40. package/src/pi/kaomoji.ts +227 -0
  41. package/src/pi/mascot-art.ts +5 -15
  42. package/src/pi/model-support.ts +135 -0
  43. package/src/pi/run-summary.ts +172 -0
  44. package/src/pi/settings-ui.ts +162 -60
  45. package/src/pi/start-task.ts +63 -0
  46. package/src/pi/tools.ts +51 -8
  47. package/src/pi/ui.ts +151 -49
  48. package/src/pi/zen-large.ts +41 -4
  49. package/src/pi/zen-metrics.ts +47 -7
  50. package/src/pi/zen.ts +29 -8
  51. package/src/roles/reviewer.ts +24 -4
  52. package/src/roles/worker.ts +7 -1
  53. package/src/schemas/configuration.ts +177 -18
  54. package/src/schemas/findings.ts +26 -0
  55. package/src/schemas/task.ts +27 -1
  56. package/src/state/backlog.ts +106 -0
  57. package/src/state/comments.ts +136 -0
  58. package/src/state/metrics.ts +305 -0
  59. package/src/state/project.ts +9 -0
  60. package/src/text.ts +9 -0
  61. package/src/workflow/workflow.ts +161 -12
@@ -0,0 +1,227 @@
1
+ /**
2
+ * The agents' kaomoji: a pure catalogue of emotes and the rules that pick one.
3
+ *
4
+ * Every emote is a tiny animation: the open face, the same face blinking (same
5
+ * width, so nothing shifts), and an optional action frame (a table flip, a
6
+ * sparkle, a bow). Each agent has its own personality set for every emotion,
7
+ * and a shared pool of emotions is open to all of them:
8
+ *
9
+ * - DEV, the hacker: shades, flexing, coffee, and table flips on failure
10
+ * - DESIGN, the artist: sparkles, flowers, hearts and little flourishes
11
+ * - RESEARCH, the bookworm: note-taking, pointing, shrugging, deal-with-it glasses
12
+ * - QA, the inspector: side-eyes and squints, then a flex and a victory dance
13
+ *
14
+ * Nothing here reads a clock or a random source: the caller passes a `variant`
15
+ * drawn once per expression, so the same variant always picks the same face.
16
+ * Every face fits a slot cell and avoids combining marks, right-to-left
17
+ * scripts and emoji-presentation glyphs (enforced by tests).
18
+ */
19
+ import type { SlotId, SlotState } from "./mascot-art.ts";
20
+
21
+ export const EMOTIONS = [
22
+ "happy",
23
+ "proud",
24
+ "love",
25
+ "excited",
26
+ "focused",
27
+ "curious",
28
+ "thinking",
29
+ "nervous",
30
+ "confused",
31
+ "sleepy",
32
+ "sad",
33
+ "angry",
34
+ "waiting",
35
+ "surprised",
36
+ "grateful",
37
+ ] as const;
38
+ export type Emotion = (typeof EMOTIONS)[number];
39
+
40
+ /** One emote: the open face, the same face blinking, and an optional action frame. */
41
+ export interface Emote {
42
+ open: string;
43
+ blink: string;
44
+ action?: string;
45
+ }
46
+
47
+ const e = (open: string, blink: string, action?: string): Emote => (action ? { open, blink, action } : { open, blink });
48
+
49
+ /** Emotions every agent can draw on. */
50
+ export const SHARED_EMOTES: Record<Emotion, readonly Emote[]> = {
51
+ happy: [e("(^▽^)", "(-▽-)"), e("(^ω^)", "(-ω-)"), e("ヽ(•‿•)ノ", "ヽ(-‿-)ノ"), e("(*^‿^*)", "(*-‿-*)"), e("(◕‿◕)", "(-‿-)")],
52
+ proud: [e("( ̄^ ̄)ゞ", "(-^-)ゞ"), e("(´▽`)b", "(-▽-)b"), e("(  ̄ー ̄)", "( -ー-)"), e("(•ᴗ•)ง", "(-ᴗ-)ง")],
53
+ love: [e("(♡‿♡)", "(-‿-)", "(♡‿♡)♡"), e("(っ˘з(˘⌣˘ )", "(っ-з(-⌣- )"), e("(´ε` )♡", "(-ε- )♡"), e("(◍•ᴗ•◍)♡", "(◍-ᴗ-◍)♡")],
54
+ excited: [e("\\(^o^)/", "\\(-o-)/"), e("ヽ(^▽^)ノ", "ヽ(-▽-)ノ"), e("(ノ◕ヮ◕)ノ", "(ノ-ヮ-)ノ", "(ノ◕ヮ◕)ノ*:・゚✧"), e("o(≧▽≦)o", "o(-▽-)o")],
55
+ focused: [e("(•_•)", "(-_-)"), e("(ง •_•)ง", "(ง -_-)ง"), e("(`・ω・´)", "(`-ω-´)"), e("( ._.)φ", "( -_-)φ")],
56
+ curious: [e("(・・ )?", "(-- )?"), e("(⊙_⊙)?", "(-_-)?"), e("(◔_◔)", "(-_-)"), e("(゚∀゚)?", "(-∀-)?")],
57
+ thinking: [e("( ̄~ ̄;)", "(-~-;)"), e("( ˘_˘ )", "( -_- )"), e("(・・;)ゞ", "(--;)ゞ"), e("(¬‿¬ )", "(-‿- )")],
58
+ nervous: [e("(;・∀・)", "(;-∀-)"), e("(⊙﹏⊙)", "(-﹏-)"), e("(●´⌓`●)", "(●-⌓-●)"), e("(°□°;)", "(-□-;)")],
59
+ confused: [e("(・_・ヾ", "(-_-ヾ"), e("(⊙_☉)", "(-_-)"), e("(゜-゜)", "(---)"), e("(・・?)", "(--?)")],
60
+ sleepy: [e("(-_-) zzZ", "(-.-) zzZ"), e("( ̄o ̄) zzZZ", "(-o-) zzZZ"), e("(∪。∪)。zz", "(-。-)。zz"), e("( ˘ω˘ )スヤァ", "( -ω- )スヤァ")],
61
+ sad: [e("(ಥ_ಥ)", "(-_-)"), e("(╥﹏╥)", "(-﹏-)"), e("(´;ω;`)", "(-;ω;-)"), e("(〒﹏〒)", "(-﹏-)"), e("(T_T)", "(-_-)")],
62
+ angry: [e("(╬ಠ益ಠ)", "(╬-益-)"), e("(ノಠ益ಠ)ノ", "(ノ-益-)ノ", "(ノಠ益ಠ)ノ彡┻━┻"), e("ヽ(`Д´)ノ", "ヽ(-Д-)ノ"), e("(¬_¬\")", "(-_-\")")],
63
+ waiting: [e("(´・_・`)", "(´-_-`)"), e("( ˘・_・˘)", "( ˘-_-˘)"), e("(っ´ω`c)", "(っ-ω-c)"), e("(・_・)…", "(-_-)…")],
64
+ surprised: [e("(°o°)", "(-o-)"), e("Σ(°△°|||)", "Σ(-△-|||)"), e("(゚Д゚)", "(-Д-)"), e("w(°o°)w", "w(-o-)w")],
65
+ grateful: [e("(人´∀`)", "(人-∀-)"), e("(◍•ᴗ•◍)", "(◍-ᴗ-◍)"), e("m(_ _)m", "m(- -)m"), e("(ᵔᴥᵔ)", "(-ᴥ-)"), e("(*´∀`*)", "(*-∀-*)")],
66
+ };
67
+
68
+ /** DEV, the hacker: shades, flexes, coffee and code brackets; flips tables when things break. */
69
+ const DEV: Record<Emotion, readonly Emote[]> = {
70
+ happy: [e("(⌐■_■)", "(⌐-_-)"), e("(^_^)b", "(-_-)b"), e("</>(^▽^)", "</>(-▽-)"), e("( •_•)c[_]", "( -_-)c[_]")],
71
+ proud: [e("(⌐■_■)b", "(⌐-_-)b"), e("ᕦ(ò_óˇ)ᕤ", "ᕦ(-_-ˇ)ᕤ"), e("( •_•)>⌐■-■", "( -_-)>⌐■-■", "(⌐■_■)"), e("b( ̄▽ ̄)d", "b(-▽-)d")],
72
+ love: [e("(♡_♡)</>", "(-_-)</>"), e("(´∀`)♡{ }", "(-∀-)♡{ }"), e("(˘▽˘)c[_]", "(-▽-)c[_]"), e("(◍•_•◍)♡", "(◍-_-◍)♡")],
73
+ excited: [e("ᕦ(ò_ó)ᕤ", "ᕦ(-_-)ᕤ"), e("(ノ≧∀≦)ノ", "(ノ-∀-)ノ", "(ノ≧∀≦)ノ</>"), e("\\(•_•)/", "\\(-_-)/"), e("(⌐■▽■)", "(⌐-▽-)")],
74
+ focused: [e("(•_•)>_", "(-_-)>_"), e("[•_•]$_", "[-_-]$_"), e("(ง'-')ง", "(ง- -)ง"), e("(⌐■_■)>_", "(⌐-_-)>_")],
75
+ curious: [e("(•_•)?</>", "(-_-)?</>"), e("(・_・)ノ{ }", "(-_-)ノ{ }"), e("(°_°)>_", "(-_-)>_"), e("( ⊙_⊙)</>", "( -_-)</>")],
76
+ thinking: [e("( ̄_ ̄)c[_]", "(-_-)c[_]"), e("( •_•)…{ }", "( -_-)…{ }"), e("(¬_¬)>_", "(-_-)>_"), e("(°~°)</>", "(-~-)</>")],
77
+ nervous: [e("(°_°;)>_", "(-_-;)>_"), e("(・_・;)$_", "(-_-;)$_"), e("(⊙_⊙;)", "(-_-;)"), e("(゚_゚;)c[_]", "(-_-;)c[_]")],
78
+ confused: [e("(・_・)?{ }", "(-_-)?{ }"), e("(°_°)?>_", "(-_-)?>_"), e("(⊙_☉)</>", "(-_-)</>"), e("(゜_゜)$?", "(-_-)$?")],
79
+ sleepy: [e("(-_-)c[_] z", "(-.-)c[_] z"), e("( ̄o ̄)>_ zz", "(-o-)>_ zz"), e("(∪_∪)</> z", "(-_-)</> z"), e("( ˘_˘ )$_ z", "( -_- )$_ z")],
80
+ sad: [e("(╥_╥)>_", "(-_-)>_"), e("(T_T)</>", "(-_-)</>"), e("(ಥ_ಥ)c[_]", "(-_-)c[_]"), e("(´;_;`)$_", "(-;_;-)$_")],
81
+ angry: [e("(╯°□°)╯", "(╯-□-)╯", "(╯°□°)╯︵ ┻━┻"), e("(ノಠ益ಠ)ノ", "(ノ-益-)ノ", "(ノಠ益ಠ)ノ彡┻━┻"), e("(╬ò_ó)>_", "(╬-_-)>_"), e("┻━┻ ︵ (°□°)", "┻━┻ ︵ (-□-)")],
82
+ waiting: [e("(•_•)c[_]", "(-_-)c[_]"), e("(´・_・`)>_", "(´-_-`)>_"), e("( ˘_˘)</>…", "( -_-)</>…"), e("(・_・)$_…", "(-_-)$_…")],
83
+ surprised: [e("(°□°)>_", "(-□-)>_"), e("Σ(°_°)</>", "Σ(-_-)</>"), e("(゚Д゚)$_", "(-Д-)$_"), e("(⊙_⊙)!{ }", "(-_-)!{ }")],
84
+ grateful: [e("(⌐■_■)b", "(⌐-_-)b"), e("m(_ _)m</>", "m(- -)m</>"), e("(^_^)c[_]", "(-_-)c[_]"), e("(人•_•)", "(人-_-)")],
85
+ };
86
+
87
+ /** DESIGN, the artist: sparkles, flowers, hearts and a little flourish on most things. */
88
+ const DESIGN: Record<Emotion, readonly Emote[]> = {
89
+ happy: [e("✧(◕‿◕✿)", "✧(-‿-✿)"), e("(✿^‿^)", "(✿-‿-)"), e("(◕ᴗ◕✿)", "(-ᴗ-✿)"), e("❀(^▽^)❀", "❀(-▽-)❀")],
90
+ proud: [e("(✿◠‿◠)", "(✿-‿-)"), e("✧(˘▽˘)✧", "✧(-▽-)✧"), e("( ̄▽ ̄)✿", "(-▽-)✿"), e("(◕‿◕)b✧", "(-‿-)b✧")],
91
+ love: [e("(♡‿♡)✿", "(-‿-)✿"), e("(っ˘з˘)♡✧", "(っ-з-)♡✧"), e("(◍♡‿♡◍)", "(◍-‿-◍)"), e("✿(´ε`)♡", "✿(-ε-)♡")],
92
+ excited: [e("(ノ◕ヮ◕)ノ", "(ノ-ヮ-)ノ", "(ノ◕ヮ◕)ノ*:・゚✧"), e("✧*(^▽^)*✧", "✧*(-▽-)*✧"), e("☆(≧▽≦)☆", "☆(-▽-)☆"), e("♪(^∇^)♪", "♪(-∇-)♪")],
93
+ focused: [e("(•‿•)~✎", "(-‿-)~✎"), e("( ˘_˘)~✿", "( -_-)~✿"), e("(◕_◕)✎", "(-_-)✎"), e("(・ω・)~♪", "(-ω-)~♪")],
94
+ curious: [e("(◕_◕)?✧", "(-_-)?✧"), e("(・・ )✿?", "(-- )✿?"), e("(°‿°)?", "(-‿-)?"), e("✧(◔_◔)", "✧(-_-)")],
95
+ thinking: [e("( ˘‿˘)~✎", "( -‿-)~✎"), e("( ̄~ ̄)✿", "(-~-)✿"), e("(◕~◕)…", "(-~-)…"), e("(・・)~♪", "(--)~♪")],
96
+ nervous: [e("(◕﹏◕✿)", "(-﹏-✿)"), e("(°‿°;)✎", "(-‿-;)✎"), e("(・_・;)✿", "(-_-;)✿"), e("(⊙‿⊙;)", "(-‿-;)")],
97
+ confused: [e("(◕_◕)?~", "(-_-)?~"), e("(・・?)✿", "(--?)✿"), e("(゜‿゜)?", "(-‿-)?"), e("(°_°)✎?", "(-_-)✎?")],
98
+ sleepy: [e("(-‿-)✿ zz", "(-.-)✿ zz"), e("( ˘ω˘ )✿z", "( -ω- )✿z"), e("(∪‿∪)♪ z", "(-‿-)♪ z"), e("( ̄‿ ̄)zzZ", "(-‿-)zzZ")],
99
+ sad: [e("(╥﹏╥)✿", "(-﹏-)✿"), e("(ಥ‿ಥ)", "(-‿-)"), e("(´;ω;`)✎", "(-;ω;-)✎"), e("(◕︵◕)", "(-︵-)")],
100
+ angry: [e("(╬◣_◢)", "(╬-_-)"), e("(ノ`Д´)ノ✎", "(ノ-Д-)ノ✎"), e("(¬_¬)✿", "(-_-)✿"), e("(ಠ‿ಠ)✎", "(-‿-)✎")],
101
+ waiting: [e("(っ´ω`c)✿", "(っ-ω-c)✿"), e("( ˘‿˘)~♪", "( -‿-)~♪"), e("(・‿・)…✿", "(-‿-)…✿"), e("(´・‿・`)", "(´-‿-`)")],
102
+ surprised: [e("(°o°)✿", "(-o-)✿"), e("✧(⊙‿⊙)✧", "✧(-‿-)✧"), e("(゚∀゚)!✎", "(-∀-)!✎"), e("Σ(◕o◕)", "Σ(-o-)")],
103
+ grateful: [e("(人◕‿◕)", "(人-‿-)"), e("(◍•ᴗ•◍)✿", "(◍-ᴗ-◍)✿"), e("✿m(_ _)m", "✿m(- -)m"), e("(*´▽`*)✧", "(*-▽-*)✧")],
104
+ };
105
+
106
+ /** RESEARCH, the bookworm: takes notes, points at things, shrugs at the unknown. */
107
+ const RESEARCH: Record<Emotion, readonly Emote[]> = {
108
+ happy: [e("φ(^▽^)", "φ(-▽-)"), e("(^_^)[ ]", "(-_-)[ ]"), e("(•‿•)φ", "(-‿-)φ"), e("( ˘▽˘)ノ", "( -▽-)ノ")],
109
+ proud: [e("( •_•)>⌐■-■", "( -_-)>⌐■-■", "(⌐■_■)"), e("( ̄^ ̄)φ", "(-^-)φ"), e("(•ᴗ•)b[ ]", "(-ᴗ-)b[ ]"), e("(  ̄ー ̄)φ", "( -ー-)φ")],
110
+ love: [e("(♡_♡)[ ]", "(-_-)[ ]"), e("φ(´∀`)♡", "φ(-∀-)♡"), e("(◍•_•◍)φ", "(◍-_-◍)φ"), e("(˘ε˘)♡[ ]", "(-ε-)♡[ ]")],
111
+ excited: [e("(σ・・)σ!", "(σ--)σ!"), e("φ(≧▽≦)", "φ(-▽-)"), e("\\(°o°)/", "\\(-o-)/"), e("(ノ°▽°)ノ[ ]", "(ノ-▽-)ノ[ ]")],
112
+ focused: [e("φ(..)", "φ(--)", "φ(..;)"), e("( ._.)φ__", "( -_-)φ__"), e("(・_・)[ ]", "(-_-)[ ]"), e("( •_•)φ", "( -_-)φ")],
113
+ curious: [e("(σ・・)σ", "(σ--)σ"), e("(・・ )?[ ]", "(-- )?[ ]"), e("(⊙_⊙)φ?", "(-_-)φ?"), e("(°_°)…?", "(-_-)…?")],
114
+ thinking: [e("φ( ̄~ ̄)", "φ(-~-)"), e("( ˘_˘ )φ", "( -_- )φ"), e("(・・;)[ ]", "(--;)[ ]"), e("(¬_¬)φ…", "(-_-)φ…")],
115
+ nervous: [e("φ(°_°;)", "φ(-_-;)"), e("(・_・;)[?]", "(-_-;)[?]"), e("(⊙﹏⊙)φ", "(-﹏-)φ"), e("(°□°;)[ ]", "(-□-;)[ ]")],
116
+ confused: [e("¯\\_(ツ)_/¯", "¯\\_(ー)_/¯"), e("(・_・ヾ[?]", "(-_-ヾ[?]"), e("(⊙_☉)φ", "(-_-)φ"), e("(゜-゜)?[ ]", "(---)?[ ]")],
117
+ sleepy: [e("(-_-)[ ] zz", "(-.-)[ ] zz"), e("φ( ̄o ̄) z", "φ(-o-) z"), e("(∪_∪)φ zz", "(-_-)φ zz"), e("( ˘ω˘ )[ ]z", "( -ω- )[ ]z")],
118
+ sad: [e("(ಥ_ಥ)φ", "(-_-)φ"), e("(╥_╥)[ ]", "(-_-)[ ]"), e("(´;ω;`)φ", "(-;ω;-)φ"), e("(T_T)[?]", "(-_-)[?]")],
119
+ angry: [e("(ಠ_ಠ)φ", "(-_-)φ"), e("(╬ಠ_ಠ)[ ]", "(╬-_-)[ ]"), e("ヽ(`Д´)ノφ", "ヽ(-Д-)ノφ"), e("(¬_¬\")[ ]", "(-_-\")[ ]")],
120
+ waiting: [e("(´・_・`)φ", "(´-_-`)φ"), e("( ˘・_・˘)[ ]", "( ˘-_-˘)[ ]"), e("φ(・_・)…", "φ(-_-)…"), e("(っ´ω`c)φ", "(っ-ω-c)φ")],
121
+ surprised: [e("Σ(°o°)φ", "Σ(-o-)φ"), e("(゚Д゚)[!]", "(-Д-)[!]"), e("(⊙_⊙)!φ", "(-_-)!φ"), e("w(°o°)w[ ]", "w(-o-)w[ ]")],
122
+ grateful: [e("(人´∀`)φ", "(人-∀-)φ"), e("m(_ _)m[ ]", "m(- -)m[ ]"), e("(ᵔᴥᵔ)φ", "(-ᴥ-)φ"), e("(◍•ᴗ•◍)[ ]", "(◍-ᴗ-◍)[ ]")],
123
+ };
124
+
125
+ /** QA, the inspector: side-eyes and squints at everything, then flexes and dances on a pass. */
126
+ const QA: Record<Emotion, readonly Emote[]> = {
127
+ happy: [e("(•‿•)✓", "(-‿-)✓"), e("( ^_^)[✓]", "( -_-)[✓]"), e("(ᵔ.ᵔ)✓", "(-.-)✓"), e("(^_^)✓", "(-_-)✓")],
128
+ proud: [e("ᕙ( • ‿ • )ᕗ", "ᕙ( - ‿ - )ᕗ"), e("ᕦ(•_•)ᕤ✓", "ᕦ(-_-)ᕤ✓"), e("( ̄^ ̄)✓", "(-^-)✓"), e("( •_•)b✓", "( -_-)b✓")],
129
+ love: [e("(♡_♡)✓", "(-_-)✓"), e("(´∀`)♡[✓]", "(-∀-)♡[✓]"), e("(◍•_•◍)✓", "(◍-_-◍)✓"), e("(˘ε˘)♡✓", "(-ε-)♡✓")],
130
+ excited: [e("ᕕ( ᐛ )ᕗ", "ᕕ( - )ᕗ"), e("\\(•_•)/✓", "\\(-_-)/✓"), e("(ノ≧▽≦)ノ✓", "(ノ-▽-)ノ✓"), e("ᕙ(⇀‸↼)ᕗ", "ᕙ(-‸-)ᕗ")],
131
+ focused: [e("(ಠ_ಠ)", "(−_−)"), e("(¬_¬)", "(-_-)"), e("(눈_눈)", "(-_-)"), e("(•_•)⊙", "(-_-)⊙")],
132
+ curious: [e("(ಠ_ಠ)?", "(−_−)?"), e("(¬_¬)?[ ]", "(-_-)?[ ]"), e("(⊙_⊙)⊙", "(-_-)⊙"), e("(・_・)?✓", "(-_-)?✓")],
133
+ thinking: [e("(¬‿¬)[ ]", "(-‿-)[ ]"), e("(ಠ~ಠ)", "(−~−)"), e("( ̄_ ̄)[?]", "(-_-)[?]"), e("( ˘_˘ )✓?", "( -_- )✓?")],
134
+ nervous: [e("(ಠ_ಠ;)", "(−_−;)"), e("(°_°;)[ ]", "(-_-;)[ ]"), e("(⊙﹏⊙)✓?", "(-﹏-)✓?"), e("(・_・;)✗?", "(-_-;)✗?")],
135
+ confused: [e("(ಠ_ಠ)?!", "(−_−)?!"), e("(・_・ヾ[?]", "(-_-ヾ[?]"), e("(⊙_☉)✗", "(-_-)✗"), e("(゜-゜)[✗]", "(---)[✗]")],
136
+ sleepy: [e("(-_-)[ ] zz", "(-.-)[ ] zz"), e("( ̄o ̄)✓ zz", "(-o-)✓ zz"), e("(∪_∪)⊙ z", "(-_-)⊙ z"), e("( ˘ω˘ )✓z", "( -ω- )✓z")],
137
+ sad: [e("(ಥ_ಥ)✗", "(-_-)✗"), e("(╥﹏╥)[✗]", "(-﹏-)[✗]"), e("(´;ω;`)✗", "(-;ω;-)✗"), e("(T_T)✗", "(-_-)✗")],
138
+ angry: [e("(ಠ益ಠ)", "(−益−)"), e("(╬ಠ_ಠ)✗", "(╬−_−)✗"), e("(ノಠ益ಠ)ノ", "(ノ−益−)ノ", "(ノಠ益ಠ)ノ[✗]"), e("(¬_¬\")✗", "(-_-\")✗")],
139
+ waiting: [e("(¬_¬)…", "(-_-)…"), e("(´・_・`)[ ]", "(´-_-`)[ ]"), e("(ಠ_ಠ)…", "(−_−)…"), e("( ˘_˘)✓…", "( -_-)✓…")],
140
+ surprised: [e("(ಠ_ಠ)!", "(−_−)!"), e("Σ(°_°)✗", "Σ(-_-)✗"), e("(⊙_⊙)[!]", "(-_-)[!]"), e("(゚Д゚)✓!", "(-Д-)✓!")],
141
+ grateful: [e("(人•_•)✓", "(人-_-)✓"), e("m(_ _)m✓", "m(- -)m✓"), e("(ᵔ.ᵔ)b", "(-.-)b"), e("(◍•ᴗ•◍)✓", "(◍-ᴗ-◍)✓")],
142
+ };
143
+
144
+ /** Personality sets per agent column. */
145
+ export const PERSONALITY_EMOTES: Record<SlotId, Record<Emotion, readonly Emote[]>> = {
146
+ dev: DEV,
147
+ design: DESIGN,
148
+ research: RESEARCH,
149
+ qa: QA,
150
+ };
151
+
152
+ /** Frames per emote: open, blink, then the action (or the open face again), held one step each. */
153
+ export const EMOTE_STEPS = 4;
154
+
155
+ /** The four frames an emote steps through: open, blink, action, action. */
156
+ export function emoteFrames(emote: Emote): readonly string[] {
157
+ const finish = emote.action ?? emote.open;
158
+ return [emote.open, emote.blink, finish, finish];
159
+ }
160
+
161
+ /** What a slot is going through, as far as its face is concerned. */
162
+ export interface Situation {
163
+ status: SlotState;
164
+ /** Live one-word activity while working (reading, editing, running, thinking, ...). */
165
+ activity?: string;
166
+ flag?: "waiting" | "quiet" | "retry";
167
+ /** The run was asked to wrap up early. */
168
+ wrappedUp?: boolean;
169
+ /** A file was just handed to this agent. */
170
+ handover?: boolean;
171
+ }
172
+
173
+ const ACTIVITY_EMOTIONS: Record<string, readonly Emotion[]> = {
174
+ reading: ["curious", "focused"],
175
+ searching: ["curious", "thinking"],
176
+ editing: ["focused", "excited", "proud"],
177
+ running: ["nervous", "focused"],
178
+ thinking: ["thinking", "curious"],
179
+ writing: ["focused", "proud"],
180
+ researching: ["curious", "thinking"],
181
+ };
182
+
183
+ /** The emotions that fit a situation, most fitting first. */
184
+ export function emotionsFor(situation: Situation): readonly Emotion[] {
185
+ switch (situation.status) {
186
+ case "idle":
187
+ return ["sleepy", "waiting", "happy"];
188
+ case "done":
189
+ return situation.wrappedUp ? ["nervous", "proud"] : ["happy", "proud", "love", "excited"];
190
+ case "failed":
191
+ return ["sad", "angry", "confused", "surprised"];
192
+ default:
193
+ break;
194
+ }
195
+ if (situation.flag === "waiting") return ["waiting"];
196
+ if (situation.flag === "quiet") return ["confused", "sleepy"];
197
+ if (situation.flag === "retry") return ["nervous", "sad"];
198
+ if (situation.handover) return ["grateful", "happy"];
199
+ if (situation.wrappedUp) return ["nervous", "surprised"];
200
+ return ACTIVITY_EMOTIONS[situation.activity ?? ""] ?? ["focused", "excited", "happy"];
201
+ }
202
+
203
+ /** Share of picks drawn from the agent's own personality set (out of 10). */
204
+ const PERSONALITY_SHARE = 6;
205
+
206
+ function positive(variant: number): number {
207
+ return Number.isFinite(variant) ? Math.abs(Math.floor(variant)) : 0;
208
+ }
209
+
210
+ /**
211
+ * The emote for one expression: the emotion from the situation, then a face
212
+ * from the agent's personality (about 60%) or the shared pool, all chosen by
213
+ * the expression's `variant`.
214
+ */
215
+ export function pickEmote(slot: SlotId, situation: Situation, variant: number): Emote {
216
+ const seed = positive(variant);
217
+ const emotions = emotionsFor(situation);
218
+ const emotion = emotions[seed % emotions.length]!;
219
+ const own = (Math.floor(seed / 7) % 10) < PERSONALITY_SHARE;
220
+ const pool = own ? PERSONALITY_EMOTES[slot][emotion] : SHARED_EMOTES[emotion];
221
+ return pool[Math.floor(seed / 71) % pool.length]!;
222
+ }
223
+
224
+ /** The frames of the emote a slot plays for this expression. */
225
+ export function slotEmote(slot: SlotId, situation: Situation, variant: number): readonly string[] {
226
+ return emoteFrames(pickEmote(slot, situation, variant));
227
+ }
@@ -1,4 +1,5 @@
1
1
  import type { TaskState } from "../schemas/task.ts";
2
+ import { slotEmote } from "./kaomoji.ts";
2
3
 
3
4
  /**
4
5
  * Pure ASCII art data for the scene-based zen panel. No logic lives here: the
@@ -258,33 +259,22 @@ const FACE_FRAMES: Record<SlotState, readonly string[]> = {
258
259
  failed: ["(>_<)", "(x_x)", "(T_T)", "(;_;)"],
259
260
  };
260
261
 
261
- /** Kaomoji emotes for the large scene: working nervous, done happy, failed scared. */
262
- const EMOTE_FACES: Record<SlotState, readonly string[]> = {
263
- working: ["(٥↼_↼)", "(●´⌓`●)"],
264
- idle: ["(-.-)", "(u.u)"],
265
- done: ["(✿^‿^)", "(っ˘з(˘⌣˘ )"],
266
- failed: ["ಥ_ಥ", "(〒﹏〒)"],
267
- };
268
-
269
- /** QA's success emotes are a flex and a victory dance rather than a smile. */
270
- const QA_DONE_FACES: readonly string[] = ["ᕙ( • ‿ • )ᕗ", "ᕕ( ᐛ )ᕗ"];
271
-
272
262
  /** Every compact face is five columns, so a centered column never shifts between frames. */
273
263
  export const FACE_WIDTH = 5;
274
264
 
275
265
  function buildSlotFrames(id: SlotId): Record<SlotState, readonly string[][]> {
276
266
  const frames = {} as Record<SlotState, readonly string[][]>;
277
267
  for (const state of SLOT_STATES) {
278
- const emotes = id === "qa" && state === "done" ? QA_DONE_FACES : EMOTE_FACES[state];
279
- frames[state] = [...FACE_FRAMES[state].slice(0, 2), ...emotes].map((face) => [face]);
268
+ frames[state] = [...FACE_FRAMES[state].slice(0, 2), ...slotEmote(id, { status: state }, 0)].map((face) => [face]);
280
269
  }
281
270
  return frames;
282
271
  }
283
272
 
284
273
  /**
285
274
  * Animated faces per slot: one row each. Rest (0) and blink (1) stay the five-column
286
- * ASCII eyes; the emote frames (2+) are status-aware kaomoji, wider than five columns,
287
- * so the scene centres each frame inside its fixed slot cell.
275
+ * ASCII eyes; the emote frames (2+) are the slot's default kaomoji for the status
276
+ * (open, blink, action). The live scene swaps in a situation-aware emote per
277
+ * expression (see kaomoji.ts); either way the scene centres each frame in its cell.
288
278
  */
289
279
  export const SLOT_FRAMES: Record<SlotId, Record<SlotState, readonly string[][]>> = {
290
280
  dev: buildSlotFrames("dev"),
@@ -0,0 +1,135 @@
1
+ import { clampThinkingLevel, getSupportedThinkingLevels, type Api, type Model, type ModelThinkingLevel } from "@earendil-works/pi-ai";
2
+ import {
3
+ agentProfile,
4
+ isThinkingLevel,
5
+ lobbyAgentProfile,
6
+ panelMemberProfile,
7
+ type LobbyAgentKind,
8
+ type PanelMember,
9
+ THINKING_LEVELS,
10
+ type BotLobbyConfig,
11
+ type ProfileResolver,
12
+ type SubagentKind,
13
+ type ThinkingLevelName,
14
+ } from "../schemas/configuration.ts";
15
+
16
+ /** Find a model by `provider/id` (or bare id); undefined when the registry does not know it. */
17
+ export type ModelLookup = (ref: string) => Model<Api> | undefined;
18
+
19
+ export type { ProfileResolver };
20
+
21
+ /** `provider/id` for a model, the form settings store. */
22
+ export function modelRef(model: Pick<Model<Api>, "provider" | "id">): string {
23
+ return `${model.provider}/${model.id}`;
24
+ }
25
+
26
+ /** Thinking levels a model supports, in pi's order; every level when the model is unknown. */
27
+ export function supportedThinking(model: Model<Api> | undefined): ThinkingLevelName[] {
28
+ if (!model) return [...THINKING_LEVELS];
29
+ return getSupportedThinkingLevels(model).filter((level): level is ThinkingLevelName => isThinkingLevel(level));
30
+ }
31
+
32
+ export interface ThinkingCheck {
33
+ /** The level to run at: the requested one, or pi's nearest supported level. */
34
+ level: string;
35
+ /** Set when the requested level had to change. */
36
+ warning?: string;
37
+ }
38
+
39
+ /** Clamp `level` to what `model` supports, explaining any change; unknown models pass through. */
40
+ export function checkThinking(model: Model<Api> | undefined, level: string): ThinkingCheck {
41
+ if (!model || !isThinkingLevel(level)) return { level };
42
+ const clamped = clampThinkingLevel(model, level as ModelThinkingLevel);
43
+ if (clamped === level) return { level };
44
+ return { level: clamped, warning: `"${level}" thinking isn't supported by ${modelRef(model)} — using "${clamped}"` };
45
+ }
46
+
47
+ const LABELS: Record<SubagentKind | "master", string> = {
48
+ master: "Master",
49
+ designer: "Designer",
50
+ backend: "Backend",
51
+ qa: "QA",
52
+ scout: "Scout",
53
+ researcher: "Researcher",
54
+ quickfix: "Quick fix",
55
+ planner: "Planner",
56
+ };
57
+
58
+ export function kindLabel(kind: SubagentKind | "master"): string {
59
+ return LABELS[kind];
60
+ }
61
+
62
+ export interface ResolverOptions {
63
+ lookup: ModelLookup;
64
+ /** The session's model as `provider/id`; used only where settings name no model yet. */
65
+ sessionModel?: string;
66
+ /** Receives each distinct clamp warning once per process. */
67
+ warn?: (message: string) => void;
68
+ }
69
+
70
+ const warned = new Set<string>();
71
+
72
+ /**
73
+ * Profiles straight from settings, with the thinking level clamped to what
74
+ * each model supports. A subagent whose model is still unset runs on the
75
+ * session's model (named explicitly on the command line) until settings pin one.
76
+ */
77
+ export function createProfileResolver(config: BotLobbyConfig, options: ResolverOptions): ProfileResolver {
78
+ return (domain, role) => {
79
+ const profile = agentProfile(config, domain, role);
80
+ const model = profile.model ?? options.sessionModel;
81
+ const check = checkThinking(model ? options.lookup(model) : undefined, profile.thinking);
82
+ if (check.warning) {
83
+ const message = `bot-lobby: ${kindLabel(profile.kind)} — ${check.warning}. Change it in /bot-lobby settings.`;
84
+ if (!warned.has(message)) {
85
+ warned.add(message);
86
+ options.warn?.(message);
87
+ }
88
+ }
89
+ return { ...profile, model, thinking: check.level };
90
+ };
91
+ }
92
+
93
+ type RunProfile = { model?: string; thinking: string; timeoutMs: number; instructions?: string };
94
+
95
+ /** Unset models run on the session's; thinking is clamped to the model, with a one-time warning. */
96
+ function resolveRunProfile(profile: RunProfile, label: string, options: ResolverOptions): RunProfile {
97
+ const model = profile.model ?? options.sessionModel;
98
+ const check = checkThinking(model ? options.lookup(model) : undefined, profile.thinking);
99
+ if (check.warning) {
100
+ const message = `bot-lobby: ${label} — ${check.warning}. Change it in /bot-lobby settings.`;
101
+ if (!warned.has(message)) {
102
+ warned.add(message);
103
+ options.warn?.(message);
104
+ }
105
+ }
106
+ return { ...profile, model, thinking: check.level };
107
+ }
108
+
109
+ /** A lobby agent's model, thinking and time limit. */
110
+ export function resolveLobbyProfile(config: BotLobbyConfig, kind: LobbyAgentKind, options: ResolverOptions): RunProfile {
111
+ return resolveRunProfile(lobbyAgentProfile(config, kind), kindLabel(kind), options);
112
+ }
113
+
114
+ /** A planning panel seat's model, thinking and time limit, from its domain's (or the researcher's) settings. */
115
+ export function resolvePanelProfile(config: BotLobbyConfig, member: PanelMember, options: ResolverOptions): RunProfile {
116
+ return resolveRunProfile(panelMemberProfile(config, member), kindLabel(member), options);
117
+ }
118
+
119
+ /** Every configured subagent whose thinking level its model does not support, for `/bot-lobby config`. */
120
+ export function thinkingMismatches(config: BotLobbyConfig, lookup: ModelLookup, sessionModel?: string): string[] {
121
+ const entries: Array<[SubagentKind | "master", string, string]> = [
122
+ ["master", config.master.model, config.master.thinking],
123
+ ["designer", config.agents.designer.model, config.agents.designer.thinking],
124
+ ["backend", config.agents.backend.model, config.agents.backend.thinking],
125
+ ["qa", config.agents.qa.model, config.agents.qa.thinking],
126
+ ["researcher", config.researcher.model, config.researcher.thinking],
127
+ ["quickfix", config.quickFix.model, config.quickFix.thinking],
128
+ ["planner", config.planner.model, config.planner.thinking],
129
+ ];
130
+ return entries.flatMap(([kind, ref, level]) => {
131
+ const model = ref === "inherit" ? sessionModel : ref;
132
+ const check = checkThinking(model ? lookup(model) : undefined, level);
133
+ return check.warning ? [`${kindLabel(kind)}: ${check.warning}`] : [];
134
+ });
135
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * One-line descriptions of subagent runs, shared by the transcript entries,
3
+ * `/bot-lobby runs`, the Master's reports and the zen feed row. Pure: every
4
+ * clock read arrives as `now`.
5
+ */
6
+ import type { AgentRun } from "../schemas/findings.ts";
7
+ import type { RunLogEntry } from "../schemas/task.ts";
8
+ import { shortDuration } from "../text.ts";
9
+ import { SLOT_LABELS, type SlotId } from "./mascot-art.ts";
10
+
11
+ /** The zen column a run belongs to; a researcher reports under RESEARCH from any domain. */
12
+ export function slotOf(run: Pick<AgentRun, "domain" | "role">): SlotId {
13
+ if (run.role === "researcher") return "research";
14
+ if (run.domain === "backend") return "dev";
15
+ return run.domain === "designer" ? "design" : "qa";
16
+ }
17
+
18
+ /** Upper-case agent name as the scene shows it (DEV, DESIGN, RESEARCH, QA). */
19
+ export function agentName(run: Pick<AgentRun, "domain" | "role">): string {
20
+ return SLOT_LABELS[slotOf(run)];
21
+ }
22
+
23
+ /** Compact token count: 950, 41k, 1.2M. */
24
+ export function tokens(count: number): string {
25
+ if (!Number.isFinite(count) || count < 1000) return String(Math.max(0, Math.round(count || 0)));
26
+ if (count < 1_000_000) return `${Math.round(count / 1000)}k`;
27
+ return `${(count / 1_000_000).toFixed(1)}M`;
28
+ }
29
+
30
+ function durationOf(run: Pick<AgentRun, "startedAt" | "finishedAt">, now: number): number {
31
+ const end = run.finishedAt ? Date.parse(run.finishedAt) : now;
32
+ const elapsed = end - Date.parse(run.startedAt);
33
+ return Number.isFinite(elapsed) && elapsed > 0 ? elapsed : 0;
34
+ }
35
+
36
+ const STATUS_ICONS: Record<AgentRun["status"], string> = {
37
+ running: "◐",
38
+ success: "✓",
39
+ failed: "✗",
40
+ cancelled: "·",
41
+ timeout: "✗",
42
+ };
43
+
44
+ /** Why a run ended the way it did, in words, when that is not plain success. */
45
+ export function runFlags(run: Pick<AgentRun, "status" | "stalled" | "wrappedUp" | "attempts">): string[] {
46
+ const flags: string[] = [];
47
+ if (run.stalled) flags.push("stalled");
48
+ else if (run.status === "timeout") flags.push("hit its time limit");
49
+ else if (run.status === "failed") flags.push("failed");
50
+ else if (run.status === "cancelled") flags.push("cancelled");
51
+ if (run.wrappedUp) flags.push(run.status === "success" ? "wrapped up early — report may be partial" : "asked to wrap up");
52
+ if (run.attempts > 1) flags.push(`${run.attempts} attempts`);
53
+ return flags;
54
+ }
55
+
56
+ /**
57
+ * `✓ DEV worker · 3m 12s · 9 turns · 23 tools · 41k↑ 6k↓ · $0.12 · provider/model`,
58
+ * followed by any flags (stalled, time limit, wrapped up early, attempts).
59
+ */
60
+ export function describeRun(run: AgentRun, now = Date.now()): string {
61
+ const parts = [`${STATUS_ICONS[run.status]} ${agentName(run)} ${run.role}`, shortDuration(durationOf(run, now))];
62
+ const turns = run.turns ?? run.usage?.turns;
63
+ if (turns) parts.push(`${turns} turn${turns === 1 ? "" : "s"}`);
64
+ if (run.tools) parts.push(`${run.tools} tool${run.tools === 1 ? "" : "s"}`);
65
+ if (run.usage && (run.usage.input || run.usage.output)) parts.push(`${tokens(run.usage.input)}↑ ${tokens(run.usage.output)}↓`);
66
+ if (run.usage?.cost) parts.push(`$${run.usage.cost.toFixed(2)}`);
67
+ if (run.model) parts.push(run.model);
68
+ parts.push(...runFlags(run));
69
+ const line = parts.join(" · ");
70
+ return run.status === "success" || !run.error ? line : `${line} — ${run.error.split("\n")[0]}`;
71
+ }
72
+
73
+ /** The persisted form of a finished run, for `task.runLog`. */
74
+ export function runLogEntry(run: AgentRun): RunLogEntry {
75
+ return {
76
+ runId: run.runId,
77
+ domain: run.domain,
78
+ role: run.role,
79
+ status: run.status,
80
+ startedAt: run.startedAt,
81
+ ...(run.finishedAt ? { finishedAt: run.finishedAt } : {}),
82
+ ...(run.model ? { model: run.model } : {}),
83
+ ...(run.thinking ? { thinking: run.thinking } : {}),
84
+ ...(run.turns ?? run.usage?.turns ? { turns: run.turns ?? run.usage?.turns } : {}),
85
+ ...(run.tools ? { tools: run.tools } : {}),
86
+ ...(run.usage ? { input: run.usage.input, output: run.usage.output, cost: run.usage.cost } : {}),
87
+ attempts: run.attempts,
88
+ ...(run.stalled ? { stalled: true } : {}),
89
+ ...(run.wrappedUp ? { wrappedUp: true } : {}),
90
+ ...(run.error ? { error: run.error.split("\n")[0]!.slice(0, 200) } : {}),
91
+ };
92
+ }
93
+
94
+ /** A persisted entry back as a run, so `describeRun` formats both the same way. */
95
+ export function runFromLog(entry: RunLogEntry, taskId: string): AgentRun {
96
+ return {
97
+ runId: entry.runId,
98
+ taskId,
99
+ domain: entry.domain,
100
+ role: entry.role,
101
+ status: entry.status,
102
+ output: "",
103
+ attempts: entry.attempts ?? 1,
104
+ startedAt: entry.startedAt,
105
+ ...(entry.finishedAt ? { finishedAt: entry.finishedAt } : {}),
106
+ ...(entry.model ? { model: entry.model } : {}),
107
+ ...(entry.thinking ? { thinking: entry.thinking } : {}),
108
+ ...(entry.turns ? { turns: entry.turns } : {}),
109
+ ...(entry.tools ? { tools: entry.tools } : {}),
110
+ ...(entry.input !== undefined ? { usage: { input: entry.input, output: entry.output ?? 0, cost: entry.cost ?? 0, turns: entry.turns ?? 0 } } : {}),
111
+ ...(entry.stalled ? { stalled: true } : {}),
112
+ ...(entry.wrappedUp ? { wrappedUp: true } : {}),
113
+ ...(entry.error ? { error: entry.error } : {}),
114
+ };
115
+ }
116
+
117
+ /** Silence after which a working agent reads as "quiet" in the scene. */
118
+ export const QUIET_MS = 45_000;
119
+
120
+ /** How long a running agent has produced nothing, or 0 when it is not running or has no clock yet. */
121
+ export function quietFor(run: Pick<AgentRun, "status" | "lastEventAt" | "startedAt">, now: number): number {
122
+ if (run.status !== "running") return 0;
123
+ const last = run.lastEventAt ?? Date.parse(run.startedAt);
124
+ const quiet = now - last;
125
+ return Number.isFinite(quiet) && quiet > 0 ? quiet : 0;
126
+ }
127
+
128
+ export interface FeedLine {
129
+ text: string;
130
+ kind: "info" | "warning";
131
+ }
132
+
133
+ /** Ticks one working agent holds the feed before it rotates to the next (~3 s at the live clock). */
134
+ export const FEED_ROTATE_TICKS = 12;
135
+
136
+ /** What one working agent is doing right now, with a warning when something needs attention. */
137
+ function liveFeed(run: AgentRun, now: number): FeedLine {
138
+ const name = agentName(run);
139
+ if (run.waitingFor) return { text: `⧗ ${name} waiting for ${run.waitingFor}${run.note ? ` · ${run.note}` : ""}`, kind: "warning" };
140
+ const quiet = quietFor(run, now);
141
+ if (quiet >= QUIET_MS) {
142
+ const doing = run.activity ? ` (last: ${run.activity}${run.detail ? ` ${run.detail}` : ""})` : "";
143
+ return { text: `! ${name} quiet for ${shortDuration(quiet)}${doing}`, kind: "warning" };
144
+ }
145
+ if (run.note && run.noteKind === "warning") return { text: `! ${name} ${run.note}`, kind: "warning" };
146
+ const parts = [`▸ ${name} ${run.activity ?? "working"}${run.detail ? ` ${run.detail}` : ""}`];
147
+ const turns = run.turns ?? run.usage?.turns;
148
+ if (turns) parts.push(`turn ${turns}`);
149
+ if (run.tools) parts.push(`${run.tools} tools`);
150
+ if (run.usage && run.usage.input + run.usage.output > 0) parts.push(`${tokens(run.usage.input + run.usage.output)} tok`);
151
+ if (run.note) parts.push(run.note);
152
+ return { text: parts.join(" · "), kind: "info" };
153
+ }
154
+
155
+ /**
156
+ * The scene's feed row: a working agent that needs attention first, otherwise
157
+ * the working agents in rotation, otherwise the most recent finished run.
158
+ * Undefined before anything has run.
159
+ */
160
+ export function feedLine(runs: readonly AgentRun[], now: number, tick: number): FeedLine | undefined {
161
+ const running = runs.filter((run) => run.status === "running");
162
+ if (running.length > 0) {
163
+ const lines = running.map((run) => liveFeed(run, now));
164
+ const warning = lines.find((line) => line.kind === "warning");
165
+ if (warning) return warning;
166
+ const index = Math.floor(Math.max(0, tick) / FEED_ROTATE_TICKS) % lines.length;
167
+ return lines[index];
168
+ }
169
+ const last = [...runs].filter((run) => run.finishedAt).sort((a, b) => Date.parse(a.finishedAt!) - Date.parse(b.finishedAt!)).at(-1);
170
+ if (!last) return undefined;
171
+ return { text: describeRun(last, now), kind: last.status === "success" && !last.wrappedUp ? "info" : "warning" };
172
+ }