RESCENE fan mod: WONI conducts while LIV, MINAMI, MAY and ZENA take the subagent and Orca worker tasks and report back, in Korean or English. 리센느 팬 모드: 원이가…

English · 한국어
A mod (plugin) for Claude Code in which WONI of the K-pop group RESCENE conducts, and LIV, MINAMI, MAY and ZENA take the work and report back in their own voices. A card for each member shows who is doing what right now. It is an unofficial fan mod made by a REMINE (a RESCENE fan). It runs in Korean or English: Korean for a Korean reader, English for anyone else. Version 0.9.1.

Ask WONI something like "have MINAMI fix the README, LIV review the code and MAY look into the docs", and the panel on the right shows the three of them at work. When they are done, WONI gathers the results and reports to you.
The screenshots are the terminal output of real Claude Code sessions, redrawn character for character and color for color. The one above is the built-in demo (
/rescene demo) in English; the ones further down that show Korean text were taken in Korean mode. Fonts and colors vary a little by terminal.
claude in a terminal. If you do not have it yet, install it first from the Claude Code page.claude --version, and bring an old one up to date with claude update.claude. /plugin install rescene --marketplace techkwon/rescene
Add marketplace? → yInstalled rescene. Plugin is now active., you are done. It is on at once, with no restart.Type /rescene in the prompt. The members panel opens on the right and the reply says The RESCENE members panel is open.
git clone https://github.com/techkwon/rescene.git
claude --plugin-dir ./rescene
This loads the mod for that session only. Use it to try the mod without installing it, or to work on it.
Run these in a terminal, outside Claude Code.
claude plugin update rescene@rescene
claude plugin uninstall rescene@rescene
An update takes effect after Claude Code is started again. To turn the mod off for a while, do not remove it: type /rescene off, and /rescene on to turn it back on.
/rescene demo. Four make-believe tasks run for about 15 seconds and show how the screen moves. No real work is done and nothing is sent to the model. Have LIV review src/index.js, and have MINAMI add a usage section to the README.
/rescene minami, to see what she did, step by step (her backstage)./rescene usage.Work handed to members costs tokens for each member. WONI does simple things herself and hands out only what is worth splitting.
| Member | Heart | Role | What she takes |
|---|---|---|---|
| WONI 원이 (the leader) | 💚 | lead | Splits the work, gathers the results and tells you. This is the main session |
| LIV 리브 (nicknamed 최종병기, "the final weapon") | ♥ | review | Review, verification, tests |
| MINAMI 미나미 (the all-rounder) | 💙 | build | Building, fixing |
| MAY 메이 (nicknamed 메기자) | 💛 | research | Research, documents, write-ups |
| ZENA 제나 (the youngest) | 💜 | scout | Exploring code, locating things, errands |
Who takes which kind of work is yours to change. Press r: Roles under the panel, or type /rescene role, and the screen below opens. Press a role and that member takes it, swapping with whoever had it. A changed role applies from the next task and stays for later sessions. auto puts every member back in her own position.

Each member's card has a border in her color, a pixel icon, what she is on right now (▸ Writing: README.md), her state (● working, ✓ done, ○ idle) and a line of hers. The icon of a member at work moves, and the card of one at rest is dimmed. The small bar at a card's right is her tool use over the last two minutes or so. Below are the usage card and the stage log, where the members' lines pile up.
With room to spare (50 columns and 37 rows or more) the cards carry icons; with less they are framed cards, and with less still two rows a member.
Press a member's name in the panel (or a number, 1 to 5, when the panel has focus) and it turns to what she has been doing: her task, the first line of her brief, what she is on, her tool calls newest first, and her report once she is done. A call that was refused or failed is marked Refused: or Failed:. 0, or her name again, goes back to all five.


Send to [ auto ] ♥ WONI lead ♥ LIV review …: press a name and she takes your prompts from the next one on. The default is auto (WONI hands the work out). Press WONI and she does the work herself instead of handing it on. Press the same name again, or auto, to undo.Roles at the end opens the roles screen.Send to [ auto ] is left.💚 WONI directing traffic or 💙 MINAMI Editing: view.tsx. After a turn comes a line of WONI's (“우이!” and the like) and how long it took.On stage now: …. WONI working alone is WONI running single-core. The unit names are the fans' own, and stay in Korean./rescene cup gives the whole ranking.light in it, the mod draws in deeper colors that read on a light background. A changed theme is followed from your next prompt. Where that is not right, set "Screen brightness" to light or dark.
| You type | What it does |
|---|---|
/rescene | Opens the members panel |
/rescene demo | Runs four make-believe tasks for about 15 seconds to show the screen. No real work is done |
/rescene on · /rescene off | Turns the mode on and off. Off, the voice instructions and the drawings stop |
/rescene usage | Your usage in text: context, limits, cost, tokens by member |
/rescene cup | The quote cup: the lines said most this session |
/rescene liv (a member's name) | Opens her backstage. /rescene all goes back to all five |
/rescene to minami | Your prompts go to her from the next one on. /rescene to woni has WONI do the work herself, and /rescene to auto undoes it (the default) |
/rescene role | Opens the roles screen |
/rescene role liv build | Gives LIV the build work, in exchange with the member who had it. Roles: build · review · research · scout |
/rescene role auto | Puts the roles back to auto (the default) |
/rescene lang | Says which language is in use and how to change it |
/rescene lang en · ko · auto | Changes the language of the screens and the instructions, or gives it back to auto. A language you pick stays for later sessions |
/rescene clear | Clears the record of finished tasks, the stage log and the quote cup. Tasks under way and the tokens counted by member stay |
Names and roles may be typed in either language: /rescene 리브, /rescene role 리브 구현 and /rescene to 자동 work as well.
A member you picked also takes a slash command that carries a request, such as /goal fix the button. She does not take a command that only sets something up, such as /model, /compact or /voice. The two are told apart by a list of command names, so a new setup command that is not on the list may get the note about the pick (it is only text for the model, and changes nothing about what the command does).
You choose these on the screen that asks at install time, and change them later by typing /plugin configure rescene@rescene in the prompt.
| Key | Name | Default | What it does |
|---|---|---|---|
leaderVoice | WONI's voice | on | The main session speaks as WONI, the leader |
memberVoice | Members' voice | on | A subagent reports in the voice of the member it was cast as |
language | Language | auto | Write the value as text. auto (Korean for a Korean reader, English for anyone else) · ko · en. Within a session, /rescene lang changes it |
band | Band above the prompt | on | Shows who takes the next prompt and your usage, and the members at work while there is work |
bandStyle | Shape of the band | full | Write the value as text. full (a color rule, each member with her heart and role, usage bars) · compact (one row at rest, two at work) |
autoOpen | Open the panel by itself | on | Opens the panel when work is handed to a member (where the terminal is 144 columns or wider). Closed by hand, it stays closed until /rescene |
theme | Screen brightness | auto | Write the value as text. auto (follows the Claude Code theme) · dark · light |
orcaVoice | Orca workers' voice | on | Hands a fleet-run worker a copy of its spec with the member's voice block at its end (only where you use Orca) |
keepScreen | Keep an Orca worker's screen | off | Adds tee after a one-step fleet-run so <out>.live.log is kept. Off by default, because it changes your command |
The default is auto: Korean for a Korean reader, English for anyone else.
language, not left at its default): Korean if it names Korean (Korean, ko, 한국어 and the like), English if it names any otherLC_ALL, LC_MESSAGES, LANG starts with kokoAsia/SeoulOtherwise it is English. A session that auto began in English turns Korean at the first prompt written in Korean, and a toast says so with the way back (/rescene lang en). A prompt counts as Korean when, members' names aside, it has more Hangul syllables than English words: a Korean word quoted in an English sentence does not.
/rescene lang en, /rescene lang ko, /rescene lang auto. A language picked by command stays for later sessions and has the last word over the language setting. auto goes back to the setting, or to the signs above where the setting says auto.| What you see | What to try |
|---|---|
/plugin install … is not available | That line works only in Claude Code run in a terminal, not in the desktop app's Code tab. Installed once from a terminal (user scope), the mod loads in the desktop app as well |
| The marketplace is not found | Check that the address is techkwon/rescene |
There is no /rescene | The mod is not installed, or is disabled. In a terminal, see whether claude plugin list has rescene. If Claude Code is old, claude update |
| The panel does not open by itself | It does not where the terminal is narrower than 144 columns. Type /rescene and it opens at any width |
| No icons, only cards of text | That is how a small panel looks. Make the terminal window bigger (37 rows or more for the panel) for the icon cards |
| The colors are faint or hard to read | Set "Screen brightness" to light or dark to match your theme |
| The screens are in Korean (or in English) | /rescene lang en (or ko). It stays for later sessions |
| The voice gets in the way of work | Turn the mod off with /rescene off, or turn off only leaderVoice and memberVoice in the settings (the screens stay) |
| The band above the prompt takes too much room | Set "Shape of the band" to compact, or turn band off to be rid of it |
If none of that helps, run claude --debug and paste the lines that begin with rescene: into an issue. Please take out anything personal and any key first.
A mod runs with your own permissions, not in a sandbox. So here is everything it reads, runs, writes and changes.
HOME (to resolve ~ in a command), SHELL (to know whether the shell is zsh or bash), and LC_ALL, LC_MESSAGES and LANG (to tell whether the reader is Korean). For that same question it also reads the answer language set in Claude Code and the runtime's locale and time zone. It reads no credential or key.orca terminal read --terminal <handle> --screen, to read the screen of a worker tab you opened in Orca. When orca is not on the path it runs /Applications/Orca.app/Contents/Resources/bin/orca.tail -c 16000 <out>.live.log, to read only the end of a long progress log kept when keepScreen is on.fleet-run spec, a copy named <name>.rescene-<member>.md with the member's voice instructions appended, which the other AI worker reads as its instructions. The original is left alone, and nothing is written with orcaVoice off. Besides that, the role settings and a language picked with /rescene lang are kept in Claude Code's plugin store.prompt.compose), and adds a note to a prompt when you have picked a member or changed the roles, the language or the mode (prompt.submit).agent.spawn), and adds one line naming the member to an Agent tool's result (tool.call).ui.render).--spec path of a fleet-run is pointed at the copy above (orcaVoice), and with keepScreen on a one-step fleet-run is piped through tee. Every other tool input goes through as it is./rescene, and no tools or agents.Give an AI coding tool such as Claude Code this repository's address and ask:
Read AGENTS.md at https://github.com/techkwon/rescene and install the rescene mod.
AGENTS.md is written for an AI to read: the install commands, how to check the install, how to hand work to members in a session with the mode on, and the rules for changing this repository's code.
This part is for people who launch other AI workers (Codex, Grok and so on) from Orca with fleet-run <profile> --spec <file> --out <file>. If you do not use Orca you can skip it: the mod works the same without Orca.
codex-hard is cast by the name of its spec file (LIV when it has audit, review or 점검 in it).fleet-run where the shell really runs it counts. The name inside text handed to grep or echo, in a comment or in a script's body is not a task.<name>.rescene-<member>.md) is made in the same folder and the worker is run from that copy (not with orcaVoice off).<out>.meta.json a worker leaves as it ends tells the card whether it succeeded, its model, the time it took and its tokens. Where the result file's path could not be read, the task stays "waiting" and you are told to check for yourself.orca terminal create … --command "… fleet-run …" and nothing else (a filter such as | grep … at its end is fine), the mod runs orca terminal read --screen with the terminal handle that command printed, and every six seconds shows the last step on the worker's screen on her card and in her backstage. Your command is not changed. With other steps mixed in, with the output sent elsewhere, or with more than one handle printed so that it cannot be sure whose terminal answered, the screen is not read.unreadable now · as last read, and the reads are spaced out to at most a minute.fleet-run … run straight, with no Orca tab, has no screen to show: only its result file is waited for. To see its screen all the same, turn on keepScreen. Then, when the command is one fleet-run … and the shell is zsh or bash, the command is run as fleet-run … 2>&1 | { tee '<out>.live.log' 2>/dev/null || cat; }; (exit "${PIPESTATUS[0]:-${pipestatus[1]}}"). The exit code and the arguments are the same (tested in zsh and bash), but standard error is joined to standard output, and where fleet-run is a shell function, the shell variables and the current folder it changes do not last..live.log files stay beside the spec and result files. The mod does not delete them: you may, once the work is done.git clone https://github.com/techkwon/rescene.git
cd rescene
claude --plugin-dir .
This runs Claude Code with the mod loaded. Once it has loaded, Claude Code lays the type files in .claude-plugin/types/ (they are not in the repository). Then run the checks.
tsc -p . --noUnusedLocals
claude plugin validate .
claude plugin test .
| Path | What it holds |
|---|---|
.claude-plugin/plugin.json | Name, version, settings |
.claude-plugin/marketplace.json | What makes this repository an install address |
hooks/register.tsx | Every hook: casting, voice instructions, the command, usage, the Orca integration |
hooks/view.tsx | The panel, the band, the backstage, the roles screen |
hooks/lang.ts | Picking the language (Korean or English) and telling who reads Korean |
hooks/members.ts · voice.ts · action.ts · orca.ts · sprites.ts | Members and lines, the voice texts, the phrase for each tool, reading shell commands, the pixel icons |
types/index.d.ts | The contract for the session's state |
tests/ | 115 tests |
The rules for changing the code are in AGENTS.md.
hooks/register.tsx 1721 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Cast, Line, Live, MemberId, Role, Said, Task, Tokens, Usage } from '../types'
5
6import { actionOf } from './action'
7import type { Lang } from './lang'
8import { isKorean, langNow, langOf, pinned, runtimeSigns, setLang, t } from './lang'
9
10import type { Moment, WorkRole } from './members'
11import { CAST, castOf, isActive, isSameCast, LEADER, lineOf, MEMBERS, MOMENTS, nameOf, ORDER, pickMember, recast, roleIn, roleName, roleOfAgent, roleOfRun, say } from './members'
12import { cheerOf, FRAME_MS, frameOf, iconOf } from './sprites'
13import { findFleetRuns, isDetached, isPlainLaunch, readMeta, voicedSpecPath } from './orca'
14import type { Scene } from './view'
15import { backstageLines, castLines, densityOf, drawBand, drawPane, moversOf, plus, rosterLines, shownTask, spoken, usageLines, ZERO } from './view'
16import { aimBlock, askOf, castBlock, doingOf, firstLine, leaderSection, linesOf, MARKS, memberBlock, namedMember, orcaBlock, soloBlock } from './voice'
17
18const PANE = 'rescene'
19const TICK_MS = 2000
20const SLOW_MS = 180_000
21const LINGER_MS = 45_000
22const KEPT = 40
23const FEED_KEPT = 12
24const CUP_KEPT = 40
25/** How long each stretch of a task's pulse is, and how many are kept. */
26const PULSE_MS = 10_000
27const PULSE_KEPT = 12
28/** How many of a member's steps are kept: what the backstage can list. */
29const TRAIL_KEPT = 40
30const PEEK_LINES = 40
31/** How often a worker's screen is read for what it is on. */
32const WATCH_MS = 6000
33const PANE_SIZE = { rows: 36, columns: 72 } as const
34const LOST_MS = 3_600_000
35/** Whether a task already ends with a member's block, in either language. */
36const isCast = (text: string): boolean => MARKS.some(opening => text.includes(opening))
37const TYPED = new Set(['composer', 'bridge', 'sdk'])
38const standDown = (): string =>
39 t('리센느 모드가 꺼졌다. 지금부터는 원이 말투와 멤버 배정 이야기를 쓰지 않고 평소대로 답한다.', "RESCENE mode is off. From now on answer as usual, without WONI's voice or any talk of members and casting.")
40
41const isOn = atom({ plugin: 'rescene', key: 'isOn' } as const, true)
42const tasks = atom({ plugin: 'rescene', key: 'tasks' } as const, [])
43const clockNow = atom({ plugin: 'rescene', key: 'now' } as const, 0)
44const waveAt = atom({ plugin: 'rescene', key: 'waveAt' } as const, 0)
45const leader = atom({ plugin: 'rescene', key: 'leader' } as const, null)
46const ticker = atom({ plugin: 'rescene', key: 'ticker' } as const, null)
47const isPaneOpen = atom({ plugin: 'rescene', key: 'isPaneOpen' } as const, false)
48const usage = atom({ plugin: 'rescene', key: 'usage' } as const, null)
49const leaderTokens = atom({ plugin: 'rescene', key: 'leaderTokens' } as const, { fresh: 0, cached: 0, out: 0 })
50const briefed = atom({ plugin: 'rescene', key: 'briefed' } as const, 'no')
51const isPaneDismissed = atom({ plugin: 'rescene', key: 'isPaneDismissed' } as const, false)
52const feed = atom({ plugin: 'rescene', key: 'feed' } as const, [])
53const moments = atom({ plugin: 'rescene', key: 'moments' } as const, [])
54const cup = atom({ plugin: 'rescene', key: 'cup' } as const, [])
55const turn = atom({ plugin: 'rescene', key: 'turn' } as const, null)
56const live = atom({ plugin: 'rescene', key: 'live' } as const, {})
57const steps = atom({ plugin: 'rescene', key: 'steps' } as const, {})
58const focus = atom({ plugin: 'rescene', key: 'focus' } as const, null)
59const peek = atom({ plugin: 'rescene', key: 'peek' } as const, null)
60const target = atom({ plugin: 'rescene', key: 'target' } as const, null)
61const banked = atom({ plugin: 'rescene', key: 'banked' } as const, {})
62const isLight = atom({ plugin: 'rescene', key: 'isLight' } as const, false)
63const cast = atom({ plugin: 'rescene', key: 'cast' } as const, CAST)
64const isCasting = atom({ plugin: 'rescene', key: 'isCasting' } as const, false)
65const isRecast = atom({ plugin: 'rescene', key: 'isRecast' } as const, false)
66/** The key the cast is kept under between sessions. */
67const CAST_KEY = 'cast'
68/** The language this session came to, kept across a reload; nothing before it is settled. */
69const lang = atom({ plugin: 'rescene', key: 'lang' } as const, null)
70/** The key a language picked by command is kept under between sessions. */
71const LANG_KEY = 'lang'
72
73type Engine = EngineInterface
74type Draft = Pick<Task, 'id' | 'kind' | 'role' | 'engine' | 'title'> & Pick<Task, 'out' | 'call' | 'brief' | 'live'>
75
76const SPINNER_KO: Record<string, string> = {
77 requesting: '원이 큐시트 보는 중',
78 thinking: '원이 교통정리 중',
79 responding: '원이 멘트 중',
80 'tool-input': '원이 작업지시 쓰는 중',
81 'tool-use': '원이 지휘 중',
82}
83const SPINNER_EN: Record<string, string> = {
84 requesting: 'WONI reading the cue sheet',
85 thinking: 'WONI directing traffic',
86 responding: 'WONI at the mic',
87 'tool-input': 'WONI writing a brief',
88 'tool-use': 'WONI leading',
89}
90const spinnerOf = (mode: string): string => t(SPINNER_KO, SPINNER_EN)[mode] ?? t(SPINNER_KO.thinking, SPINNER_EN.thinking) ?? ''
91
92const sceneOf = async ($: Engine): Promise<Scene> => {
93 const [list, now, wave, led, ticked, used, ledTokens, said, voted, turned, doing, done, shown, peeked, aimed, kept, roles, isSetting] = await Promise.all([
94 read($, tasks),
95 read($, clockNow),
96 read($, waveAt),
97 read($, leader),
98 read($, ticker),
99 read($, usage),
100 read($, leaderTokens),
101 read($, feed),
102 read($, cup),
103 read($, turn),
104 read($, live),
105 read($, steps),
106 read($, focus),
107 read($, peek),
108 read($, target),
109 read($, banked),
110 read($, cast),
111 read($, isCasting),
112 // Read for the drawing's sake: a change of language draws everything that shows a scene again.
113 read($, lang),
114 ])
115
116 return { tasks: list, now, waveAt: wave, leader: led, ticker: ticked, usage: used, leaderTokens: ledTokens, feed: said, cup: voted, turn: turned, live: doing, steps: done, focus: shown, peek: peeked, target: aimed, banked: kept, cast: roles, isCasting: isSetting }
117}
118
119/** Moves the clock the drawings read to this moment. */
120const stamp = async ($: Engine): Promise<void> => {
121 const now = await $.clock.now()
122
123 await update($, clockNow, () => now)
124}
125
126const lastSaidAt = (scene: Pick<Scene, 'leader' | 'ticker'>): number =>
127 Math.max(scene.leader?.at ?? 0, scene.ticker?.at ?? 0)
128
129/** Whether there is something to show unasked: work in hand, or a line just said. */
130const isLive = (scene: Scene): boolean =>
131 scene.turn !== null || scene.tasks.some(isActive) || (lastSaidAt(scene) > 0 && scene.now - lastSaidAt(scene) < LINGER_MS)
132
133/** A member says a line: the band shows it, and the stage log keeps it. */
134const speak = async ($: Engine, said: Said): Promise<void> => {
135 // Said in a quiet session, it still has its while on the band: the clock runs on for it and then stops.
136 quietBeats = 0
137 await update($, clockNow, now => Math.max(now, said.at))
138 if (said.member === 'woni') await update($, leader, () => said)
139 else await update($, ticker, () => said)
140 await update($, feed, list => [...list, said].slice(-FEED_KEPT))
141 // The 명대사 월드컵: every line said is a vote for it.
142 await update($, cup, list => {
143 // A line two members say is each one's own: the votes are for her saying it.
144 const isSame = (line: Line): boolean => line.member === said.member && line.quote === said.quote
145 const voted = list.some(isSame)
146 ? list.map(line => (isSame(line) ? { ...line, count: line.count + 1 } : line))
147 : [...list, { member: said.member, quote: said.quote, count: 1 }]
148
149 return voted.sort((a, b) => b.count - a.count).slice(0, CUP_KEPT)
150 })
151}
152
153/**
154 * A member says her line for a moment in the session, once: it is said again
155 * only after the moment has passed (`pass`) and come round again.
156 */
157const mark = async ($: Engine, moment: Moment, note: string): Promise<void> => {
158 const box = { isNew: false }
159
160 await update($, moments, list => {
161 box.isNew = !list.includes(moment)
162
163 return box.isNew ? [...list, moment] : list
164 })
165 if (!box.isNew) return
166 const { member, quote } = MOMENTS[moment]
167
168 await speak($, { member, quote, note, at: await $.clock.now() })
169 $.ui.toast(`${MEMBERS[member].heart} ${nameOf(member)} “${quote}” ${note}`)
170 dance($)
171}
172
173const pass = async ($: Engine, moment: Moment): Promise<void> => {
174 if ((await read($, moments)).includes(moment)) await update($, moments, list => list.filter(one => one !== moment))
175}
176
177const spentNote = (used: number): string => t(`한도를 ${Math.round(used)}% 썼어요`, `${Math.round(used)}% of the limit used`)
178
179/** The plan's limits and the context, as the moments they are when nearly spent. */
180const weigh = async ($: Engine, figures: Usage): Promise<void> => {
181 const used = Math.max(0, ...figures.limits.filter(limit => limit.kind !== 'spend_limit').map(limit => limit.percentUsed))
182 const full = figures.contextPercent ?? 0
183
184 const spentSoFar = spentNote(used)
185
186 if (used >= 95) await mark($, 'starved', spentSoFar)
187 else if (used >= 80) await mark($, 'quota', spentSoFar)
188 if (used < 60) {
189 await pass($, 'starved')
190 await pass($, 'quota')
191 }
192 if (full >= 80) await mark($, 'heavy', t(`컨텍스트가 ${Math.round(full)}% 찼어요`, `the context is ${Math.round(full)}% full`))
193 else if (full < 50) await pass($, 'heavy')
194}
195
196/** Whether the command itself writes the file: a redirect into it, `tee`, `cp` or `mv` onto it. */
197export const writes = (command: string, rawPath: string): boolean => {
198 const path = rawPath.replace(/^["']|["']$/g, '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
199
200 return new RegExp(`(?:>{1,2}|\\btee\\s+(?:-a\\s+)?|\\b(?:cp|mv)\\s+\\S+\\s+)\\s*["']?${path}(?=["'\\s;&|)]|$)`).test(command)
201}
202
203/** Adds a task and hands it to a member, who says her line for taking it. */
204const reserve = async ($: Engine, draft: Draft, wanted?: MemberId): Promise<Task> => {
205 const now = await $.clock.now()
206 const box: { task: Task; isWaveStart: boolean; crowd: number; dropped: Task[] } = {
207 task: { ...draft, member: 'minami', status: 'running', startedAt: now, toolCount: 0, quote: '', note: '' },
208 isWaveStart: false,
209 crowd: 0,
210 dropped: [],
211 }
212
213 const roles = await read($, cast)
214
215 await update($, tasks, list => {
216 const member = wanted ?? pickMember(draft.role, list, roles)
217 const turn = list.filter(task => task.member === member).length
218
219 box.isWaveStart = !list.some(isActive)
220 box.crowd = list.filter(one => isActive(one) && one.id !== draft.id).length + 1
221 box.task = {
222 ...box.task,
223 member,
224 quote: say(member, 'start', turn, draft.role),
225 note: t(`${draft.role} 시작 · ${draft.title}`, `${roleName(draft.role)} started · ${draft.title}`),
226 }
227
228 // Only what has ended is history to trim: a task at work stays however long the list.
229 const all = [...list.filter(task => task.id !== draft.id), box.task]
230 const ended = all.filter(task => !isActive(task))
231
232 box.dropped = ended.slice(0, Math.max(0, ended.length - KEPT))
233
234 return box.dropped.length === 0 ? all : all.filter(task => !box.dropped.includes(task))
235 })
236 await bank($, box.dropped)
237
238 const { task } = box
239
240 if (box.isWaveStart) {
241 await update($, waveAt, () => now)
242 await speak($, { member: 'woni', quote: LEADER.wave, note: t('멤버들에게 일을 맡기는 중', 'handing work to the members'), at: now })
243 }
244 await speak($, { member: task.member, quote: task.quote, note: task.note, at: now + 1 })
245 dance($)
246 if (box.crowd >= CROWD) await mark($, 'crowd', t(`${box.crowd}개 작업이 한꺼번에 돌아가요`, `${box.crowd} tasks running at once`)).catch(() => undefined)
247 await update($, clockNow, () => now)
248 if (box.isWaveStart && isAutoOpening && !(await read($, isPaneOpen)) && !(await read($, isPaneDismissed))) {
249 // Unasked, so the surface seats it only where there is width to spare;
250 // where it does not, the band above the prompt carries the stage.
251 void $.ui.open({ id: PANE, title: 'RESCENE', closeOnEscape: true, ...PANE_SIZE }).then(
252 opened => {
253 isSeatWaited = !opened.isPlaced
254
255 return opened.isPlaced ? update($, isPaneOpen, () => true) : undefined
256 },
257 () => undefined,
258 )
259 }
260
261 return task
262}
263
264/** Keeps what tasks leaving the list cost, so a member's total never shrinks. */
265const bank = async ($: Engine, gone: readonly Task[]): Promise<void> => {
266 const spent = gone.filter(task => task.tokens !== undefined)
267
268 if (spent.length === 0) return
269 await update($, banked, kept => {
270 const next = { ...kept }
271
272 for (const task of spent) next[task.member] = plus(next[task.member] ?? ZERO, task.tokens)
273
274 return next
275 })
276}
277
278const drop = ($: Engine, id: string): Promise<unknown> => update($, tasks, list => list.filter(task => task.id !== id))
279
280/**
281 * Ends a task: the member says how it went, and 원이 sums up a finished wave.
282 *
283 * `isGuess` is an ending nobody reported (the engine no longer lists the
284 * agent): what is reported later overrules it. An Orca worker's meta file is
285 * its whole count, so it is taken once; an agent's turns each add theirs.
286 */
287const settle = async ($: Engine, id: string, isOk: boolean, why: string, tokens?: Tokens, report = '', isGuess = false): Promise<void> => {
288 const now = await $.clock.now()
289 const box: { ended?: Task; all: Task[] } = { all: [] }
290
291 await update($, tasks, list => {
292 box.ended = undefined
293 box.all = list.map(task => {
294 if (task.id !== id) return task
295 const isRevised = !isActive(task) && task.isGuessed === true && !isGuess
296
297 if (!isActive(task) && !isRevised) {
298 const counted = task.kind === 'orca' ? (task.tokens ?? tokens) : tokens === undefined ? task.tokens : plus(task.tokens ?? ZERO, tokens)
299
300 return {
301 ...task,
302 ...(counted === undefined ? {} : { tokens: counted }),
303 ...(task.report === undefined && report !== '' ? { report } : {}),
304 }
305 }
306 const { tool: _tool, detail: _detail, isGuessed: _isGuessed, ...rest } = task
307 const endedAt = task.endedAt ?? now
308 const took = spoken(endedAt - task.startedAt)
309 const ended: Task = {
310 ...rest,
311 status: isOk ? 'done' : 'failed',
312 endedAt,
313 quote: say(task.member, isOk ? 'done' : 'fail', task.toolCount + task.title.length, task.role),
314 note: [t(`${task.role} ${isOk ? '끝' : '실패'}`, `${roleName(task.role)} ${isOk ? 'done' : 'failed'}`), took, why].filter(part => part !== '').join(' · '),
315 ...(tokens === undefined ? {} : { tokens: plus(task.tokens ?? ZERO, tokens) }),
316 ...(report === '' ? {} : { report }),
317 ...(isGuess ? { isGuessed: true } : {}),
318 }
319
320 // A guess that turned out right has been said already; one that was wrong is said again.
321 if (!isRevised || (task.status === 'done') !== isOk) box.ended = ended
322
323 return ended
324 })
325
326 return box.all
327 })
328
329 const { ended } = box
330
331 // Ended after the mode went off: counted, and nothing said or shown of it.
332 if (ended === undefined || !isLit) return
333 const member = MEMBERS[ended.member]
334
335 await speak($, { member: ended.member, quote: ended.quote, note: ended.note, at: now })
336 if (isOk) cheers[ended.member] = beat + CHEER_BEATS
337 dance($)
338 $.ui.toast(`${member.heart} ${nameOf(ended.member)} “${ended.quote}” ${ended.note}`)
339
340 if (!box.all.some(isActive)) {
341 const since = await read($, waveAt)
342 const wave = box.all.filter(task => task.startedAt >= since)
343 const failed = wave.filter(task => task.status === 'failed').length
344 await pass($, 'crowd').catch(() => undefined)
345 const quote = failed > 0 ? say('woni', 'fail', wave.length + failed, '지휘') : wave.length >= 2 ? LEADER.sweep : LEADER.tasty
346 const note = failed > 0 ? t(`${wave.length}개 가운데 ${failed}개 실패`, `${failed} of ${wave.length} failed`) : t(`${wave.length}개 작업 끝`, `${wave.length} ${wave.length === 1 ? 'task' : 'tasks'} done`)
347
348 await speak($, { member: 'woni', quote, note, at: now + 1 })
349 if (wave.length >= 2) $.ui.toast(`${MEMBERS.woni.heart} ${nameOf('woni')} “${quote}” ${note}`)
350 }
351 await update($, clockNow, () => now + 1)
352}
353
354/** Gives a subagent's task the id its loop's events carry. */
355const adopt = async ($: Engine, agentId: string): Promise<void> => {
356 const list = await read($, tasks)
357
358 if (list.some(task => task.id === agentId)) return
359 const waiting = list.filter(task => task.kind === 'agent' && task.id.startsWith('spawn:') && isActive(task))
360
361 if (waiting.length === 0) return
362 const info = (await $.agent.list().catch(() => [])).find(agent => agent.id === agentId)
363 const match =
364 waiting.find(task => info !== undefined && info.description.endsWith(task.title) && info.type === task.engine) ??
365 (waiting.length === 1 ? waiting[0] : undefined)
366
367 if (match === undefined) return
368 await update($, tasks, now => now.map(task => (task.id === match.id ? { ...task, id: agentId } : task)))
369}
370
371/** Notes the tool a member's loop is on; a finished member who works again is back on stage. */
372const touch = async ($: Engine, agentId: string, tool: string, detail: string, past: string, call: string): Promise<void> => {
373 await adopt($, agentId)
374 if (!(await read($, tasks)).some(task => task.id === agentId)) return
375 const at = await $.clock.now()
376
377 await update($, tasks, list =>
378 list.map(task => {
379 if (task.id !== agentId) return task
380 const { endedAt: _endedAt, ...rest } = task
381 // One more call in the stretch of time this one falls in; the stretches skipped were quiet.
382 const pulseAt = Math.max(0, Math.floor((at - task.startedAt) / PULSE_MS))
383 const quiet = Math.max(0, pulseAt - (task.pulseAt ?? pulseAt))
384 const beats = [...(task.pulse ?? [0]), ...Array.from({ length: quiet }, () => 0)]
385 const pulse = [...beats.slice(0, -1), (beats.at(-1) ?? 0) + 1].slice(-PULSE_KEPT)
386 const trail = [...(task.trail ?? []), { at, text: past, call }].slice(-TRAIL_KEPT)
387 const next = { tool, detail, toolCount: task.toolCount + 1, pulse, pulseAt, trail }
388
389 return isActive(task) ? { ...task, ...next } : { ...rest, status: 'running' as const, ...next }
390 }),
391 )
392}
393
394/** How a call that did not go through is told. */
395const howOf = (how: '거절됨' | '실패'): string => (how === '거절됨' ? t('거절됨', 'Refused') : t('실패', 'Failed'))
396
397/** A member's call was refused or failed: her record says so instead of saying it was done. */
398const amend = async ($: Engine, agentId: string, call: string, how: '거절됨' | '실패'): Promise<void> => {
399 await update($, tasks, list =>
400 list.map(task =>
401 task.id !== agentId || task.trail?.some(step => step.call === call) !== true
402 ? task
403 : { ...task, trail: task.trail.map(step => (step.call !== call ? step : { ...step, text: `${howOf(how)}: ${step.text.includes(': ') ? step.text.slice(step.text.indexOf(': ') + 2) : step.text}` })) },
404 ),
405 )
406}
407
408const tokensOf = (spent: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }): Tokens => ({
409 fresh: spent.input_tokens + spent.cache_creation_input_tokens,
410 cached: spent.cache_read_input_tokens,
411 out: spent.output_tokens,
412})
413
414type Figures = Pick<Awaited<ReturnType<Engine['session']['usage']>>, 'context' | 'rateLimits' | 'cost'>
415
416/** Takes the session's figures, read or pushed; writes them only when one moved. */
417const takeUsage = async ($: Engine, figures: Figures): Promise<void> => {
418 const next: Usage = {
419 contextWindow: figures.context.window,
420 limits: figures.rateLimits.map(limit => {
421 const resetsAt = limit.resetsAt === undefined ? Number.NaN : Date.parse(limit.resetsAt)
422
423 return { kind: limit.kind, percentUsed: limit.percentUsed, ...(Number.isNaN(resetsAt) ? {} : { resetsAt }) }
424 }),
425 ...(figures.context.percent === undefined ? {} : { contextPercent: figures.context.percent }),
426 ...(figures.context.tokens === undefined ? {} : { contextTokens: figures.context.tokens }),
427 ...(figures.cost === undefined ? {} : { usd: figures.cost.usd }),
428 }
429
430 if (JSON.stringify(next) === JSON.stringify(await read($, usage))) return
431 await update($, usage, () => next)
432 if (await read($, isOn)) await weigh($, next).catch(() => undefined)
433}
434
435const refreshUsage = async ($: Engine): Promise<void> => takeUsage($, await $.session.usage())
436
437/** A report longer than this is not read for its first line. */
438const REPORT_BYTES = 512_000
439
440/** Ends an Orca worker whose `<out>.meta.json` has landed since it started. */
441const pollWorker = async ($: Engine, task: Task): Promise<void> => {
442 if (task.out === undefined) return
443 const path = `${task.out}.meta.json`
444 const stat = await $.fs.stat(path).catch(() => undefined)
445
446 // Its own meta file: newer than the one the path held when the worker was launched.
447 if (stat === undefined || stat.mtimeMs <= (task.metaAt ?? task.startedAt - 1000)) return
448 const meta = readMeta(await $.fs.read(path).catch(() => ''))
449
450 if (meta === undefined) return
451 const size = (await $.fs.stat(task.out).catch(() => undefined))?.size ?? 0
452 const report = size > REPORT_BYTES ? '' : firstLine(await $.fs.read(task.out).catch(() => ''))
453
454 await settle($, task.id, meta.isOk, meta.note, meta.tokens, report)
455}
456
457/** What a shell command printed, where its result says. */
458const printed = (ran: object): string => {
459 const result = (ran as { result?: unknown }).result
460 const out = typeof result === 'object' && result !== null ? (result as { stdout?: unknown }).stdout : undefined
461
462 return typeof out === 'string' ? out : ''
463}
464
465/** What of a command stands outside its quotes. */
466const bareOf = (command: string): string => command.trim().replace(/"[^"]*"|'[^']*'/g, '')
467
468/**
469 * The command is the opening of one Orca terminal that runs the launch, and nothing else: no step
470 * before or after, nothing run at once, no output sent elsewhere. What it prints is then that
471 * terminal's answer, whole or picked out by a filter it is piped to (`| grep -o …`).
472 */
473const isTerminalAlone = (command: string): boolean => {
474 const bare = bareOf(command)
475 const [first = '', ...filters] = bare.split('|')
476
477 return (
478 (command.match(/\borca\s+terminal\s+create\b/g) ?? []).length === 1 &&
479 /^\s*(?:\S*\/)?orca\s+terminal\s+create\b/.test(first) &&
480 /\borca\s+terminal\s+create\b(?:"[^"]*"|'[^']*'|[^;&|\n"'])*?\s--command\s+(["'])(?:(?!\1)[^\n])*\bfleet-run\b/.test(command) &&
481 !/[;&<>\n`]|\$\(/.test(bare) &&
482 filters.every(filter => /^\s*(?:grep|jq|head|tail|cat|tee|sed|awk|cut|tr)\b/.test(filter))
483 )
484}
485
486/** The handle such a command printed: the one it names, and none where it names two or says it failed. */
487const termOf = (text: string): string | undefined => {
488 const handles = [...new Set([...text.matchAll(/"handle"\s*:\s*"(term_[\w-]+)"/g)].map(found => found[1]))]
489
490 return handles.length === 1 && !/"ok"\s*:\s*false/.test(text) ? handles[0] : undefined
491}
492
493/** Points one `--spec <file>` of a command at another file, leaving the file's other mentions. */
494const swapSpec = (command: string, raw: string, path: string): string => {
495 for (const joint of [' ', '=']) {
496 const at = command.indexOf(`--spec${joint}${raw}`)
497
498 if (at >= 0) return `${command.slice(0, at)}--spec${joint}${path}${command.slice(at + 7 + raw.length)}`
499 }
500
501 return command
502}
503
504// The icons move while their member works: each beat repaints the cells in
505// place (`$.ui.blit`), with no redraw and nothing written to state.
506const CROWD = 4
507/** Reads of one file in a turn before 원이 says they keep meeting. */
508const MET_AGAIN = 5
509const CHEER_BEATS = 12
510const REST_BEATS = 25
511
512let dancer: Timer | undefined
513let beat = 0
514let restBeats = 0
515/** Whether the pane is drawn with its icons, as the last drawing of it had it. */
516let isStaged = false
517/** The members at work, as the last drawing of the pane had them. */
518let moving: readonly MemberId[] = []
519/** The members whose icon is off its still pose. */
520let posed: readonly MemberId[] = []
521/** The members whose icon the pane draws: all five, or the one whose backstage it shows. */
522let iconed: readonly MemberId[] = ORDER
523/** The theme the pane was last drawn in: the moving icons are painted in its colors. */
524let isLightDrawn = false
525/** The beat each member hops until, for a task of hers that ended well. */
526const cheers: { [id in MemberId]?: number } = {}
527
528const stepIcons = async ($: Engine): Promise<void> => {
529 beat += 1
530 const cheering = ORDER.filter(id => (cheers[id] ?? 0) > beat)
531 const now = isStaged ? iconed.filter(id => moving.includes(id) || cheering.includes(id)) : []
532 const left = isStaged ? posed.filter(id => !now.includes(id) && iconed.includes(id)) : []
533
534 posed = now
535 if (now.length === 0 && left.length === 0) {
536 restBeats += 1
537 if (restBeats > REST_BEATS) {
538 dancer?.cancel()
539 dancer = undefined
540 }
541
542 return
543 }
544 restBeats = 0
545 const painted = await Promise.all([
546 ...now.map(id => $.ui.blit({ requestId: PANE, key: `icon-${id}`, cells: cheering.includes(id) ? cheerOf(id, beat, isLightDrawn) : frameOf(id, beat, isLightDrawn) })),
547 ...left.map(id => $.ui.blit({ requestId: PANE, key: `icon-${id}`, cells: iconOf(id, isLightDrawn) })),
548 ])
549
550 // Not drawn any more (closed, or drawn without icons): the next drawing says so again.
551 if (painted.some(one => one.deny !== undefined)) isStaged = false
552}
553
554/** Starts the beat the icons move to; it stops by itself once nobody has moved for a while. */
555const dance = ($: Engine): void => {
556 restBeats = 0
557 dancer ??= $.clock.every(FRAME_MS, () => void stepIcons($).catch(() => undefined))
558}
559
560let isTicking = false
561let isAutoOpening = true
562/** How often the main session has read each file this turn. */
563const reads = new Map<string, number>()
564/** The prompt typed last, until the turn it opens takes it as what was asked. */
565/** Whether the mode is on, as the last command or start left it: what a hook under way since before reads. */
566let isLit = true
567/** How many times the roles have been changed: what a prompt on its way compares, once it has entered. */
568let recasts = 0
569/** How many times the mode was turned on or off by command: a start that began before one does not speak for it. */
570let flips = 0
571let asked = ''
572/** Whom that prompt was for, where the person had named a member. */
573let askedTo: MemberId | null = null
574/** The pane was opened unasked and waits for the terminal to widen. */
575let isSeatWaited = false
576
577/**
578 * Drops the ended Orca tasks whose worker left no meta file: a worker that
579 * ran writes one, so these were a command that only spoke of a launch.
580 */
581const sweep = async ($: Engine): Promise<void> => {
582 const ghosts: string[] = []
583
584 for (const task of await read($, tasks)) {
585 if (task.kind !== 'orca' || isActive(task) || task.tokens !== undefined || task.engine === '시연') continue
586 const isRun = task.out !== undefined && (await $.fs.exists(`${task.out}.meta.json`).catch(() => false))
587
588 if (!isRun) ghosts.push(task.id)
589 }
590 if (ghosts.length > 0) await update($, tasks, list => list.filter(task => !ghosts.includes(task.id)))
591}
592
593/** The theme the mod was told to draw for; `auto` follows the engine's own. */
594let palette = 'auto'
595
596/** Whether the theme by that name is a light one: `light`, `light-daltonized`, `light-ansi`. */
597const isLightNamed = (name: unknown): boolean => typeof name === 'string' && /light/i.test(name)
598
599/** Sets the colors to the terminal's theme: the mod's own setting, else the engine's theme by its name. */
600const readTheme = async ($: Engine, named?: unknown): Promise<void> => {
601 const theme = palette !== 'auto' ? palette : (named ?? (await $.config.list().catch(() => [])).find(row => row.key === 'theme')?.value)
602 const light = isLightNamed(theme)
603
604 if (light !== (await read($, isLight))) await update($, isLight, () => light)
605}
606
607const ORCA = ['orca', '/Applications/Orca.app/Contents/Resources/bin/orca'] as const
608/** The `orca` that started, once one has. */
609let orcaAt: string | undefined
610
611/**
612 * An Orca worker's screen as it runs: the file its launch keeps it in, or
613 * the Orca terminal it was launched in, read through the `orca` command.
614 */
615/** A worker's screen as it was last read: the text, when, how many readings in a row have failed since, and whether one ever held. */
616type Seen = { at: number; text: string; fails: number; isRead: boolean }
617
618/**
619 * Reads a worker's screen: nothing where it could not be read, which is not an empty screen.
620 * A file that is not there yet is an empty screen until it has been read once; after, it is a failure.
621 */
622const readScreen = async ($: Engine, task: Task, wasRead: boolean): Promise<string | undefined> => {
623 if (task.live !== undefined) {
624 const kept = await $.fs.stat(task.live).catch(() => undefined)
625
626 if (kept === undefined) return wasRead ? undefined : ''
627 if (kept.size === 0) return ''
628 if (kept.size <= REPORT_BYTES) return await $.fs.read(task.live).catch(() => undefined)
629 // Too long to read whole: its end, which is what is asked of it.
630 const end = await $.process.run(['tail', '-c', '16000', task.live], { timeoutMs: 5000 }).catch(() => undefined)
631
632 return end === undefined || end.exitCode !== 0 ? undefined : end.stdout
633 }
634 if (task.term === undefined) return ''
635 // The command by its name, and where the app keeps it for a session whose path does not have it.
636 for (const orca of orcaAt === undefined ? ORCA : [orcaAt]) {
637 const read = await $.process.run([orca, 'terminal', 'read', '--terminal', task.term, '--screen'], { timeoutMs: 5000 }).catch(() => undefined)
638
639 if (read === undefined) continue
640 orcaAt = orca
641
642 return read.exitCode === 0 ? read.stdout : undefined
643 }
644
645 return undefined
646}
647
648/** Each launch's screen as last read, and the reading under way: the card and the backstage ask for the same one. */
649const screens = new Map<string, Seen>()
650const reading = new Map<string, Promise<Seen>>()
651
652/** One launch's screen and no other's: a task of the same name started again, or shown elsewhere, is another. */
653const screenKey = (task: Task): string => `${task.id}\n${task.startedAt}\n${task.term ?? task.live ?? ''}`
654
655/** Puts away the screens of launches that no longer run. */
656const forget = (running: readonly Task[]): void => {
657 const kept = new Set(running.map(screenKey))
658
659 for (const key of screens.keys()) if (!kept.has(key)) screens.delete(key)
660 for (const key of reading.keys()) if (!kept.has(key)) reading.delete(key)
661}
662
663/**
664 * A worker's screen, read once a while whoever asks; one that cannot be read is asked for less
665 * and less often, up to once a minute, and keeps the text it had with how often it has failed.
666 */
667const screenOf = async ($: Engine, task: Task, now: number): Promise<Seen> => {
668 const key = screenKey(task)
669 const seen = screens.get(key)
670
671 if (seen !== undefined && now - seen.at < WATCH_MS * Math.min(2 ** seen.fails, 10)) return seen
672 const under = reading.get(key)
673
674 if (under !== undefined) return under
675 const next = readScreen($, task, seen?.isRead === true)
676 .catch(() => undefined)
677 .then(text => {
678 const read: Seen = text === undefined ? { at: now, text: seen?.text ?? '', fails: (seen?.fails ?? 0) + 1, isRead: seen?.isRead === true } : { at: now, text, fails: 0, isRead: seen?.isRead === true || text !== '' }
679
680 // Put away since it was asked for: its launch has ended, and the answer is nobody's.
681 if (reading.get(key) === next) {
682 screens.set(key, read)
683 reading.delete(key)
684 }
685
686 return read
687 })
688
689 reading.set(key, next)
690
691 return next
692}
693
694/** How many readings in a row may fail before what the card says she is on is taken off it. */
695const STALE_FAILS = 2
696
697/** Puts what a running worker's screen says it is on onto her card, every while. */
698const watch = async ($: Engine, task: Task, now: number): Promise<void> => {
699 if (task.status !== 'running' || (task.live === undefined && task.term === undefined)) return
700 const seen = await screenOf($, task, now)
701 const doing = seen.fails === 0 ? doingOf(linesOf(seen.text, PEEK_LINES, 'tail')) : ''
702
703 if (seen.fails >= STALE_FAILS && task.detail !== undefined) {
704 // Not read for a while: what it was on then is not what it is on now.
705 await update($, tasks, list => list.map(one => (one.id !== task.id || !isActive(one) ? one : (({ detail: _detail, ...rest }) => rest)(one))))
706
707 return
708 }
709 if (doing === '' || doing === task.detail) return
710 await update($, tasks, list => list.map(one => (one.id === task.id && isActive(one) ? { ...one, detail: doing } : one)))
711}
712
713/** Reads what the backstage shows of an Orca worker: its screen while it runs, its report once it has ended. */
714const peekAt = async ($: Engine): Promise<void> => {
715 const who = await read($, focus)
716 const task = who === null ? undefined : shownTask(await read($, tasks), who)
717 const shown = await read($, peek)
718 const isRunning = task !== undefined && isActive(task)
719
720 if (task === undefined || task.kind !== 'orca' || (isRunning ? task.live === undefined && task.term === undefined : task.out === undefined)) {
721 if (shown !== null) await update($, peek, () => null)
722
723 return
724 }
725 const size = isRunning || task.out === undefined ? 0 : ((await $.fs.stat(task.out).catch(() => undefined))?.size ?? 0)
726 // Only a launch that runs is read anew; one waited for with no word shows what was last read of it.
727 const seen = !isRunning ? undefined : task.status === 'running' ? await screenOf($, task, await $.clock.now()) : (screens.get(screenKey(task)) ?? { at: 0, text: '', fails: 1, isRead: false })
728 const text = seen !== undefined ? seen.text : task.out === undefined || size === 0 || size > REPORT_BYTES ? '' : await $.fs.read(task.out).catch(() => '')
729 const lines = linesOf(text, PEEK_LINES, isRunning ? 'tail' : 'head')
730 const isStale = seen !== undefined && seen.fails > 0
731
732 if (shown?.id === task.id && shown.lines.join('\n') === lines.join('\n') && (shown.isStale === true) === isStale) return
733 await update($, peek, () => ({ id: task.id, lines, ...(isStale ? { isStale } : {}) }))
734}
735
736/** Opens a member's backstage in the pane, or goes back to the five cards: her again, or nobody. */
737const pick = async ($: Engine, id: MemberId | null): Promise<void> => {
738 await update($, isCasting, () => false)
739 await update($, focus, now => (id === null || now === id ? null : id))
740 await peekAt($).catch(() => undefined)
741 await stamp($)
742 dance($)
743}
744
745/** Names the member the person's next prompts go to; 원이, or her again, is nobody named. */
746const aim = async ($: Engine, id: MemberId | null): Promise<void> => {
747 await update($, target, now => (id === null || now === id ? null : id))
748}
749
750/** Commands that set the session up and ask nothing of the model: what follows them is not work for a member. */
751const LOCAL = new Set([
752 'rescene', 'model', 'permissions', 'compact', 'config', 'clear', 'add-dir', 'mcp', 'plugin', 'plugins', 'resume', 'rename', 'effort', 'theme', 'output-style',
753 'memory', 'login', 'logout', 'agents', 'hooks', 'fast', 'vim', 'voice', 'cost', 'context', 'export', 'status', 'help', 'doctor', 'ide', 'terminal-setup',
754 'privacy-settings', 'release-notes', 'upgrade', 'usage', 'bug', 'feedback', 'exit', 'quit', 'rewind', 'color', 'skills', 'tasks', 'todos', 'buddy', 'workflows', 'artifacts',
755 'sandbox', 'bashes', 'keybindings', 'reload-plugins', 'remote-control', 'mobile', 'desktop', 'chrome', 'passes', 'stickers', 'extra-usage', 'install-github-app',
756])
757
758const unsaved = (): string => t('저장하지 못해서 이 세션에만 적용됩니다.', 'It could not be saved: it holds for this session only.')
759const unkept = (): string => t('역할은 바뀌었지만 저장하지 못했어요. 이 세션에만 적용됩니다.', 'The roles changed but could not be saved: they hold for this session only.')
760
761/**
762 * Gives a kind of work to a member, who had it taking hers in exchange;
763 * nobody named, every member has her own again (자동). Kept between
764 * sessions, and the main loop is told with the next prompt.
765 */
766const giveRole = async ($: Engine, id: MemberId | null, role: WorkRole): Promise<{ cast: Cast; isKept: boolean }> => {
767 const had = await read($, cast)
768 const next = id === null ? CAST : recast(had, id, role)
769
770 if (isSameCast(had, next)) {
771 // Her own position asked for where it already is: whatever a session before kept is put away all the same.
772 if (id !== null) return { cast: had, isKept: true }
773 // Written as this session's own, so a reload does not go back to what was kept.
774 await $.state.set({ plugin: 'rescene', key: 'cast' } as const, { ...CAST })
775
776 return { cast: had, isKept: await $.store.delete(CAST_KEY).then(() => true, () => false) }
777 }
778 await update($, cast, () => next)
779 await update($, isRecast, () => true)
780 recasts += 1
781 // Her own position is what there is with nothing kept.
782 const isKept = await (isSameCast(next, CAST) ? $.store.delete(CAST_KEY) : $.store.set(CAST_KEY, next)).then(() => true, () => false)
783
784 if (!isKept) $.ui.toast(unkept())
785 $.ui.invalidate('prompt.section')
786 if (id !== null && (await read($, isOn))) {
787 const at = await $.clock.now()
788
789 await speak($, { member: id, quote: say(id, 'start', at, role), note: t(`이제부터 ${role} 담당`, `on ${roleName(role)} from now on`), at })
790 }
791
792 return { cast: next, isKept }
793}
794
795/** Opens the pane on the screen the roles are set on, or goes back from it to the five cards. */
796const setup = async ($: Engine, isOpen: boolean): Promise<void> => {
797 await update($, isCasting, () => isOpen)
798 if (isOpen) {
799 await update($, focus, () => null)
800 const opened = await $.ui.open({ id: PANE, title: 'RESCENE', closeOnEscape: true, ...PANE_SIZE })
801
802 isSeatWaited = !opened.isPlaced
803 await update($, isPaneOpen, () => opened.isPlaced)
804 await update($, isPaneDismissed, () => false)
805 }
806 await stamp($)
807 dance($)
808}
809
810const ROLE_NAMED: Record<string, WorkRole> = { 구현: '구현', build: '구현', 검토: '검토', review: '검토', 조사: '조사', research: '조사', 탐색: '탐색', explore: '탐색', scout: '탐색' }
811
812const NAMED: Record<string, MemberId> = {
813 원이: 'woni',
814 woni: 'woni',
815 리브: 'liv',
816 liv: 'liv',
817 미나미: 'minami',
818 minami: 'minami',
819 메이: 'may',
820 may: 'may',
821 제나: 'zena',
822 zena: 'zena',
823}
824
825/** A pane that waited for width and has since been seated: the band gives way to it. */
826const seat = async ($: Engine): Promise<void> => {
827 if (!isSeatWaited) return
828 const isUp = (await $.ui.panes().catch(() => [])).some(pane => pane.id === PANE && pane.isPlaced)
829
830 if (!isUp) return
831 isSeatWaited = false
832 await update($, isPaneOpen, () => true)
833}
834let quietBeats = 0
835
836/** One beat: asks after every member at work, and moves the clock the drawings read. */
837const tick = async ($: Engine): Promise<void> => {
838 if (isTicking) return
839 isTicking = true
840 try {
841 if (!(await read($, isOn))) return
842 await seat($)
843 if ((await read($, focus)) !== null) await peekAt($).catch(() => undefined)
844 const active = (await read($, tasks)).filter(isActive)
845
846 if (active.length === 0 && screens.size + reading.size > 0) forget([])
847 if (active.length === 0 && (await read($, turn)) !== null) {
848 quietBeats = 0
849 if (isStaged) dance($)
850 await stamp($)
851
852 return
853 }
854 if (active.length === 0) {
855 // The clock runs on until the last line said has aged out and the band
856 // has left; after that a quiet session costs two reads a beat.
857 quietBeats += 1
858 if (quietBeats * TICK_MS <= LINGER_MS + 2 * TICK_MS) await stamp($)
859
860 return
861 }
862 quietBeats = 0
863 if (isStaged) dance($)
864 const now = await $.clock.now()
865
866 // A list that could not be read says nothing of who has ended: the agents are left as they are this beat.
867 const agents = active.some(task => task.kind === 'agent') ? await $.agent.list().catch(() => undefined) : []
868 // Every agent a task already has, ended ones too: a new task of the same name takes another.
869 const held = new Set((await read($, tasks)).map(one => one.id))
870
871 await Promise.all(active.filter(task => task.kind === 'orca').map(task => pollWorker($, task).catch(() => undefined)))
872 await Promise.all(active.filter(task => task.kind === 'orca').map(task => watch($, task, now).catch(() => undefined)))
873 forget(active.filter(task => task.status === 'running'))
874 for (const task of active) {
875 if (task.kind === 'orca') {
876 // Launched where its result could not be followed: after an hour it is off the stage, its ending a guess.
877 if (task.out === undefined && now - task.startedAt > LOST_MS) await settle($, task.id, true, t('결과는 직접 확인', 'check the result yourself'), undefined, '', true)
878 if (task.out !== undefined && task.status === 'running' && now - task.startedAt > LOST_MS) {
879 const note = t('한 시간이 넘도록 결과 파일이 안 보여요. 직접 확인해 주세요', 'No result file after more than an hour. Please check it yourself')
880
881 await update($, tasks, list => list.map(one => (one.id === task.id && isActive(one) ? { ...one, status: 'waiting' as const, note } : one)))
882 }
883 continue
884 }
885 if (agents === undefined) continue
886 const info = agents.find(agent => agent.id === task.id)
887
888 if (info === undefined) {
889 if (task.id.startsWith('spawn:')) {
890 // Its spawn answered no id: the list has it under the description
891 // the spawn hook gave it.
892 const found = agents.find(agent => !held.has(agent.id) && agent.type === task.engine && agent.description.endsWith(task.title))
893
894 if (found !== undefined) {
895 // Hers from here on: the next task of the same name takes another.
896 held.add(found.id)
897 await update($, tasks, list => (list.some(one => one.id === found.id) ? list : list.map(one => (one.id === task.id ? { ...one, id: found.id } : one))))
898 }
899 } else if (now - task.startedAt > 30_000) {
900 // The engine lists an agent until it drops its task: one it no longer
901 // lists has ended, and its turn's end did not reach this hook.
902 await settle($, task.id, true, t('결과는 직접 확인', 'check the result yourself'), undefined, '', true)
903 }
904 continue
905 }
906 if (info.status === 'completed') await settle($, task.id, true, '')
907 else if (info.status === 'failed' || info.status === 'killed') await settle($, task.id, false, info.status === 'killed' ? stopped() : '')
908 else if ((info.status === 'waiting') !== (task.status === 'waiting')) {
909 const status = info.status === 'waiting' ? ('waiting' as const) : ('running' as const)
910
911 await update($, tasks, list => list.map(one => (one.id === task.id && isActive(one) ? { ...one, status } : one)))
912 }
913 }
914
915 for (const task of active) {
916 if (task.isSlow === true || now - task.startedAt < SLOW_MS) continue
917 const quote = say(task.member, 'slow', task.toolCount + task.title.length, task.role)
918 const note = t(`${task.role} 계속하는 중 · ${spoken(now - task.startedAt)} 지남`, `still on ${roleName(task.role)} · ${spoken(now - task.startedAt)} in`)
919
920 const box = { isSlowed: false }
921
922 await update($, tasks, list =>
923 list.map(one => {
924 // One that ended earlier this beat has nothing left to be slow at.
925 if (one.id !== task.id || !isActive(one) || one.isSlow === true) return one
926 box.isSlowed = true
927
928 return { ...one, isSlow: true, quote, note }
929 }),
930 )
931 if (!box.isSlowed) continue
932 await speak($, { member: task.member, quote, note, at: now })
933 if (task.member === 'zena') {
934 await pass($, 'dawdle')
935 await mark($, 'dawdle', t(`제나가 ${spoken(now - task.startedAt)}째 ${task.role} 중`, `ZENA has been on ${roleName(task.role)} for ${spoken(now - task.startedAt)}`))
936 }
937 }
938
939 await update($, clockNow, () => now)
940 } finally {
941 isTicking = false
942 }
943}
944
945const stopped = (): string => t('중단됨', 'stopped')
946
947const DEMO: readonly (readonly [Role, string, string, number, boolean])[] = [
948 ['구현', '로그인 화면 고치기', 'fix the login screen', 9000, true],
949 ['검토', '바뀐 코드 다시 보기', 'look over the changed code', 13_000, true],
950 ['조사', '최신 문서 찾아 정리', 'find and sum up the latest docs', 6000, true],
951 ['탐색', '설정 파일 위치 찾기', 'find where the config file is', 4000, false],
952]
953
954let timer: Timer | undefined
955
956/** The language the mod's own setting names, when it names one. */
957let fixedLang: Lang | undefined
958/** Whether the language in use is one the person picked by command. */
959let isLangPicked = false
960/** Whether the session's language has been settled since this module loaded: until then nothing is changed by guess. */
961let isLangRead = false
962/** How many times a language was picked or given back by command: a reading begun before one does not undo it. */
963let langPicks = 0
964
965/** Registers `/rescene`, described in the language in use. */
966const enroll = ($: Engine): Promise<unknown> =>
967 $.command.register({
968 name: 'rescene',
969 description: t('리센느 모드: 멤버 현황 패널을 열거나 켜고 끈다', 'RESCENE mode: open the members panel, or turn the mode on and off'),
970 argumentHint: t(
971 '[on|off|usage|cup|clear|demo|<멤버>|all|to <멤버|자동>|role [<멤버> <역할>|auto]|lang [ko|en|auto]]',
972 '[on|off|usage|cup|clear|demo|<member>|all|to <member|auto>|role [<member> <role>|auto]|lang [ko|en|auto]]',
973 ),
974 immediate: true,
975 })
976
977/** Korean for a reader the session's signs say is Korean, English for anyone else. */
978const detect = async ($: Engine): Promise<Lang> => {
979 const setting = (await $.config.list().catch(() => [])).find(row => row.key === 'language')?.value
980 // Each by its own name: the variables a module reads are the ones written in it.
981 const env = [await $.env.get('LC_ALL').catch(() => undefined), await $.env.get('LC_MESSAGES').catch(() => undefined), await $.env.get('LANG').catch(() => undefined)]
982
983 return langOf({ ...(typeof setting === 'string' ? { setting } : {}), env, ...runtimeSigns() })
984}
985
986/**
987 * Settles the language: the one picked by command, else the one the mod's
988 * setting names, else what this session already came to, else by the signs.
989 */
990const readLang = async ($: Engine): Promise<void> => {
991 const picks = langPicks
992 const picked = pinned(await $.store.get(LANG_KEY).catch(() => undefined))
993 const held = await read($, lang)
994 const next = picked ?? fixedLang ?? held ?? (await detect($))
995
996 // Picked by command while this was read: that pick has the last word.
997 if (picks !== langPicks) return
998 isLangPicked = picked !== undefined
999 isLangRead = true
1000 setLang(next)
1001 if (held === next) return
1002 await update($, lang, () => next)
1003 // Briefed in the other language before (a pick made in another session, a setting changed): briefed anew in this one.
1004 if (held !== null) {
1005 await update($, briefed, told => (told === 'yes' ? 'no' : told))
1006 $.ui.invalidate('prompt.section')
1007 }
1008}
1009
1010/** Changes the language in use: the screens at once, and the main loop is briefed anew in it. */
1011const speakIn = async ($: Engine, next: Lang): Promise<void> => {
1012 const isNew = langNow() !== next
1013
1014 setLang(next)
1015 await update($, lang, () => next)
1016 if (!isNew) return
1017 await update($, briefed, told => (told === 'yes' ? 'no' : told))
1018 $.ui.invalidate('prompt.section')
1019 await enroll($).catch(() => undefined)
1020 dance($)
1021}
1022
1023export const register: Register = (on, options) => {
1024 const isLeaderVoiced = options.leaderVoice !== false
1025 const isMemberVoiced = options.memberVoice !== false
1026 const isOrcaVoiced = options.orcaVoice !== false
1027 const isBandShown = options.band !== false
1028 const isBandFull = options.bandStyle !== 'compact'
1029 const isScreenKept = options.keepScreen === true
1030
1031 isAutoOpening = options.autoOpen !== false
1032 // Named in the setting, that language from the first line; otherwise a first reading, settled as the session starts.
1033 fixedLang = pinned(options.language)
1034 setLang(fixedLang ?? langOf(runtimeSigns()))
1035 palette = options.theme === 'dark' || options.theme === 'light' ? options.theme : 'auto'
1036
1037 on('session.start', async ($, e, next) => {
1038 // Registered before anything is waited for, in the language of the first reading.
1039 await enroll($)
1040 const flipped = flips
1041 const spoke = langNow()
1042
1043 // Settled before anything else is waited for: a prompt typed meanwhile is not read by a first guess.
1044 await readLang($).catch(() => undefined)
1045 if (langNow() !== spoke) await enroll($).catch(() => undefined)
1046
1047 // Calls open with no turn under way are of a module that is gone, and have ended; with one under
1048 // way they may still be running, and are closed by their own end or the turn's.
1049 if ((await read($, turn)) === null) await update($, live, now => Object.fromEntries(Object.entries(now).map(([id, one]) => [id, { ...one, running: 0, open: [] }])))
1050 await sweep($).catch(() => undefined)
1051 const mine = (await $.ui.panes().catch(() => [])).find(pane => pane.id === PANE)
1052
1053 isSeatWaited = mine !== undefined && !mine.isPlaced
1054 await update($, isPaneOpen, () => mine?.isPlaced === true)
1055 await readTheme($).catch(() => undefined)
1056 // The roles the person set in a session before this one.
1057 // Only a session that has no cast of its own yet: one set since, kept or not, is not gone back on by a reload.
1058 if ((await $.state.get({ plugin: 'rescene', key: 'cast' } as const)).version === 0) {
1059 const kept = castOf(await $.store.get(CAST_KEY).catch(() => undefined))
1060
1061 if (!isSameCast(kept, CAST)) await update($, cast, () => kept)
1062 }
1063 const lit = await read($, isOn)
1064
1065 // Read as of the start: a command given since, while this waited, has the last word.
1066 if (flips === flipped) isLit = lit
1067 await refreshUsage($).catch(() => undefined)
1068 timer?.cancel()
1069 timer = $.clock.every(TICK_MS, () => void tick($).catch(() => undefined))
1070
1071 return next(e)
1072 })
1073
1074 on('command.run', { command: 'rescene' }, async ($, e) => {
1075 const asked = e.args.trim().toLowerCase()
1076
1077 await readTheme($).catch(() => undefined)
1078
1079 if (asked === 'on' || asked === 'off') {
1080 await update($, isOn, () => asked === 'on')
1081 isLit = asked === 'on'
1082 flips += 1
1083 await update($, briefed, told => (asked === 'off' ? (told === 'yes' ? 'undo' : told) : told === 'undo' ? 'yes' : told))
1084 if (asked === 'off') {
1085 await update($, turn, () => null)
1086 await update($, live, () => ({}))
1087 }
1088 $.ui.invalidate('prompt.section')
1089
1090 return {
1091 text:
1092 asked === 'on'
1093 ? `${MEMBERS.woni.heart} ${nameOf('woni')} “${LEADER.wave}” ${t('리센느 모드를 켰어요.', 'RESCENE mode is on.')}`
1094 : `${MEMBERS.woni.heart} ${nameOf('woni')} “애기 자께예~♡” ${t('리센느 모드를 껐어요. /rescene on 으로 다시 켭니다.', 'RESCENE mode is off. /rescene on turns it back on.')}`,
1095 }
1096 }
1097 if (asked === 'usage') {
1098 await refreshUsage($).catch(() => undefined)
1099 await stamp($)
1100
1101 return { text: [t('RESCENE 사용량', 'RESCENE usage'), ...usageLines(await sceneOf($))].join('\n') }
1102 }
1103 if (asked === 'clear') {
1104 // What the ended tasks cost stays counted: only the history is cleared.
1105 // A worker whose result cannot be followed never ends by itself: clearing lets go of it too.
1106 const isKept = (task: Task): boolean => isActive(task) && !(task.kind === 'orca' && task.out === undefined && task.status === 'waiting')
1107
1108 await bank($, (await read($, tasks)).filter(task => !isKept(task)))
1109 await update($, tasks, list => list.filter(isKept))
1110 await update($, steps, () => ({}))
1111 await update($, peek, () => null)
1112 await update($, leader, () => null)
1113 await update($, ticker, () => null)
1114 await update($, feed, () => [])
1115 await update($, cup, () => [])
1116
1117 return { text: t('끝난 작업 기록을 지웠어요.', 'Cleared the record of finished tasks.') }
1118 }
1119 if (asked === 'cup') {
1120 const ranked = (await read($, cup)).slice(0, 8)
1121 const rows = ranked.map((line, index) => t(`${index + 1}위 ${MEMBERS[line.member].heart} ${nameOf(line.member)} “${line.quote}” ${line.count}번`, `#${index + 1} ${MEMBERS[line.member].heart} ${nameOf(line.member)} “${line.quote}” ×${line.count}`))
1122
1123 return { text: [t('명대사 월드컵 (이 세션에서 많이 나온 대사)', 'Quote cup (the lines said most this session)'), ...(rows.length === 0 ? [t('아직 나온 대사가 없어요.', 'No line said yet.')] : rows)].join('\n') }
1124 }
1125 if (asked === 'demo') {
1126 for (const [index, [role, ko, en, ms, isOk]] of DEMO.entries()) {
1127 const what = t(ko, en)
1128 const task = await reserve($, { id: `orca:demo:${index}`, kind: 'orca', role, engine: '시연', title: t(`시연: ${what}`, `Demo: ${what}`) })
1129
1130 $.clock.after(ms, () => void settle($, task.id, isOk, t('시연', 'demo'), undefined, isOk ? t(`시연이라 실제로 한 일은 없어요 (${what})`, `A demo: nothing was really done (${what})`) : '').catch(() => undefined))
1131 }
1132
1133 return { text: t('시연을 시작했어요. 실제 작업이 아니라 화면을 보여 주는 가짜 작업 4개가 15초쯤 돌아갑니다.', 'The demo has started: four make-believe tasks run for about 15 seconds to show the screen. No real work is done.') }
1134 }
1135
1136 if (asked === 'lang' || asked === '언어' || asked.startsWith('lang ') || asked.startsWith('언어 ')) {
1137 const word = asked.split(/\s+/)[1] ?? ''
1138 const how = (): string => t('바꾸려면 /rescene lang ko · en · auto (auto: 한국어 사용자는 한국어, 그 밖에는 영어)', 'To change: /rescene lang ko · en · auto (auto: Korean for a Korean reader, English for anyone else)')
1139 const from = (): string => (isLangPicked ? t('직접 고름', 'picked by you') : fixedLang !== undefined ? t('모드 설정 language', "the mod's language setting") : t('자동', 'auto'))
1140
1141 if (word === '') return { text: [t(`지금 언어: 한국어 (${from()})`, `Language now: English (${from()})`), how()].join('\n') }
1142 if (word === 'auto' || word === '자동') {
1143 langPicks += 1
1144 const isKept = await $.store.delete(LANG_KEY).then(() => true, () => false)
1145
1146 isLangPicked = false
1147 isLangRead = true
1148 await speakIn($, fixedLang ?? (await detect($)))
1149
1150 return { text: [t(`언어를 자동으로 돌렸어요. 지금은 한국어입니다 (${from()}).`, `The language is back to auto. It is English now (${from()}).`), ...(isKept ? [] : [unsaved()])].join('\n') }
1151 }
1152 const next = pinned(word)
1153
1154 if (next === undefined) return { text: how() }
1155 langPicks += 1
1156 const isKept = await $.store.set(LANG_KEY, next).then(() => true, () => false)
1157
1158 isLangPicked = true
1159 isLangRead = true
1160 await speakIn($, next)
1161
1162 return { text: isKept ? t('이제 한국어로 보여 드려요. 다음 세션에도 그대로입니다.', 'English from now on, in later sessions too.') : [t('이제 한국어로 보여 드려요.', 'English from now on.'), unsaved()].join('\n') }
1163 }
1164
1165 if (asked === 'to' || asked.startsWith('to ')) {
1166 const who = asked.slice(2).trim()
1167
1168 if (who === 'auto' || who === '자동') {
1169 await update($, target, () => null)
1170
1171 return { text: t('자동으로 돌렸어요. 원이가 알아서 일을 나눠 맡깁니다.', 'Back to auto: WONI hands the work out as she sees fit.') }
1172 }
1173 const named = NAMED[who]
1174
1175 if (named === undefined) return { text: t('받을 멤버를 적어 주세요: /rescene to 자동 · 원이(직접 처리) · 리브 · 미나미 · 메이 · 제나', 'Name who takes it: /rescene to auto · WONI (does it herself) · LIV · MINAMI · MAY · ZENA') }
1176 await update($, target, () => named)
1177
1178 return {
1179 text:
1180 named === 'woni'
1181 ? t(`다음 명령부터 ${MEMBERS.woni.heart} 원이가 맡기지 않고 직접 처리합니다. 풀려면 /rescene to 자동`, `From the next prompt ${MEMBERS.woni.heart} WONI does the work herself instead of handing it on. To undo: /rescene to auto`)
1182 : t(`다음 명령부터 ${MEMBERS[named].heart} ${nameOf(named)}에게 맡깁니다. 풀려면 /rescene to 자동`, `From the next prompt the work goes to ${MEMBERS[named].heart} ${nameOf(named)}. To undo: /rescene to auto`),
1183 }
1184 }
1185 if (asked === 'role' || asked === '역할' || asked.startsWith('role ') || asked.startsWith('역할 ')) {
1186 const [first = '', second = ''] = asked.split(/\s+/).slice(1)
1187 const how = t('바꾸려면 /rescene role 리브 구현 (멤버: 리브·미나미·메이·제나 / 역할: 구현·검토·조사·탐색), 자동으로 돌리려면 /rescene role auto', 'To change: /rescene role LIV build (members: LIV·MINAMI·MAY·ZENA / roles: build·review·research·scout). Back to auto: /rescene role auto')
1188
1189 if (first === '') {
1190 await setup($, true)
1191
1192 return { text: [t('RESCENE 역할 설정', 'RESCENE roles'), ...castLines(await read($, cast)), how].join('\n') }
1193 }
1194 if (first === 'auto' || first === '자동' || first === 'reset' || first === '기본') {
1195 const given = await giveRole($, null, '구현')
1196
1197 return { text: [t('역할을 자동(기본)으로 돌렸어요.', 'The roles are back to auto (default).'), ...castLines(given.cast), ...(given.isKept ? [] : [unkept()])].join('\n') }
1198 }
1199 const member = NAMED[first] ?? NAMED[second]
1200 const role = ROLE_NAMED[second] ?? ROLE_NAMED[first]hooks/action.ts 112 lines1import type { Cast, MemberId, Role } from '../types'
2
3import { t } from './lang'
4import { CAST } from './members'
5
6// Says a tool call in plain words (what is being done, and to what), and
7// which member's kind of work it is when the main session does it itself.
8
9export type Action = {
10 member: MemberId
11 /** While the call runs: `고치는 중: view.tsx`. */
12 phrase: string
13 /** Once it is over: `고침: view.tsx`. */
14 past: string
15}
16
17const text = (input: Record<string, unknown>, key: string): string => {
18 const value = input[key]
19
20 return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : ''
21}
22
23const baseName = (path: string): string => path.slice(path.lastIndexOf('/') + 1)
24
25const host = (url: string): string => /^[a-z]+:\/\/([^/]+)/i.exec(url)?.[1] ?? url
26
27const LOOKS = /^(?:ls|cat|head|tail|grep|rg|find|wc|stat|file|which|env|pwd|tree|du|jq)$/
28const CHECKS = /^(?:tsc|eslint|pytest|jest|vitest|diff|shellcheck)$/
29const RUNNERS = /^(?:npm|pnpm|yarn|bun|npx|cargo|go|make|claude|python3?|deno|gradle|mvn)$/
30
31/** The first step the shell runs, as words: past the assignments and the `cd` a command opens with. */
32const headOf = (command: string): string[] => {
33 const steps = command.split(/&&|\|\||;|\n/).map(step => step.trim().replace(/^(?:[A-Za-z_]\w*=\S*\s+)+/, ''))
34 return (steps.find(step => step !== '' && !/^(?:cd|export|set|source)\b/.test(step) && !/^[A-Za-z_]\w*=\S*$/.test(step)) ?? '').split(/\s+/)
35}
36
37/** A command as a few words to show: what it runs, not where it went first, with long paths down to their names. */
38const shown = (command: string): string => {
39 const words = headOf(command).map(word => (word.length > 24 && word.includes('/') ? `…/${baseName(word.replace(/\/+$/, ''))}` : word))
40
41 return words.join(' ') || command
42}
43
44/** What kind of work a shell command is: its own first word says, and where that does not, its description. */
45const roleOfShell = (command: string, description: string): Role => {
46 const [first = '', ...rest] = headOf(command)
47 const tool = baseName(first)
48
49 if (tool === 'fleet-run' || tool === 'fleet-race' || (tool === 'orca' && /^(?:create|send|worker-start|task-create|dispatch)$/.test(rest[1] ?? ''))) return '지휘'
50 if (CHECKS.test(tool) || (RUNNERS.test(tool) && rest.some(word => /^(?:test|lint|check|typecheck|validate|verify|pytest)$/.test(word)))) return '검토'
51 if (/^(?:curl|wget)$/.test(tool) || (tool === 'gh' && rest[0] === 'api')) return '조사'
52 if (tool === 'orca' || LOOKS.test(tool) || (tool === 'sed' && rest[0] === '-n') || (tool === 'git' && /^(?:status|log|show|diff|blame)$/.test(rest[0] ?? ''))) return '탐색'
53 if (/고치|수정|추가|반영|바꾸|만들|구현|작성|\b(?:write|edit|fix|add|create|update|install|build)\b/i.test(description)) return '구현'
54 if (/테스트|검사|검증|\b(?:test|check|verify|validate|lint|review)\b/i.test(description)) return '검토'
55 if (/조사|내려받|\b(?:fetch|download|research)\b/i.test(description)) return '조사'
56 if (/읽|찾|목록|보기|확인|\b(?:list|read|find|search|show|locate|inspect)\b/i.test(description)) return '탐색'
57
58 return '구현'
59}
60
61const cut = (phrase: string): string => ([...phrase].length > 64 ? `${[...phrase].slice(0, 63).join('')}…` : phrase)
62
63/** A tool call as the member whose kind of work it is and a phrase: `고치는 중: view.tsx`, `실행 중: 타입 검사`. */
64export const actionOf = (tool: string, input: object, cast: Cast = CAST): Action => {
65 const args = input as Record<string, unknown>
66 const file = baseName(text(args, 'file_path') || text(args, 'notebook_path') || text(args, 'path'))
67 const say = (role: Role, phrase: string, past: string): Action => ({ member: role === '지휘' ? 'woni' : cast[role], phrase: cut(phrase), past: cut(past) })
68
69 switch (tool) {
70 case 'Read':
71 return say('탐색', t(`읽는 중: ${file}`, `Reading: ${file}`), t(`읽음: ${file}`, `Read: ${file}`))
72 case 'Grep':
73 case 'Glob':
74 return say('탐색', t(`찾는 중: ${text(args, 'pattern')}`, `Searching: ${text(args, 'pattern')}`), t(`찾음: ${text(args, 'pattern')}`, `Searched: ${text(args, 'pattern')}`))
75 case 'ToolSearch':
76 return say('탐색', t(`도구 찾는 중: ${text(args, 'query')}`, `Finding a tool: ${text(args, 'query')}`), t(`도구 찾음: ${text(args, 'query')}`, `Found a tool: ${text(args, 'query')}`))
77 case 'Edit':
78 case 'MultiEdit':
79 case 'NotebookEdit':
80 return say('구현', t(`고치는 중: ${file}`, `Editing: ${file}`), t(`고침: ${file}`, `Edited: ${file}`))
81 case 'Write':
82 return say('구현', t(`쓰는 중: ${file}`, `Writing: ${file}`), t(`씀: ${file}`, `Wrote: ${file}`))
83 case 'WebSearch':
84 return say('조사', t(`검색 중: ${text(args, 'query')}`, `Searching the web: ${text(args, 'query')}`), t(`검색함: ${text(args, 'query')}`, `Searched the web: ${text(args, 'query')}`))
85 case 'WebFetch':
86 return say('조사', t(`웹 읽는 중: ${host(text(args, 'url'))}`, `Reading the web: ${host(text(args, 'url'))}`), t(`웹 읽음: ${host(text(args, 'url'))}`, `Read the web: ${host(text(args, 'url'))}`))
87 case 'Skill':
88 return say('조사', t(`스킬 여는 중: ${text(args, 'skill')}`, `Opening a skill: ${text(args, 'skill')}`), t(`스킬 엶: ${text(args, 'skill')}`, `Opened a skill: ${text(args, 'skill')}`))
89 case 'Bash': {
90 const command = text(args, 'command')
91 const description = text(args, 'description')
92
93 return say(roleOfShell(command, description), t(`실행 중: ${description || shown(command)}`, `Running: ${description || shown(command)}`), t(`실행함: ${description || shown(command)}`, `Ran: ${description || shown(command)}`))
94 }
95 case 'Agent':
96 return say('지휘', t(`맡기는 중: ${text(args, 'description')}`, `Handing over: ${text(args, 'description')}`), t(`맡김: ${text(args, 'description')}`, `Handed over: ${text(args, 'description')}`))
97 case 'AskUserQuestion':
98 return say('지휘', t('사용자에게 묻는 중', 'Asking the person'), t('사용자에게 물음', 'Asked the person'))
99 case 'SendMessage':
100 return say('지휘', t('멤버에게 말 전하는 중', 'Messaging a member'), t('멤버에게 말 전함', 'Messaged a member'))
101 case 'SubagentHandback':
102 return say('지휘', t('보고 올리는 중', 'Reporting back'), t('보고 올림', 'Reported back'))
103 case 'Artifact':
104 return say('구현', t('페이지 만드는 중', 'Making a page'), t('페이지 만듦', 'Made a page'))
105 default: {
106 const short = tool.startsWith('mcp__') ? tool.slice(tool.lastIndexOf('__') + 2) : tool
107
108 return say(tool.startsWith('mcp__') ? '조사' : '지휘', t(`${short} 쓰는 중`, `Using ${short}`), t(`${short} 씀`, `Used ${short}`))
109 }
110 }
111}
112hooks/lang.ts 85 lines1// The language the mod speaks: Korean to a Korean reader, English to anyone else.
2
3export type Lang = 'ko' | 'en'
4
5// One session, one reader: every text the mod writes is in the language held here.
6let lang: Lang = 'ko'
7
8export const langNow = (): Lang => lang
9
10export const setLang = (next: Lang): void => {
11 lang = next
12}
13
14/** One of two texts, by the language in use. */
15export const t = <T>(ko: T, en: T): T => (lang === 'en' ? en : ko)
16
17// Korean as a setting may name it, anywhere in what it says: `ko`, `ko_KR`, `Korean`, `한국어`, `한국말`, `Answer in Korean`.
18const NAMES_KOREAN = /(?:^|[^a-z])(?:ko|kr|kor|korean)(?:[^a-z]|$)|한국|한글/i
19
20/** A language asked for by name; nothing for `auto`, or for a word that names none. */
21export const pinned = (asked: unknown): Lang | undefined => {
22 const word = typeof asked === 'string' ? asked.trim().toLowerCase() : ''
23
24 if (/^(?:ko|kr|kor|korean|한국어|한국말|한글)$/.test(word)) return 'ko'
25 if (/^(?:en|eng|english|영어)$/.test(word)) return 'en'
26
27 return undefined
28}
29
30export const hasHangul = (text: string): boolean => /[가-힣]/.test(text)
31
32/** What tells whether the reader is Korean, strongest first. */
33export type Signs = {
34 /** The language Claude Code is set to answer in, as its settings show it: `Default (English)` where none is set. */
35 setting?: string
36 /** `LC_ALL`, `LC_MESSAGES`, `LANG`, as the session has them. */
37 env?: readonly (string | undefined)[]
38 /** The locale and the time zone the runtime reports. */
39 locale?: string
40 timeZone?: string
41}
42
43/**
44 * Korean for a reader the signs say is Korean, English for anyone else. A
45 * terminal's `LANG` is often left English on a Korean machine, so one that
46 * is not Korean settles nothing: the locale and the clock are asked next.
47 */
48export const langOf = (signs: Signs): Lang => {
49 const setting = signs.setting?.trim() ?? ''
50
51 // Left at its default, the setting names no language the person chose.
52 if (setting !== '' && !/^default\b/i.test(setting)) return NAMES_KOREAN.test(setting) ? 'ko' : 'en'
53 if ((signs.env ?? []).some(value => /^ko(?:[_.@-]|$)/i.test(value ?? ''))) return 'ko'
54 if (/^ko(?:-|$)/i.test(signs.locale ?? '')) return 'ko'
55
56 return signs.timeZone === 'Asia/Seoul' ? 'ko' : 'en'
57}
58
59/** The runtime's own locale and time zone, where it has them. */
60export const runtimeSigns = (): Pick<Signs, 'locale' | 'timeZone'> => {
61 try {
62 const { locale, timeZone } = Intl.DateTimeFormat().resolvedOptions()
63
64 return { locale, timeZone }
65 } catch {
66 return {}
67 }
68}
69
70// Names a reader of any language may write in Hangul: they say nothing of what the reader reads.
71const NAMES_KO = /리센느|리마인|원이|리브|미나미|메이|제나/g
72
73/**
74 * Whether a text is written in Korean: beyond the group's and the members'
75 * names, more syllables of Hangul than words in Latin letters. A Korean
76 * sentence about code is (`view.tsx 의 fit 함수 고쳐 줘`); a Korean word asked
77 * about in English is not (`what does 감사합니다 mean in this log?`).
78 */
79export const isKorean = (text: string): boolean => {
80 const rest = text.replace(NAMES_KO, '')
81 const syllables = rest.match(/[가-힣]/g)?.length ?? 0
82
83 return syllables >= 2 && syllables > (rest.match(/[A-Za-z]+/g)?.length ?? 0)
84}
85hooks/members.ts 496 lines1import type { Color } from 'claude-code'
2
3import type { Cast, MemberId, Role, Task } from '../types'
4
5import { t } from './lang'
6
7// Facts from the fan wiki (namu.wiki, read 2026-10-06) and the official
8// remini goods pages: colors, hearts, animals. Every quote below is a line
9// the member is recorded as having said; none is made up here.
10
11export type Member = {
12 id: MemberId
13 name: string
14 /** Her name as the group writes it in Latin letters. */
15 en: string
16 /** Her heart in plain text: the emoji, or for 리브 the suit, which shows on a dark terminal. */
17 heart: string
18 remini: string
19 title: string
20 /** Her symbol color, as the fan wiki's profile gives it: the name badge. */
21 color: string
22 ink: string
23 /** The same hue as a line on a dark terminal: borders, the heart, her words. 리브's black is the terminal's own ink. */
24 line: Color
25 /** That line on a light terminal: the hue deepened until it reads on white (4.5 to 1 or better). */
26 lineOnLight: Color
27}
28
29/** The group's pink: a badge's ground on any terminal, and a line on a dark one. */
30export const PINK = '#fc6fcf'
31const PINK_ON_LIGHT = '#c2188f'
32
33/** A member's line color for the terminal's theme. */
34export const lineOf = (id: MemberId, isLight = false): Color => (isLight ? MEMBERS[id].lineOnLight : MEMBERS[id].line)
35
36/** The group's pink as a line for the terminal's theme. */
37export const pinkOf = (isLight = false): Color => (isLight ? PINK_ON_LIGHT : PINK)
38
39export const MEMBERS: Record<MemberId, Member> = {
40 woni: {
41 id: 'woni',
42 name: '원이',
43 en: 'WONI',
44 heart: '💚',
45 remini: '조타',
46 title: '대장',
47 color: '#045a42',
48 ink: '#ffffff',
49 line: '#1f9d78',
50 lineOnLight: '#045a42',
51 },
52 liv: {
53 id: 'liv',
54 name: '리브',
55 en: 'LIV',
56 heart: '♥',
57 remini: '리뿌',
58 title: '최종병기',
59 color: '#000000',
60 ink: '#ffffff',
61 line: 'text',
62 lineOnLight: 'text',
63 },
64 minami: {
65 id: 'minami',
66 name: '미나미',
67 en: 'MINAMI',
68 heart: '💙',
69 remini: '밍',
70 title: '올라운더',
71 color: '#2b99c4',
72 ink: '#000000',
73 line: '#2b99c4',
74 lineOnLight: '#0b6a8f',
75 },
76 may: {
77 id: 'may',
78 name: '메이',
79 en: 'MAY',
80 heart: '💛',
81 remini: '얌',
82 title: '메기자',
83 color: '#ecd25b',
84 ink: '#000000',
85 line: '#ecd25b',
86 lineOnLight: '#8a6d00',
87 },
88 zena: {
89 id: 'zena',
90 name: '제나',
91 en: 'ZENA',
92 heart: '💜',
93 remini: '쩨로밍',
94 title: '신라공주',
95 color: '#ba92db',
96 ink: '#000000',
97 line: '#ba92db',
98 lineOnLight: '#7a4bb0',
99 },
100}
101
102/** A member's name in the language in use. */
103export const nameOf = (id: MemberId): string => t(MEMBERS[id].name, MEMBERS[id].en)
104
105export const ORDER: readonly MemberId[] = ['woni', 'liv', 'minami', 'may', 'zena']
106export const WORKERS: readonly MemberId[] = ['liv', 'minami', 'may', 'zena']
107
108export type Situation = 'idle' | 'start' | 'done' | 'fail' | 'slow' | 'denied'
109
110// Every line here is on record as hers (the fan wiki's 어록 and 여담, read
111// 2026-10-06), spelled as recorded. '미음' and its kin are the members' own
112// slang for 망했다, so any of them says it when something fails.
113const QUOTES: Record<Exclude<MemberId, 'woni'>, Record<Situation, readonly string[]>> = {
114 liv: {
115 idle: ['안녕하세...요↗?', '음 ㅇ오이시..', '리브 바보 아니다.'],
116 start: ['너도? 아 나도!', '정수리 옮길 거예요.', '안녕하세...요↗?'],
117 done: ['허우 유레카!', '감사 한 입, 감사 두 입.', '저의 개인기 필살기... 왕따봉!', '뚝배기 삼행시 시작! 뚝!'],
118 fail: ['리브 바보 아니다.', '도대체 어떻게 하면 바보가 아닐 수 있지?', '망했또띠', '아니, 저 바보 아니에요! 바보 아니야.'],
119 slow: ['야 곧 기다려. 나 후반부야.', '난 후반전에 가면 무조건 다 이겨'],
120 denied: ['니가 뭔데?', '너 진짜 혼나볼래?'],
121 },
122 minami: {
123 idle: ['난 파라파라나 추고 있어야겠다.', '오이데 오이데~ 마떼루요~'],
124 start: ['쿄 아손데콩!', '밤양갱! 공룡!'],
125 done: ['거제 야호-!', '쵸베리구', '텐샤이샹데슈~!'],
126 fail: ['쵸베리바', '미으무야~', '마지다루이.'],
127 slow: ['마지다루이.', '1분만...', '오이데 오이데~ 마떼루요~'],
128 denied: ['쵸베리바', '기억나? 아 PD님 기억나?'],
129 },
130 may: {
131 idle: ['반겨주는 역할이거든요... 강아지마냥.', '나는 우주먼지야'],
132 start: ['그럼 출발!', '집쭝!'],
133 done: ['기회는 그립감이 좋다.', 'PD님 짱이다.', '기회를 잡는 것도 기회가 와야 잡을 수 있는 거야.'],
134 fail: ['불협도 화음이니까.', '미음', '전 총량의 법칙을 믿습니다.', '진짜 나빠 진짜! 악덕해! 너무 나빠!'],
135 slow: ['집쭝!', '아니 왜 시간이 없어요 왜'],
136 denied: ['과해.', '못된 어른의 표본이세요, 진짜.', '더 사악해! 더 악덕하고 더 나빠!'],
137 },
138 zena: {
139 idle: ['안녕하신교?', '십원빵 아이가?', '아니 오렌지 하자~'],
140 start: ['안녕하신교?', '나는, 신라... 신라의... 신라의 공주!', '뭐 클로즈업이요? 그게 뭔데요?'],
141 done: ['아, 그뤠여?', '으아하!', '내는 원래 {일}을 싸랑해.', '아, 진쫘여?'],
142 fail: ['아뉘이이이!', '저는 그럴 때 세상을 잃어요.', '씨러!', '망해또르띠아', '나쁜 일이 일어나면 그만큼 좋은 일도 일어난다.'],
143 slow: ['덜컹덜컹 달려간다 시골버스야~♬', '(도)도, (미)...미ㅎ'],
144 denied: ['무엄하다!', '제가 해명할 게 뭐가 있죠?'],
145 },
146}
147
148export const LEADER = {
149 idle: '밥은 줍니까?',
150 wave: '인사드리겠습니다. 둘, 셋!',
151 allDone: '우이!',
152 sweep: '우리 1등하고 싶었잖아.',
153 trouble: '죠또 코와이... 다이죠부?',
154 tasty: '오이쉬!',
155} as const
156
157/** 원이's own lines by situation, the first the one she is known for. */
158const LEADS: Record<Situation, readonly string[]> = {
159 idle: [LEADER.idle, '리센느 아세요?', '애기 자께예~♡', '누구게?'],
160 start: [LEADER.wave],
161 done: [LEADER.allDone, LEADER.tasty],
162 fail: [LEADER.trouble, '미음', '너 뚝딱이 아니야'],
163 slow: ['너 김가영이야?'],
164 denied: ['마! 니 뭐! 니 살림차리고 싶나?'],
165}
166
167/**
168 * Lines for a moment in the session rather than for a task: who says it, and
169 * the real line, each used where the moment is like the one it was first said in.
170 */
171export const MOMENTS = {
172 // The PD asked whether there was any food for the crew: the plan's limit is all but eaten.
173 starved: { member: 'woni', quote: '그냥 굶어라.' },
174 // 메이 on why things even out: most of the limit is used.
175 quota: { member: 'may', quote: '전 총량의 법칙을 믿습니다.' },
176 // Said of too much of anything: the context is nearly full.
177 heavy: { member: 'may', quote: '이게 과유불급이에요.' },
178 // 원이 asking a passer-by who she is: the context was just compacted.
179 compacted: { member: 'woni', quote: '누구게?' },
180 // 메이 quieting a chat gone wild: four or more at work at once.
181 crowd: { member: 'may', quote: '여러분 여러분! 너무 시끄러워요!' },
182 // Glad to see someone yet again: the same file read over and over.
183 again: { member: 'woni', quote: '와 이래 많이 봅니까, 우리? 그만 좀 봅시다.' },
184 // The line every episode of 원이's channel opens with: the session's first prompt.
185 hello: { member: 'woni', quote: '안녕하세요, 원이입니다. 잘 부탁드립니다.' },
186 // What the members ask whoever is being slow, 가영 being 제나: a task of 제나's runs long.
187 dawdle: { member: 'woni', quote: '너 김가영이야?' },
188} as const satisfies Record<string, { member: MemberId; quote: string }>
189
190export type Moment = keyof typeof MOMENTS
191
192const hasBatchim = (word: string): boolean => {
193 const code = word.charCodeAt(word.length - 1) - 0xac00
194
195 return code >= 0 && code <= 11171 && code % 28 !== 0
196}
197
198/** One of the member's own lines for the situation; `turn` rotates them. */
199export const say = (member: MemberId, situation: Situation, turn: number, role: Role): string => {
200 const lines = member === 'woni' ? LEADS[situation] : QUOTES[member][situation]
201 const line = lines[Math.abs(Math.trunc(turn)) % lines.length] ?? lines[0] ?? ''
202
203 // 제나's line is a template she fills in herself ("내는 원래 ○○을 싸랑해.").
204 return line.replace('{일}을', `${role}${hasBatchim(role) ? '을' : '를'}`)
205}
206
207/** Every line on the mod's screen, for checking each against the record. */
208export const SPOKEN: readonly string[] = [...Object.values(QUOTES).flatMap(by => Object.values(by).flat()), ...Object.values(LEADS).flat()]
209
210/** Every line a member's voice may use, for the instruction blocks. */
211const LINES_KO: Record<MemberId, readonly string[]> = {
212 woni: [
213 '"우이!" (추임새, 인사, 뭔가 해냈을 때)',
214 '"오이쉬!" / "오이쉬에~" (결과가 마음에 들 때, 맛있을 때)',
215 '"안녕하세요, 원이입니다. 잘 부탁드립니다." (첫인사)',
216 '"밥은 줍니까?" (일을 받기 전, 작업 반장님 같은 말투)',
217 '"그냥 굶어라." (한도나 자원이 바닥났을 때, 눈치 주듯이)',
218 '"와 이래 많이 봅니까, 우리? 그만 좀 봅시다." (같은 걸 자주 볼 때, 반갑다는 뜻)',
219 '"우리 1등하고 싶었잖아." (다 같이 잘 끝냈을 때)',
220 '"죠또 코와이... 다이죠부?" (문제가 생겼을 때)',
221 '"미음" (멤버들끼리 쓰는 은어로 \'망했다\'는 뜻, 일이 틀어졌을 때)',
222 '"누구게?" (잠깐 잊었거나 다시 만났을 때 장난으로)',
223 '"너 김가영이야?" (누가 느릴 때. 가영은 제나의 본명)',
224 '"사실 그냥 지나갈 수 있는 하루를, 저에게 써 주셔서 감사합니다." (긴 작업을 마무리할 때)',
225 ],
226 liv: [
227 '"너도? 아 나도!" (일을 받을 때, 맞장구. 명대사 월드컵 우승 대사)',
228 '"허우 유레카!" (문제를 찾아냈을 때)',
229 '"저의 개인기 필살기... 왕따봉!" (결정적인 걸 내놓을 때)',
230 '"야 곧 기다려. 나 후반부야." / "난 후반전에 가면 무조건 다 이겨" (오래 걸릴 때)',
231 '"리브 바보 아니다." / "아니, 저 바보 아니에요! 바보 아니야." (실수를 인정할 때, 의외로 똑똑했을 때)',
232 '"도대체 어떻게 하면 바보가 아닐 수 있지?" (실패해서 진지하게 고민할 때)',
233 '"감사 한 입, 감사 두 입." (잘 끝났을 때)',
234 '"너 진짜 혼나볼래?" (잘못된 코드를 발견했을 때)',
235 '"음 ㅇ오이시.." (무리수를 던지고 뻘쭘할 때)',
236 '"망했또띠" (멤버 은어 \'미음\'의 변형, 일이 틀어졌을 때)',
237 ],
238 minami: [
239 '"쿄 아손데콩!" (시작할 때, 오늘 놀아 보자)',
240 '"거제 야호-!" (끝냈을 때)',
241 '"쵸베리구" (완전 좋다) / "쵸베리바" (최악이다)',
242 '"마지다루이." (귀찮거나 오래 걸릴 때)',
243 '"텐샤이샹데슈~!" (잘 해냈을 때, 천재예요)',
244 '"1분만..." (시간이 조금 더 필요할 때)',
245 '"밤양갱! 공룡!" (어려운 걸 해냈다고 뽐낼 때)',
246 '"미으무야~" (멤버 은어 \'미음\'의 변형, 일이 틀어졌을 때)',
247 '사용자를 부를 일이 있으면 "피디니무"라고 부른다.',
248 ],
249 may: [
250 '"기회는 그립감이 좋다." (일을 받거나 끝냈을 때)',
251 '"그럼 출발!" (시작할 때)',
252 '"집쭝!" (집중할 때, 산만해졌을 때)',
253 '"과해." / "이게 과유불급이에요." (너무 많거나 지나칠 때)',
254 '"역병처럼 돌아요." (같은 문제가 여러 곳에 퍼져 있을 때)',
255 '"불협도 화음이니까." (어긋난 결과를 받아들일 때)',
256 '"전 총량의 법칙을 믿습니다." (잘 안 풀렸을 때)',
257 '"아니 왜 시간이 없어요 왜" (시간에 쫓길 때)',
258 '"PD님 짱이다." (도움을 받았을 때)',
259 '"미음" (멤버들끼리 쓰는 은어로 \'망했다\'는 뜻)',
260 ],
261 zena: [
262 '"아뉘이이이!" (억울하거나 실패했을 때)',
263 '"그게 뭔데요?" (모르는 게 나왔을 때)',
264 '"아, 그뤠여?" / "아, 진쫘여?" (뒤늦게 납득했을 때)',
265 '"내는 원래 ○○을 싸랑해." (○○에 좋아하는 것을 넣어 쓴다)',
266 '"무엄하다!" (거부당했을 때)',
267 '"제가 해명할 게 뭐가 있죠?" (억울하게 지적받았을 때)',
268 '"나쁜 일이 일어나면 그만큼 좋은 일도 일어난다." (실패한 뒤, 좌우명)',
269 '"십원빵 아이가?" (반가운 걸 발견했을 때)',
270 '"안녕하신교?" (인사)',
271 '"망해또르띠아" (멤버 은어 \'미음\'의 변형, 일이 틀어졌을 때)',
272 ],
273}
274
275const MANNER_KO: Record<MemberId, string> = {
276 woni: '시원시원한 존댓말. 경상도 억양의 어미("…합니까?", "…합시다")를 가끔 섞는다. 리더답게 정리해서 말한다.',
277 liv: '장난기 많은 존댓말. 개그 욕심이 있어 한마디씩 얹지만, 남의 실수는 정확하게 짚는다.',
278 minami: '예의 바르고 차분한 존댓말. 신날 때만 갸루 감탄사가 튀어나온다.',
279 may: '조곤조곤한 존댓말. 말이 길어지기 쉬운 걸 스스로 알아서 "집쭝!" 하고 간결하게 줄인다.',
280 zena: '낯가리는 막내의 존댓말에 경상도 사투리("내는…", "…아이가?")가 섞인다. 억울할 때 리액션이 크다.',
281}
282
283// The same lines for an English reader: each quoted in Korean as she said it, with when it fits.
284const LINES_EN: Record<MemberId, readonly string[]> = {
285 woni: [
286 '"우이!" (her cheer: a greeting, or when something is done)',
287 '"오이쉬!" / "오이쉬에~" (when a result pleases her; "tasty!")',
288 '"안녕하세요, 원이입니다. 잘 부탁드립니다." (her first greeting: "Hello, I\'m WONI. Please take care of me.")',
289 '"밥은 줍니까?" (before taking on work, like a foreman: "Do we get fed?")',
290 '"그냥 굶어라." (when a limit or a resource has run out: "Then just starve.")',
291 '"와 이래 많이 봅니까, 우리? 그만 좀 봅시다." (on seeing the same thing yet again, fondly: "Why do we meet this often? Let\'s stop.")',
292 '"우리 1등하고 싶었잖아." (when everyone finished well: "We wanted first place, didn\'t we.")',
293 '"죠또 코와이... 다이죠부?" (when there is trouble: "A bit scary... all right?")',
294 '"미음" (the members\' own slang for "we\'re done for", when something goes wrong)',
295 '"누구게?" (playfully, after forgetting or meeting again: "Guess who?")',
296 '"너 김가영이야?" (when someone is slow; 가영 is ZENA\'s given name)',
297 '"사실 그냥 지나갈 수 있는 하루를, 저에게 써 주셔서 감사합니다." (closing a long piece of work: "Thank you for spending on me a day that could have just passed by.")',
298 ],
299 liv: [
300 '"너도? 아 나도!" (taking a task, agreeing: "You too? Oh, me too!")',
301 '"허우 유레카!" (on finding the problem: "Eureka!")',
302 '"저의 개인기 필살기... 왕따봉!" (on producing the decisive thing: "My special move... a big thumbs-up!")',
303 '"야 곧 기다려. 나 후반부야." / "난 후반전에 가면 무조건 다 이겨" (when it takes long: "Wait, I\'m a second-half player.")',
304 '"리브 바보 아니다." / "아니, 저 바보 아니에요! 바보 아니야." (owning a slip, or having been right after all: "LIV is no fool.")',
305 '"도대체 어떻게 하면 바보가 아닐 수 있지?" (thinking hard after a failure)',
306 '"감사 한 입, 감사 두 입." (when it ended well: "One bite of thanks, two bites of thanks.")',
307 '"너 진짜 혼나볼래?" (on finding wrong code: "Do you want a scolding?")',
308 '"음 ㅇ오이시.." (after a joke that did not land)',
309 '"망했또띠" (a twist on the members\' slang "미음", when something goes wrong)',
310 ],
311 minami: [
312 '"쿄 아손데콩!" (starting: "Let\'s play today!")',
313 '"거제 야호-!" (finishing: "Geoje, yahoo!")',
314 '"쵸베리구" (very good) / "쵸베리바" (very bad)',
315 '"마지다루이." (when it is a bother or takes long)',
316 '"텐샤이샹데슈~!" (when it went well: "I\'m a genius!")',
317 '"1분만..." (needing a little more time: "Just one minute...")',
318 '"밤양갱! 공룡!" (showing off something hard that she pulled off)',
319 '"미으무야~" (a twist on the members\' slang "미음", when something goes wrong)',
320 'When she has to address the person, she says "피디니무" (PD-nim, the producer).',
321 ],
322 may: [
323 '"기회는 그립감이 좋다." (taking or finishing a task: "A chance has a good grip.")',
324 '"그럼 출발!" (starting: "Then off we go!")',
325 '"집쭝!" (to focus, or when things got scattered: "Focus!")',
326 '"과해." / "이게 과유불급이에요." (when there is too much: "Too much is as bad as too little.")',
327 '"역병처럼 돌아요." (when one problem has spread to many places: "It goes round like a plague.")',
328 '"불협도 화음이니까." (accepting a result that is off: "Dissonance is harmony too.")',
329 '"전 총량의 법칙을 믿습니다." (when it did not go well: "I believe things even out.")',
330 '"아니 왜 시간이 없어요 왜" (pressed for time: "Why is there no time, why")',
331 '"PD님 짱이다." (when helped: "The PD is the best.")',
332 '"미음" (the members\' own slang for "we\'re done for")',
333 ],
334 zena: [
335 '"아뉘이이이!" (wronged, or on failing: "Nooooo!")',
336 '"그게 뭔데요?" (meeting something she does not know: "What is that?")',
337 '"아, 그뤠여?" / "아, 진쫘여?" (coming round late: "Oh, is that so?")',
338 '"내는 원래 ○○을 싸랑해." (she fills ○○ with what she likes: "I have always loved ○○.")',
339 '"무엄하다!" (when refused: "How insolent!")',
340 '"제가 해명할 게 뭐가 있죠?" (told off unfairly: "What is there for me to explain?")',
341 '"나쁜 일이 일어나면 그만큼 좋은 일도 일어난다." (after failing, her motto: "For every bad thing a good one comes.")',
342 '"십원빵 아이가?" (on finding something welcome)',
343 '"안녕하신교?" (a greeting, in her dialect)',
344 '"망해또르띠아" (a twist on the members\' slang "미음", when something goes wrong)',
345 ],
346}
347
348const MANNER_EN: Record<MemberId, string> = {
349 woni: 'Brisk and polite, a leader who sums things up and decides. (In Korean she has a Gyeongsang lilt; in another language, plain and decisive.)',
350 liv: 'Playful but polite. She likes to slip in a joke, and she names another\'s mistake exactly.',
351 minami: 'Courteous and calm. Her gyaru exclamations come out only when she is excited.',
352 may: 'Soft-spoken and polite. She knows she runs long, so she tells herself "집쭝!" and keeps it short.',
353 zena: 'The shy youngest, polite, with a Gyeongsang dialect in Korean. Her reactions are big when she feels wronged.',
354}
355
356/** Every line a member's voice may use, for the instruction blocks, in the language in use. */
357export const linesFor = (id: MemberId): readonly string[] => t(LINES_KO, LINES_EN)[id]
358
359/** The Korean lines, for checking each against the record. */
360export const LINES = LINES_KO
361
362/** How a member speaks, in the language in use. */
363export const mannerOf = (id: MemberId): string => t(MANNER_KO, MANNER_EN)[id]
364
365// Unit names from the fan wiki's chemistry table: the first the one a
366// pair is known by, the rest what it also goes by. 원이 conducts every
367// stage, so a member working alone is on it as a pair with her.
368const UNITS: Record<string, readonly string[]> = {
369 liv: ['우아즈'],
370 minami: ['원나미'],
371 may: ['쪼물딱즈', '룸메즈'],
372 zena: ['맏막즈', '경상즈'],
373 'liv+minami': ['06즈', '밍뿌즈'],
374 'liv+may': ['리트와 메트', '메리즈'],
375 'liv+zena': ['젤리즈', '까리즈'],
376 'minami+may': ['메미즈', '어사즈'],
377 'minami+zena': ['제나미'],
378 'may+zena': ['막내즈', '08즈', '제첩과 메스타드'],
379 'liv+minami+zena': ['까나리즈', '야식즈'],
380 'liv+minami+may+zena': ['완전체'],
381}
382
383export const isActive = (task: Task): boolean => task.status === 'running' || task.status === 'waiting'
384
385/** The name the members at work go by together; `turn` picks among a unit's names. */
386export const unitOf = (tasks: readonly Task[], turn = 0): string | undefined => {
387 const busy = WORKERS.filter(id => tasks.some(task => task.member === id && isActive(task)))
388 const names = UNITS[busy.join('+')]
389
390 // The name a unit is known by comes up most; what it also goes by, now and then.
391 return names?.[Math.abs(Math.trunc(turn)) % (names.length + 1)] ?? names?.[0]
392}
393
394export type WorkRole = Exclude<Role, '지휘'>
395
396export const ROLES: readonly WorkRole[] = ['구현', '검토', '조사', '탐색']
397
398/** Who has which kind of work until the person says otherwise: each member's own position. */
399export const CAST: Cast = { 구현: 'minami', 검토: 'liv', 조사: 'may', 탐색: 'zena' }
400
401const DUTY_KO: Record<WorkRole, string> = { 구현: '구현, 수정', 검토: '검토, 검증, 테스트', 조사: '조사, 문서, 정리', 탐색: '코드 탐색, 위치 찾기, 심부름' }
402const DUTY_EN: Record<WorkRole, string> = { 구현: 'building, fixing', 검토: 'review, verification, tests', 조사: 'research, documents, write-ups', 탐색: 'exploring code, locating things, errands' }
403
404/** What each kind of work takes in, in a few words. */
405export const dutyOf = (role: WorkRole): string => t(DUTY_KO, DUTY_EN)[role]
406
407const ROLE_EN: Record<Role, string> = { 지휘: 'lead', 구현: 'build', 검토: 'review', 조사: 'research', 탐색: 'scout' }
408
409/** A kind of work by its name in the language in use; in the code and the state it goes by its Korean name. */
410export const roleName = (role: Role): string => t(role, ROLE_EN[role])
411
412/** A member's kind of work: 원이 conducts, and each of the four has the one the cast gives her. */
413export const roleIn = (cast: Cast, id: MemberId): Role => (id === 'woni' ? '지휘' : (ROLES.find(role => cast[role] === id) ?? '구현'))
414
415/** The cast with that kind of work given to a member: who had it takes hers in exchange, so each still has one. */
416export const recast = (cast: Cast, id: MemberId, role: WorkRole): Cast => {
417 const had = ROLES.find(one => cast[one] === id)
418
419 if (id === 'woni' || had === undefined || had === role) return cast
420
421 return { ...cast, [role]: id, [had]: cast[role] }
422}
423
424/** A cast as it was stored, where it still is one: each of the four with a kind of work of her own. */
425export const castOf = (stored: unknown): Cast => {
426 if (typeof stored !== 'object' || stored === null) return CAST
427 const kept = stored as Record<string, unknown>
428 const ids = ROLES.map(role => kept[role])
429
430 return WORKERS.every(id => ids.filter(one => one === id).length === 1) ? (Object.fromEntries(ROLES.map((role, index) => [role, ids[index]])) as Cast) : CAST
431}
432
433export const isSameCast = (a: Cast, b: Cast): boolean => ROLES.every(role => a[role] === b[role])
434
435const PREFERENCE: Record<WorkRole, readonly MemberId[]> = {
436 구현: ['minami', 'liv', 'may', 'zena'],
437 검토: ['liv', 'may', 'minami', 'zena'],
438 조사: ['may', 'minami', 'liv', 'zena'],
439 탐색: ['zena', 'may', 'liv', 'minami'],
440}
441
442/** The role's own member, or while she is busy the next one with free hands. */
443export const pickMember = (role: Role, tasks: readonly Task[], cast: Cast = CAST): MemberId => {
444 const kind = role === '지휘' ? '구현' : role
445 // Whoever the cast gives the work to comes first; after her, the others in the order they stand in for it.
446 const order = [cast[kind], ...PREFERENCE[kind].filter(id => id !== cast[kind])]
447 const free = order.find(id => !tasks.some(task => task.member === id && isActive(task)))
448
449 return free ?? order[0] ?? 'minami'
450}
451
452/** The role an agent type or a description tells, when one does. */
453export const roleOfAgent = (subagentType: string, description: string): Role | undefined => {
454 const type = subagentType.toLowerCase()
455
456 if (/explore|scout|search|guide/.test(type)) return '탐색'
457 if (/review|security|quality|audit|test|root-cause|analy/.test(type)) return '검토'
458 if (/research|writer|document|requirement|learning|mentor|plan/.test(type)) return '조사'
459 if (/architect|engineer|expert|refactor/.test(type)) return '구현'
460 if (/검토|리뷰|검증|다시 보|review|verify|audit/i.test(description)) return '검토'
461 if (/탐색|찾|위치|explore|locate|find/i.test(description)) return '탐색'
462 if (/조사|정리|문서|설명서|보고서|가이드|readme|research|document|summar/i.test(description)) return '조사'
463 if (/구현|수정|고치|작성|만들|implement|fix|build|write/i.test(description)) return '구현'
464
465 return undefined
466}
467
468const PROFILES: Record<string, Role> = {
469 'codex-build': '구현',
470 'codex-hard': '구현',
471 'codex-scout': '탐색',
472 'grok-research': '조사',
473 'grok-build': '구현',
474 'agy-fast': '구현',
475 'agy-pro': '구현',
476 'agy-review': '검토',
477 'claude-build': '구현',
478 'claude-review': '검토',
479}
480
481/**
482 * The kind of work an Orca run is. A profile made for one kind is that kind;
483 * a general one (the build profiles) is what its spec's name says it is, so
484 * `codex-hard` on `codex-audit.spec.md` is a review.
485 */
486export const roleOfRun = (profile: string, title: string): Role => {
487 const made = PROFILES[profile] ?? '구현'
488
489 if (made !== '구현') return made
490 if (/검토|점검|리뷰|검증|review|audit|verify|check/i.test(title)) return '검토'
491 if (/조사|research|survey/i.test(title)) return '조사'
492 if (/탐색|scout|explore|locate/i.test(title)) return '탐색'
493
494 return made
495}
496hooks/sprites.ts 213 lines1import type { MemberId } from '../types'
2
3// Small pixel icons of each member's remini animal, drawn for this mod (not
4// the official character art): ten pixels by eight, two pixels to a cell.
5// They move while their member works: `$.ui.blit` repaints the cells in place.
6
7// A sprite is a handful of poses and the order she moves through them while
8// she works: `A` is her standing still, the others a bob, a hop, a blink.
9type Sprite = {
10 poses: Readonly<Record<string, readonly string[]>>
11 colors: Readonly<Record<string, number>>
12 /** The colors that differ on a light terminal: what is white or pale there would not show. */
13 light?: Readonly<Record<string, number>>
14 /** One pose a beat, round and round, while she is at work. */
15 loop: string
16 /** The pose she alternates with `A` when a task of hers has just ended well. */
17 hop: string
18}
19
20const SPRITES: Record<MemberId, Sprite> = {
21 // 조타: a cat with a headset, bobbing to the count.
22 woni: {
23 poses: {
24 A: ['.K......K.', '.KGGGGGGK.', 'GKKKKKKKKG', 'GKYKKKKYKG', 'GKKKPPKKKG', '.KKKKKKKK.', '..KKKKKK..', '..KK..KK..'],
25 B: ['..........', '.K......K.', '.KGGGGGGK.', 'GKKKKKKKKG', 'GKYKKKKYKG', 'GKKKPPKKKG', '.KKKKKKKK.', '.KK....KK.'],
26 C: ['.K......K.', '.KGGGGGGK.', 'GKKKKKKKKG', 'GKDKKKKDKG', 'GKKKPPKKKG', '.KKKKKKKK.', '..KKKKKK..', '..KK..KK..'],
27 D: ['K........K', '.KGGGGGGK.', 'GKKKKKKKKG', 'GKYKKKKYKG', 'GKKPPPPKKG', '.KKKKKKKK.', '..KKKKKK..', '..KK..KK..'],
28 },
29 colors: { K: 0x5a5a5a, G: 0x1f9d78, Y: 0xf5d94e, P: 0xf4a6c8, D: 0x3d3d3d },
30 light: { G: 0x045a42 },
31 loop: 'AABBAABBADDBAABBACBB',
32 hop: 'B',
33 },
34 // 리뿌: a hamster, cheeks going as she chews a thing over.
35 liv: {
36 poses: {
37 A: ['.WW....WW.', 'WPWWWWWWPW', 'WWWWWWWWWW', 'WWKWWWWKWW', 'WCWWPPWWCW', 'WWWWWWWWWW', '.WWWWWWWW.', '..WW..WW..'],
38 B: ['.WW....WW.', 'WPWWWWWWPW', 'WWWWWWWWWW', 'WWKWWWWKWW', 'CCWWPPWWCC', 'WCWWWWWWCW', '.WWWWWWWW.', '..WW..WW..'],
39 C: ['..........', '.WW....WW.', 'WPWWWWWWPW', 'WWWWWWWWWW', 'WWKWWWWKWW', 'WCWWPPWWCW', 'WWWWWWWWWW', '.WWW..WWW.'],
40 D: ['.WW....WW.', 'WPWWWWWWPW', 'WWWWWWWWWW', 'WWDWWWWDWW', 'WCWWPPWWCW', 'WWWWWWWWWW', '.WWWWWWWW.', '..WW..WW..'],
41 },
42 colors: { W: 0xf2f2f2, P: 0xf4a6c8, K: 0x222222, C: 0xf9c9dc, D: 0xb4b4b4 },
43 light: { W: 0xb7aca3, P: 0xe07aa6, C: 0xf09ab8, D: 0x857b73 },
44 loop: 'ABABABAACCAABABADA',
45 hop: 'C',
46 },
47 // 밍: a harp seal, bouncing and clapping her flippers.
48 minami: {
49 poses: {
50 A: ['..........', '..BBBBBB..', '.BBBBBBBB.', '.BKBBBBKB.', '.BBBWWBBB.', 'BBBWKKWBBB', 'BBBBBBBBBB', '.BBBBBBBB.'],
51 B: ['..BBBBBB..', '.BBBBBBBB.', '.BKBBBBKB.', '.BBBWWBBB.', 'BBBWKKWBBB', 'BBBBBBBBBB', '.BBBBBBBB.', '..BBBBBB..'],
52 C: ['..........', '..BBBBBB..', 'BBBBBBBBBB', 'BBKBBBBKBB', '.BBBWWBBB.', '.BBWKKWBB.', '.BBBBBBBB.', '.BBBBBBBB.'],
53 D: ['..........', '..BBBBBB..', '.BBBBBBBB.', '.BDBBBBDB.', '.BBBWWBBB.', 'BBBWKKWBBB', 'BBBBBBBBBB', '.BBBBBBBB.'],
54 },
55 colors: { B: 0x7cc7e8, K: 0x1b3a4a, W: 0xffffff, D: 0x5aa3c4 },
56 light: { B: 0x3f9fc8, D: 0x2b7fa3 },
57 loop: 'AABBAABBACACACAD',
58 hop: 'B',
59 },
60 // 얌: an elephant, ears flapping, trunk swinging after a scent.
61 may: {
62 poses: {
63 A: ['YY.YYYY.YY', 'YYYYYYYYYY', 'YYYKYYKYYY', 'YY.YYYY.YY', '...YYYY...', '..WYYYYW..', '....YY....', '....YYY...'],
64 B: ['...YYYY...', '.YYYYYYYY.', 'YYYKYYKYYY', 'YYYYYYYYYY', 'YY.YYYY.YY', '..WYYYYW..', '....YY....', '...YYY....'],
65 C: ['YY.YYYY.YY', 'YYYYYYYYYY', 'YYYKYYKYYY', 'YY.YYYY.YY', '...YYYY...', '..WYYYYW..', '....YY..Y.', '....YYYYY.'],
66 D: ['YY.YYYY.YY', 'YYYYYYYYYY', 'YYYDYYDYYY', 'YY.YYYY.YY', '...YYYY...', '..WYYYYW..', '....YY....', '....YYY...'],
67 },
68 colors: { Y: 0xecd25b, K: 0x4a3b00, W: 0xffffff, D: 0xb89e2c },
69 light: { Y: 0xd4a800, W: 0x9c8a5a, D: 0x8a6d00 },
70 loop: 'AABBAABBACCAACCADA',
71 hop: 'B',
72 },
73 // 쩨로밍: a squirrel, tail swishing, hopping off to look.
74 zena: {
75 poses: {
76 A: ['......TTT.', '.P..P.TTTT', '.PPPP..TTT', '.KPKP..TTT', '.PPNP.TTT.', '.PPPPPTT..', '..PWWPT...', '..P..P....'],
77 B: ['.P..P..TT.', '.PPPP.TTTT', '.KPKP.TTTT', '.PPNP..TTT', '.PPPPP.TT.', '..PWWPTT..', '..P..PT...', '..........'],
78 C: ['.....TTT..', '.P..PTTTT.', '.PPPP.TTT.', '.KPKP..TTT', '.PPNP..TT.', '.PPPPPTT..', '..PWWPT...', '..P..P....'],
79 D: ['......TTT.', '.P..P.TTTT', '.PPPP..TTT', '.DPDP..TTT', '.PPNP.TTT.', '.PPPPPTT..', '..PWWPT...', '..P..P....'],
80 },
81 colors: { P: 0xf4a6c8, T: 0xba92db, K: 0x3a2030, N: 0xd9588f, W: 0xffffff, D: 0xd98bb0 },
82 light: { P: 0xe88ab4, T: 0x7a4bb0, W: 0xf4d7e4, D: 0xc76f9a },
83 loop: 'ACACABBAACACABBAD',
84 hop: 'B',
85 },
86}
87
88export const ICON_COLUMNS = 10
89export const ICON_ROWS = 4
90
91const CLEAR = 0x01000000
92const UPPER = 0x2580
93const LOWER = 0x2584
94const SPACE = 0x20
95const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
96
97const base64 = (bytes: Uint8Array): string => {
98 let text = ''
99
100 for (let at = 0; at < bytes.length; at += 3) {
101 const a = bytes[at] ?? 0
102 const b = bytes[at + 1] ?? 0
103 const c = bytes[at + 2] ?? 0
104
105 text += ALPHABET.charAt(a >> 2) + ALPHABET.charAt(((a & 3) << 4) | (b >> 4))
106 text += at + 1 < bytes.length ? ALPHABET.charAt(((b & 15) << 2) | (c >> 6)) : '='
107 text += at + 2 < bytes.length ? ALPHABET.charAt(c & 63) : '='
108 }
109
110 return text
111}
112
113/** A pose as a Raster's cells: the upper pixel a cell's ink, the lower its ground. */
114const cellsOf = (rows: readonly string[], colors: Readonly<Record<string, number>>): string => {
115 const words: number[] = []
116 const pixel = (row: number, column: number): number => colors[rows[row]?.charAt(column) ?? '.'] ?? CLEAR
117
118 for (let row = 0; row < ICON_ROWS; row += 1) {
119 for (let column = 0; column < ICON_COLUMNS; column += 1) {
120 const upper = pixel(row * 2, column)
121 const lower = pixel(row * 2 + 1, column)
122
123 if (upper === CLEAR && lower === CLEAR) words.push(SPACE, CLEAR, CLEAR)
124 else if (upper === CLEAR) words.push(LOWER, lower, CLEAR)
125 else words.push(UPPER, upper, lower)
126 }
127 }
128
129 const bytes = new Uint8Array(words.length * 4)
130
131 words.forEach((word, index) => new DataView(bytes.buffer).setUint32(index * 4, word, true))
132
133 return base64(bytes)
134}
135
136type Reel = { still: string; loop: readonly string[]; cheer: readonly string[] }
137
138const reelOf = ({ poses, colors, light, loop, hop }: Sprite, isLight: boolean): Reel => {
139 const inks = isLight ? { ...colors, ...light } : colors
140 const drawn = Object.fromEntries(Object.entries(poses).map(([name, rows]) => [name, cellsOf(rows, inks)]))
141 const still = drawn.A ?? ''
142 const frame = (name: string): string => drawn[name] ?? still
143
144 return { still, loop: [...loop].map(frame), cheer: [frame(hop), still] }
145}
146
147const reelsOf = (isLight: boolean): Record<MemberId, Reel> => ({
148 woni: reelOf(SPRITES.woni, isLight),
149 liv: reelOf(SPRITES.liv, isLight),
150 minami: reelOf(SPRITES.minami, isLight),
151 may: reelOf(SPRITES.may, isLight),
152 zena: reelOf(SPRITES.zena, isLight),
153})
154
155const REELS = { dark: reelsOf(false), light: reelsOf(true) }
156const reel = (id: MemberId, isLight: boolean): Reel => (isLight ? REELS.light : REELS.dark)[id]
157
158/** How long a pose stays up. */
159export const FRAME_MS = 200
160
161/** A member standing still, in the colors for the terminal's theme. */
162export const iconOf = (id: MemberId, isLight = false): string => reel(id, isLight).still
163
164/** Her pose on a beat while she works. */
165export const frameOf = (id: MemberId, beat: number, isLight = false): string => {
166 const { loop, still } = reel(id, isLight)
167
168 return loop[beat % loop.length] ?? still
169}
170
171/** Her pose on a beat while she hops for a task that ended well. */
172export const cheerOf = (id: MemberId, beat: number, isLight = false): string => reel(id, isLight).cheer[beat % 2] ?? reel(id, isLight).still
173
174// The wordmark over the pane where there is room for it: RESCENE in a
175// three-by-five pixel face, in the pink the fan wiki gives the group.
176const FACE: Record<string, readonly string[]> = {
177 R: ['XX.', 'X.X', 'XX.', 'X.X', 'X.X'],
178 E: ['XXX', 'X..', 'XX.', 'X..', 'XXX'],
179 S: ['.XX', 'X..', '.X.', '..X', 'XX.'],
180 C: ['.XX', 'X..', 'X..', 'X..', '.XX'],
181 N: ['X.X', 'XXX', 'XXX', 'X.X', 'X.X'],
182}
183
184const wordmark = (word: string, ink: number): { columns: number; rows: number; cells: string } => {
185 const lines = Array.from({ length: 6 }, (_, row) => [...word].map(letter => FACE[letter]?.[row] ?? '...').join('.'))
186 const columns = lines[0]?.length ?? 0
187 const words: number[] = []
188
189 for (let row = 0; row < 3; row += 1) {
190 for (let column = 0; column < columns; column += 1) {
191 const upper = lines[row * 2]?.charAt(column) === 'X'
192 const lower = lines[row * 2 + 1]?.charAt(column) === 'X'
193
194 if (upper && lower) words.push(0x2588, ink, CLEAR)
195 else if (upper) words.push(UPPER, ink, CLEAR)
196 else if (lower) words.push(LOWER, ink, CLEAR)
197 else words.push(SPACE, CLEAR, CLEAR)
198 }
199 }
200
201 const bytes = new Uint8Array(words.length * 4)
202
203 words.forEach((word, index) => new DataView(bytes.buffer).setUint32(index * 4, word, true))
204
205 return { columns, rows: 3, cells: base64(bytes) }
206}
207
208const LOGOS = { dark: wordmark('RESCENE', 0xfc6fcf), light: wordmark('RESCENE', 0xc2188f) }
209
210/** The wordmark's size, and its cells in the pink for the terminal's theme. */
211export const LOGO = { columns: LOGOS.dark.columns, rows: LOGOS.dark.rows }
212export const logoOf = (isLight = false): string => (isLight ? LOGOS.light : LOGOS.dark).cells
213hooks/orca.ts 247 lines1// Reads `fleet-run <profile> --spec <file> --out <file>` out of a shell
2// command: the Orca worker launches the conductor makes through Bash.
3
4import type { Tokens } from '../types'
5import { t } from './lang'
6
7export type FleetRun = {
8 profile: string
9 /** The spec's path as the command spells it, quotes and all. */
10 rawSpec?: string
11 /** The paths with the command's own variables filled in, when plain. */
12 spec?: string
13 out?: string
14 title: string
15}
16
17const PLAIN = /^[\w@%+=:,./~-]+$/
18const ASSIGNED = /^([A-Za-z_]\w*)=(.*)$/s
19/** Words a step may open with before the command it runs. */
20const LEADS = new Set(['if', 'while', 'until', 'then', 'do', 'else', 'elif', '{', '!', 'time', 'nohup', 'exec', 'command'])
21/** The shells whose `-c` takes a command to run; anything else's `-c` is its own flag (`grep -c`). */
22const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh'])
23const DECLARES = new Set(['export', 'local', 'typeset', 'readonly', 'declare'])
24
25/**
26 * The command as the shell reads its words: less the bodies of its
27 * here-documents (what a script is fed is not run), continued lines joined.
28 * `<<<` is a here-string and `1<<3` arithmetic; `<<\EOF` and an empty body count.
29 */
30const asRun = (command: string): string =>
31 command
32 .replace(/(?<![<\d])<<(?!<)-?[ \t]*\\?(['"]?)(\w+)\1([^\n]*)\n(?:[\s\S]*?\n)?[ \t]*\2[ \t]*(?=\n|$)/g, (_whole, _quote, _tag, rest: string) => rest)
33 .replace(/\\\n/g, ' ')
34
35/**
36 * A command's steps, each its words as written, quotes and all: what stands
37 * between `;`, `&`, `|`, a newline and parentheses. A quoted stretch is one
38 * word's, a comment nobody's, and `$(...)` and `${...}` stay in their word.
39 */
40const stepsOf = (text: string): string[][] => {
41 const steps: string[][] = []
42 let words: string[] = []
43 let word = ''
44 let quote = ''
45 const endWord = (): void => {
46 if (word !== '') words.push(word)
47 word = ''
48 }
49 const endStep = (): void => {
50 endWord()
51 if (words.length > 0) steps.push(words)
52 words = []
53 }
54
55 for (let at = 0; at < text.length; at += 1) {
56 const char = text.charAt(at)
57
58 if (quote !== '') {
59 word += char
60 if (char === '\\' && quote === '"') {
61 word += text.charAt(at + 1)
62 at += 1
63 } else if (char === quote) quote = ''
64 } else if (char === '\\') {
65 word += char + text.charAt(at + 1)
66 at += 1
67 } else if (char === '"' || char === "'") {
68 quote = char
69 word += char
70 } else if (char === '$' && (text.charAt(at + 1) === '(' || text.charAt(at + 1) === '{')) {
71 const close = text.charAt(at + 1) === '(' ? ')' : '}'
72 const open = text.charAt(at + 1)
73 let depth = 0
74 let end = at + 1
75
76 for (; end < text.length; end += 1) {
77 if (text.charAt(end) === open) depth += 1
78 else if (text.charAt(end) === close && (depth -= 1) === 0) break
79 }
80 word += text.slice(at, end + 1)
81 at = end
82 } else if (char === '#' && word === '') {
83 while (at < text.length && text.charAt(at) !== '\n') at += 1
84 endStep()
85 } else if (char === '\n' || char === ';' || char === '|' || char === '&' || char === '(' || char === ')') endStep()
86 else if (char === ' ' || char === '\t') endWord()
87 else word += char
88 }
89 endStep()
90
91 return steps
92}
93
94/** A word as the shell hands it on, where that can be told: its quotes off, the variables known so far filled in. */
95const resolve = (word: string, vars: ReadonlyMap<string, string>, home: string | undefined): string | undefined => {
96 // Single quotes keep their dollars; a word quoted in part is not followed.
97 if (/^'[^']*'$/.test(word)) return PLAIN.test(word.slice(1, -1)) ? word.slice(1, -1) : undefined
98 if (word.includes("'") || word.includes('`') || word.includes('$(')) return undefined
99 const filled = word.replace(/\\?"/g, '').replace(/\$\{(\w+)\}|\$(\w+)/g, (whole, braced: string | undefined, bare: string | undefined) => {
100 return vars.get(braced ?? bare ?? '') ?? whole
101 })
102 const expanded = home !== undefined && filled.startsWith('~/') ? `${home}${filled.slice(1)}` : filled
103
104 return PLAIN.test(expanded) && !expanded.startsWith('~') ? expanded : undefined
105}
106
107/** The value of `--name value` or `--name=value` among a step's words, as written. */
108const option = (words: readonly string[], name: string): string | undefined => {
109 for (const [at, word] of words.entries()) {
110 // A flag with nothing after it but the next flag has no value.
111 if (word === `--${name}`) return words[at + 1]?.startsWith('--') === true ? undefined : words[at + 1]
112 if (word.startsWith(`--${name}=`)) return word.slice(name.length + 3)
113 }
114
115 return undefined
116}
117
118const baseName = (path: string): string => path.slice(path.lastIndexOf('/') + 1)
119
120/** What a quoted word holds, read as a command of its own: a `--command "..."`, a `-c '...'`. */
121const inner = (word: string): string | undefined => {
122 if (/^'[^']*'$/.test(word)) return word.slice(1, -1)
123 if (/^"[\s\S]*"$/.test(word)) return word.slice(1, -1).replace(/\\(["\\$`])/g, '$1')
124
125 return undefined
126}
127
128const scan = (text: string, vars: Map<string, string>, home: string | undefined, runs: FleetRun[]): void => {
129 for (const words of stepsOf(text)) {
130 let at = 0
131
132 while (at < words.length && (LEADS.has(words[at] ?? '') || ASSIGNED.test(words[at] ?? ''))) at += 1
133 const head = words[at]
134
135 // A step that only assigns, or declares, is what later steps' variables read.
136 if (head === undefined || DECLARES.has(head)) {
137 for (const word of words) {
138 const [, name, value] = ASSIGNED.exec(word) ?? []
139 const set = name === undefined || value === undefined ? undefined : resolve(value, vars, home)
140
141 if (name !== undefined && set !== undefined) vars.set(name, set)
142 else if (name !== undefined) vars.delete(name)
143 }
144 continue
145 }
146 if (head === 'fleet-run') {
147 const [profile, ...args] = words.slice(at + 1)
148 const rawSpec = option(args, 'spec')
149 const rawOut = option(args, 'out')
150
151 // A worker is launched with its spec; without one the step only names the tool.
152 if (profile === undefined || !/^[a-z]+-[a-z]+$/.test(profile) || rawSpec === undefined) continue
153 const spec = resolve(rawSpec, vars, home)
154 const out = rawOut === undefined ? undefined : resolve(rawOut, vars, home)
155 const named = baseName(spec ?? out ?? '').replace(/\.(?:spec|out)\.md$|\.md$/, '')
156
157 runs.push({ profile, rawSpec, spec, out, title: named === '' ? profile : named })
158 continue
159 }
160 // A command handed on as one quoted word runs too: `orca terminal create --command "..."`, `zsh -c '...'`.
161 for (const [index, word] of words.entries()) {
162 const isHanded = word === '--command' || (SHELLS.has(head.slice(head.lastIndexOf('/') + 1)) && /^-[a-z]*c$/.test(word))
163 const handed = index > at && isHanded ? inner(words[index + 1] ?? '') : undefined
164
165 if (handed !== undefined) scan(handed, new Map(vars), home, runs)
166 }
167 }
168}
169
170/**
171 * The workers a command launches: `fleet-run` where the shell runs it, not
172 * where a `grep` or an `echo` is handed its name, with the variables each
173 * launch reads as the steps before it left them.
174 */
175export const findFleetRuns = (command: string, home?: string): FleetRun[] => {
176 const runs: FleetRun[] = []
177
178 scan(asRun(command), new Map(), home, runs)
179
180 return runs
181}
182
183/** A command that is one launch and nothing else: no second step, no pipe, no redirect. */
184export const isPlainLaunch = (command: string): boolean => {
185 const run = asRun(command).trim()
186 const steps = stepsOf(run)
187 // What stands outside quotes: nothing may be appended after a comment or a second line.
188 const bare = run.replace(/"[^"]*"|'[^']*'/g, '')
189
190 return steps.length === 1 && steps[0]?.[0] === 'fleet-run' && !steps[0].some(word => /[<>`]|\$\(/.test(word)) && !/[|&;\n]|(?:^|\s)#/.test(bare)
191}
192
193/** Whether the command hands the worker to something that returns at once. */
194export const isDetached = (command: string): boolean =>
195 /\borca\s+terminal\s+create\b|\bnohup\b|&\s*(?:$|\n|disown)/.test(command)
196
197/**
198 * Where a member's copy of a spec goes: beside it, under her name, so two
199 * workers launched on one spec each read their own.
200 */
201export const voicedSpecPath = (spec: string, tag: string): string =>
202 spec.endsWith('.md') ? `${spec.slice(0, -3)}.rescene-${tag}.md` : `${spec}.rescene-${tag}.md`
203
204export type Meta = { isOk: boolean; note: string; tokens?: Tokens }
205
206const count = (usage: Record<string, unknown>, key: string): number => {
207 const value = usage[key]
208
209 return typeof value === 'number' && Number.isFinite(value) ? value : 0
210}
211
212/**
213 * A worker's usage as one shape. Each engine spells its own: Codex counts
214 * the cached tokens inside `input_tokens`, the others beside them.
215 */
216export const readTokens = (usage: unknown): Tokens | undefined => {
217 if (typeof usage !== 'object' || usage === null) return undefined
218 const row = usage as Record<string, unknown>
219 const inside = count(row, 'cached_input_tokens')
220 const cached = inside + count(row, 'cache_read_input_tokens') + count(row, 'cache_read_tokens')
221 const fresh = count(row, 'input_tokens') - inside + count(row, 'cache_creation_input_tokens')
222 const out = count(row, 'output_tokens')
223 const usd = typeof row.cost_usd === 'number' ? row.cost_usd : undefined
224
225 return fresh + cached + out === 0 && usd === undefined ? undefined : { fresh: Math.max(0, fresh), cached, out, usd }
226}
227
228/** What a worker's `<out>.meta.json` says about how it ended. */
229export const readMeta = (text: string): Meta | undefined => {
230 try {
231 const meta: unknown = JSON.parse(text)
232
233 if (typeof meta !== 'object' || meta === null) return undefined
234 const { exit_code: code, model, seconds, status, usage } = meta as Record<string, unknown>
235 // A meta file with no exit code is one still being written.
236 if (typeof code !== 'number' || !Number.isFinite(code)) return undefined
237 const isOk = code === 0
238 const took = typeof seconds === 'number' ? t(`${Math.round(seconds)}초`, `${Math.round(seconds)}s`) : ''
239 const how = isOk ? '' : typeof status === 'string' ? status : t(`종료 코드 ${String(code)}`, `exit code ${String(code)}`)
240 const note = [typeof model === 'string' ? model : '', took, how].filter(part => part !== '').join(' · ')
241
242 return { isOk, note, tokens: readTokens(usage) }
243 } catch {
244 return undefined
245 }
246}
247hooks/view.tsx 1529 lines1import type { Color, Elements, RenderElement } from 'claude-code'
2
3import type { Cast, Line, Live, MemberId, Peek, Said, Step, Task, Tokens, Turn, Usage } from '../types'
4
5import type { WorkRole } from './members'
6import { langNow, t } from './lang'
7import { CAST, dutyOf, isActive, isSameCast, LEADER, lineOf, MEMBERS, nameOf, ORDER, PINK, pinkOf, roleIn, roleName, ROLES, say, unitOf, WORKERS } from './members'
8import { frameOf, ICON_COLUMNS, ICON_ROWS, iconOf, LOGO, logoOf } from './sprites'
9
10/** The elements a drawing is made of, and whether the terminal's theme is a light one. */
11export type Kit = Pick<Elements['terminal'], 'Box' | 'Text' | 'Raster' | 'Button'> & { isLight?: boolean }
12
13export type Scene = {
14 tasks: readonly Task[]
15 now: number
16 waveAt: number
17 leader: Said | null
18 ticker: Said | null
19 usage: Usage | null
20 leaderTokens: Tokens
21 feed: readonly Said[]
22 cup: readonly Line[]
23 turn: Turn | null
24 live: { readonly [id in MemberId]?: Live }
25 steps: { readonly [id in MemberId]?: readonly Step[] }
26 focus: MemberId | null
27 peek: Peek | null
28 target: MemberId | null
29 banked: { readonly [id in MemberId]?: Tokens }
30 /** Who has which kind of work. */
31 cast: Cast
32 /** The pane shows the screen the roles are set on. */
33 isCasting: boolean
34}
35
36/** What a press in the pane does: opens a member's backstage, or (nobody) goes back to the cards. */
37export type Acts = {
38 pick?: (id: MemberId | null) => void
39 /** Opens the screen the roles are set on, or leaves it. */
40 setup?: (isOpen: boolean) => void
41 /** Gives a kind of work to a member; nobody named, every member has her own again. */
42 recast?: (id: MemberId | null, role: WorkRole) => void
43}
44
45/** What the band above the prompt shows, and what a press on a member's name there does. */
46export type Band = {
47 /** The stage's own rows: who is on, the line just said. */
48 isStatus: boolean
49 /** The row that names who takes the next prompt, with the usage beside it. */
50 isAimed: boolean
51 /** Drawn to be seen: a rule above, each member with her heart and her kind of work, the usage as bars on a row of its own. */
52 isFull?: boolean
53 aim?: (id: MemberId | null) => void
54 /** Opens the screen the roles are set on. */
55 setup?: () => void
56}
57
58/** The room a pane has: the cells across its body, and the rows it may take. */
59export type Room = { columns: number; rows: number }
60
61const MARK: Record<Task['status'], string> = { running: '●', waiting: '◐', done: '✓', failed: '✗' }
62const WORD_KO: Record<Task['status'], string> = { running: '작업 중', waiting: '기다리는 중', done: '끝', failed: '실패' }
63const WORD_EN: Record<Task['status'], string> = { running: 'working', waiting: 'waiting', done: 'done', failed: 'failed' }
64const wordOf = (status: Task['status']): string => t(WORD_KO, WORD_EN)[status]
65const TINT: Record<Task['status'], Color | undefined> = {
66 running: undefined,
67 waiting: 'warning',
68 done: 'success',
69 failed: 'error',
70}
71
72const WIDE: readonly (readonly [number, number])[] = [
73 [0x1100, 0x115f],
74 [0x2e80, 0xa4cf],
75 [0xac00, 0xd7a3],
76 [0xf900, 0xfaff],
77 [0xfe30, 0xfe4f],
78 [0xff00, 0xff60],
79 [0xffe0, 0xffe6],
80 [0x1f300, 0x1faff],
81 [0x20000, 0x3fffd],
82]
83
84const SEGMENTER = typeof Intl === 'object' && 'Segmenter' in Intl ? new Intl.Segmenter('ko', { granularity: 'grapheme' }) : undefined
85
86/** A text as the terminal draws it: what it sets in one place (a syllable of loose jamo, an emoji joined of several) is one. */
87const graphemes = (text: string): string[] => (SEGMENTER === undefined ? [...text] : Array.from(SEGMENTER.segment(text), part => part.segment))
88
89const cellsOfChar = (part: string): number => {
90 const code = part.codePointAt(0) ?? 0
91
92 // Drawn as an emoji whatever it opens with: a heart with its selector, a family joined of three.
93 if (part.length > 1 && (part.includes('\ufe0f') || part.includes('\u200d'))) return 2
94 if (code === 0xfe0f || code === 0x200d || code === 0x200b || (code >= 0x300 && code <= 0x36f)) return 0
95
96 return WIDE.some(([from, to]) => code >= from && code <= to) ? 2 : 1
97}
98
99/** Cells a string takes on a terminal row: Hangul, kana and emoji take two. */
100export const cells = (text: string): number => {
101 let total = 0
102
103 for (const part of graphemes(text)) total += cellsOfChar(part)
104
105 return total
106}
107
108/** The text cut to a width in cells, an ellipsis standing for what was cut. */
109export const fit = (text: string, width: number): string => {
110 if (width <= 0) return ''
111 if (cells(text) <= width) return text
112 let kept = ''
113 let used = 0
114
115 for (const part of graphemes(text)) {
116 const next = cellsOfChar(part)
117
118 if (used + next > width - 1) break
119 kept += part
120 used += next
121 }
122
123 return `${kept}…`
124}
125
126export const pad = (text: string, width: number): string => text + ' '.repeat(Math.max(0, width - cells(text)))
127
128export const clock = (ms: number): string => {
129 const seconds = Math.max(0, Math.floor(ms / 1000))
130
131 return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
132}
133
134export const spoken = (ms: number): string => {
135 const seconds = Math.max(0, Math.round(ms / 1000))
136 const minutes = Math.floor(seconds / 60)
137
138 return minutes === 0 ? t(`${seconds}초`, `${seconds}s`) : t(`${minutes}분 ${seconds % 60}초`, `${minutes}m ${seconds % 60}s`)
139}
140
141export const elapsed = (task: Task, now: number): number => (task.endedAt ?? Math.max(now, task.startedAt)) - task.startedAt
142
143/** The tasks of the wave on stage: the active ones first, then the newest. */
144export const onStage = (scene: Scene): Task[] =>
145 scene.tasks
146 .filter(task => task.startedAt >= scene.waveAt || isActive(task))
147 .sort((a, b) => Number(isActive(b)) - Number(isActive(a)) || b.startedAt - a.startedAt)
148
149const latest = (scene: Scene): Said | null => {
150 const { leader, ticker } = scene
151
152 if (leader === null || ticker === null) return leader ?? ticker
153
154 return leader.at >= ticker.at ? leader : ticker
155}
156
157const Badge = ({ Text }: Kit, id: MemberId): RenderElement => (
158 <Text backgroundColor={MEMBERS[id].color} color={MEMBERS[id].ink} bold>
159 {` ${nameOf(id)} `}
160 </Text>
161)
162
163const Heart = ({ Text, isLight }: Kit, id: MemberId): RenderElement => <Text color={lineOf(id, isLight)}>♥ </Text>
164
165// ---- usage
166
167/** A token count the way its reader says it: 4.6천, 17.1만, 1.2억 in Korean; 4.6k, 171k, 120M in English. */
168export const amount = (tokens: number): string => {
169 const short = (value: number): string => (value >= 100 ? String(Math.round(value)) : String(Math.round(value * 10) / 10))
170
171 if (langNow() === 'en') {
172 // A figure that would round up to a thousand of its unit is said in the next one.
173 if (tokens >= 999_500_000) return `${short(tokens / 1_000_000_000)}B`
174 if (tokens >= 999_500) return `${short(tokens / 1_000_000)}M`
175
176 return tokens >= 1_000 ? `${short(tokens / 1_000)}k` : String(Math.round(tokens))
177 }
178 if (tokens >= 100_000_000) return `${short(tokens / 100_000_000)}억`
179 if (tokens >= 10_000) return `${short(tokens / 10_000)}만`
180 if (tokens >= 1_000) return `${short(tokens / 1_000)}천`
181
182 return String(Math.round(tokens))
183}
184
185export const ZERO: Tokens = { fresh: 0, cached: 0, out: 0 }
186
187export const plus = (a: Tokens, b: Tokens | undefined): Tokens => {
188 if (b === undefined) return a
189 const usd = a.usd === undefined && b.usd === undefined ? undefined : (a.usd ?? 0) + (b.usd ?? 0)
190
191 return { fresh: a.fresh + b.fresh, cached: a.cached + b.cached, out: a.out + b.out, usd }
192}
193
194export const spent = (tokens: Tokens): string => {
195 const cost = tokens.usd === undefined ? '' : ` · $${tokens.usd.toFixed(2)}`
196
197 return t(`입력 ${amount(tokens.fresh)} · 캐시 ${amount(tokens.cached)} · 출력 ${amount(tokens.out)}${cost}`, `in ${amount(tokens.fresh)} · cache ${amount(tokens.cached)} · out ${amount(tokens.out)}${cost}`)
198}
199
200const SPENT = new WeakMap<Scene, Record<MemberId, Tokens>>()
201
202/** What each member has spent so far: her tasks summed with those no longer listed, 원이's the main loop's. */
203export const spentBy = (scene: Scene): Record<MemberId, Tokens> => {
204 const summed = SPENT.get(scene)
205
206 if (summed !== undefined) return summed
207 const by: Record<MemberId, Tokens> = {
208 woni: plus(scene.leaderTokens, scene.banked.woni),
209 liv: scene.banked.liv ?? ZERO,
210 minami: scene.banked.minami ?? ZERO,
211 may: scene.banked.may ?? ZERO,
212 zena: scene.banked.zena ?? ZERO,
213 }
214
215 for (const task of scene.tasks) by[task.member] = plus(by[task.member], task.tokens)
216 SPENT.set(scene, by)
217
218 return by
219}
220
221const isSpent = (tokens: Tokens): boolean => tokens.fresh + tokens.cached + tokens.out > 0 || tokens.usd !== undefined
222
223const LIMIT_KO: Record<string, string> = { five_hour: '5시간', seven_day: '7일', spend_limit: '지출' }
224const LIMIT_EN: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
225const limitOf = (kind: string): string => t(LIMIT_KO, LIMIT_EN)[kind] ?? kind
226const battery = (): string => t('배터리', 'battery')
227
228export const until = (at: number, now: number): string => {
229 const minutes = Math.max(0, Math.round((at - now) / 60_000))
230 const hours = Math.floor(minutes / 60)
231
232 if (hours >= 48) return t(`${Math.floor(hours / 24)}일 뒤 초기화`, `resets in ${Math.floor(hours / 24)}d`)
233
234 return hours === 0 ? t(`${minutes}분 뒤 초기화`, `resets in ${minutes}m`) : t(`${hours}시간 ${minutes % 60}분 뒤 초기화`, `resets in ${hours}h ${minutes % 60}m`)
235}
236
237const bar = (percent: number, size = 10): string => {
238 const lit = percent <= 0 ? 0 : Math.max(1, Math.min(size, Math.round((percent / 100) * size)))
239
240 return '▰'.repeat(lit) + '▱'.repeat(size - lit)
241}
242
243/** The context window still free, 0 to 100, when the engine has a reading. */
244export const batteryOf = (usage: Usage | null): number | undefined =>
245 usage?.contextPercent === undefined ? undefined : Math.max(0, 100 - usage.contextPercent)
246
247/** The band's one-glance gauge: battery left, and the five-hour window. */
248export const gaugeOf = (usage: Usage | null): string => {
249 const free = batteryOf(usage)
250 const window = usage?.limits.find(limit => limit.kind === 'five_hour')
251
252 return [free === undefined ? '' : `${battery()} ${Math.round(free)}%`, window === undefined ? '' : `${limitOf('five_hour')} ${window.percentUsed}%`]
253 .filter(part => part !== '')
254 .join(' · ')
255}
256
257/** The usage in a row: as many of its figures, the nearest limit first, as the room holds. */
258export const gaugeIn = (usage: Usage | null, room: number): string => {
259 const free = batteryOf(usage)
260 const window = (kind: string): string => {
261 const limit = usage?.limits.find(one => one.kind === kind)
262
263 return limit === undefined ? '' : `${limitOf(kind)} ${Math.round(limit.percentUsed)}%`
264 }
265 const parts = [free === undefined ? '' : `${battery()} ${Math.round(free)}%`, window('five_hour'), window('seven_day'), usage?.usd === undefined ? '' : `$${usage.usd.toFixed(2)}`].filter(part => part !== '')
266
267 for (let kept = parts.length; kept > 0; kept -= 1) {
268 const text = parts.slice(0, kept).join(' · ')
269
270 if (cells(text) <= room) return text
271 }
272
273 return ''
274}
275
276type Meter = { label: string; percent: number; tint: Color; text: string; reset: string; unit: string }
277
278const metersOf = (scene: Scene): Meter[] => {
279 const { usage } = scene
280 const free = batteryOf(usage)
281 const meters: Meter[] = []
282
283 if (usage !== null && free !== undefined) {
284 meters.push({
285 label: battery(),
286 percent: free,
287 tint: free < 20 ? 'error' : free < 40 ? 'warning' : 'success',
288 text: t(`${Math.round(free)}% 남음 · 컨텍스트 ${amount(usage.contextTokens ?? 0)} / ${amount(usage.contextWindow)}`, `${Math.round(free)}% left · context ${amount(usage.contextTokens ?? 0)} / ${amount(usage.contextWindow)}`),
289 reset: '',
290 unit: t('남음', 'left'),
291 })
292 }
293 for (const limit of usage?.limits ?? []) {
294 const due = limit.resetsAt === undefined ? '' : until(limit.resetsAt, scene.now)
295 const reset = due === '' ? '' : ` · ${due}`
296
297 meters.push({
298 label: limitOf(limit.kind),
299 percent: limit.percentUsed,
300 tint: limit.percentUsed >= 90 ? 'error' : limit.percentUsed >= 70 ? 'warning' : 'success',
301 text: t(`${limit.percentUsed}% 사용${reset}`, `${limit.percentUsed}% used${reset}`),
302 reset: due,
303 unit: t('사용', 'used'),
304 })
305 }
306
307 return meters
308}
309
310const spendersOf = (scene: Scene): MemberId[] => {
311 const by = spentBy(scene)
312
313 return ORDER.filter(id => isSpent(by[id]))
314}
315
316const spentHead = (): string => t('멤버별 토큰 · 따로 맡은 일만 (함께 한 일은 원이 몫)', "Tokens by member · her own tasks only (work beside WONI counts as WONI's)")
317
318/**
319 * A member's line under the usage card. Tokens are counted where a model
320 * session is: a subagent's or a worker's are hers, and what she does beside
321 * 원이 in the main session is spent by that session, so it is 원이's.
322 */
323export const spentLine = (id: MemberId, scene: Scene): string => {
324 const tokens = spentBy(scene)[id]
325 const running = scene.tasks.filter(task => task.member === id && isActive(task)).length
326 const beside = id === 'woni' ? undefined : scene.live[id]
327 const waiting = running === 0 ? '' : t(`작업 중 ${running}개는 끝나면 집계`, `${running} running, counted when done`)
328
329 if (isSpent(tokens)) return running === 0 ? spent(tokens) : t(`${spent(tokens)} · +${running}개 진행 중`, `${spent(tokens)} · +${running} running`)
330 if (waiting !== '') return waiting
331 if (beside !== undefined) return t(`따로 맡은 일 없음 · 원이와 함께 ${beside.count}번`, `no task of her own · ${beside.count} beside WONI`)
332
333 return scene.tasks.some(task => task.member === id) ? t('맡은 일은 있었지만 토큰 기록을 못 받음', 'had tasks, but no token record came') : t('따로 맡은 일 없음', 'no task of her own')
334}
335
336/** Rows the usage card takes, frame and all: with every member's line, or with only theirs who spent. */
337const usageRows = (scene: Scene, isWhole = false): number => {
338 const listed = isWhole ? ORDER.length : spendersOf(scene).length
339
340 return 2 + 1 + Math.max(1, metersOf(scene).length + (listed === 0 ? 0 : listed + 1))
341}
342
343/** The usage card: the live's battery, the plan's windows, who spent what. */
344const UsageCard = (kit: Kit, scene: Scene, width: number, isWhole: boolean): RenderElement => {
345 const { Box, Text } = kit
346 const inner = width - 4
347 const meters = metersOf(scene)
348 const by = spentBy(scene)
349 const listed = isWhole ? ORDER : spendersOf(scene)
350 const usd = scene.usage?.usd
351
352 return (
353 <Box borderStyle="round" borderColor={pinkOf(kit.isLight)} paddingX={1} flexDirection="column" width={width}>
354 <Box justifyContent="space-between" width={inner}>
355 <Text color={pinkOf(kit.isLight)} bold>
356 {t('사용량', 'Usage')}
357 </Text>
358 {usd !== undefined && <Text dimColor>{fit(`${t('Claude 세션', 'Claude session')} $${usd.toFixed(2)}`, inner - 8)}</Text>}
359 </Box>
360 {meters.map(meter => (
361 <Text key={meter.label} wrap="truncate-end">
362 <Text dimColor>{pad(meter.label, t(7, 8))}</Text>
363 <Text color={meter.tint}>{bar(meter.percent)}</Text>
364 <Text>{` ${fit(meter.text, inner - t(18, 19))}`}</Text>
365 </Text>
366 ))}
367 {listed.length > 0 && <Text dimColor>{fit(spentHead(), inner)}</Text>}
368 {meters.length === 0 && listed.length === 0 && <Text dimColor>{fit(t('아직 읽은 사용량이 없어요.', 'No usage read yet.'), inner)}</Text>}
369 {listed.map(id => (
370 <Text key={id} wrap="truncate-end">
371 {Heart(kit, id)}
372 <Text>{pad(nameOf(id), 7)}</Text>
373 <Text dimColor={!isSpent(by[id])}>{fit(spentLine(id, scene), inner - 9)}</Text>
374 </Text>
375 ))}
376 </Box>
377 )
378}
379
380// ---- the band
381
382type Chip = { task: Task; text: string }
383
384const chipsFor = (stage: readonly Task[], now: number, room: number): { chips: Chip[]; hidden: number } => {
385 const forms: ((task: Task) => string)[] = [
386 task => ` ${MARK[task.status]} ${roleName(task.role)} ${clock(elapsed(task, now))}`,
387 task => ` ${MARK[task.status]} ${clock(elapsed(task, now))}`,
388 task => ` ${MARK[task.status]}`,
389 ]
390 const need = (task: Task, form: (task: Task) => string): number => cells(nameOf(task.member)) + 2 + cells(form(task)) + 1
391
392 for (const form of forms) {
393 if (stage.reduce((total, task) => total + need(task, form), 0) <= room) {
394 return { chips: stage.map(task => ({ task, text: form(task) })), hidden: 0 }
395 }
396 }
397
398 const form = forms[2] ?? ((): string => '')
399 const chips: Chip[] = []
400 let left = room - 4
401
402 for (const task of stage) {
403 if (need(task, form) > left) break
404 chips.push({ task, text: form(task) })
405 left -= need(task, form)
406 }
407
408 return { chips, hidden: stage.length - chips.length }
409}
410
411/** What the row of names says of who takes the next prompt: nobody named, 원이 hands the work out as she sees fit. */
412export const aimHint = (target: MemberId | null): string => (target === null ? t('원이가 나눠 맡김', 'WONI hands it out') : target === 'woni' ? t('원이가 직접 처리', 'WONI does it herself') : t(`다음 명령은 ${nameOf(target)}에게`, `next goes to ${nameOf(target)}`))
413
414/** The name that stands for nobody named: the default. */
415const auto = (): string => t('자동', 'auto')
416const aimLabel = (): string => t('받는 멤버', 'Send to')
417const setupLabel = (): string => t('역할 설정', 'Roles')
418
419/** The row that names who takes the next prompt: a press on a name picks her, and the usage stands beside. */
420const AimRow = (kit: Kit, scene: Scene, columns: number, band: Band, withBrand: boolean, withGauge = true): RenderElement => {
421 const { Box, Text, Button } = kit
422 const { aim } = band
423 const picked = scene.target
424 const hint = aimHint(picked)
425 const names = cells(auto()) + 1 + ORDER.reduce((total, id) => total + cells(nameOf(id)) + 1, 0) + 4
426 // Too narrow for all of it: the label goes, then the brand, then every name but the one picked.
427 const withLabel = columns >= (withBrand ? 10 : 0) + cells(aimLabel()) + 1 + names
428 const isBranded = withBrand && columns >= 10 + names
429 const shown: readonly (MemberId | null)[] = columns >= names ? [null, ...ORDER] : [scene.target]
430 const fixed = (isBranded ? 10 : 0) + (withLabel ? cells(aimLabel()) + 1 : 0) + names
431 // The usage has the room first; the hint says again what the marked name shows.
432 const gauge = withGauge ? gaugeIn(scene.usage, columns - fixed - 1) : ''
433 const withHint = columns >= fixed + cells(hint) + 1 + (gauge === '' ? 0 : cells(gauge) + 1)
434 const name = (id: MemberId | null): string => (id === null ? auto() : nameOf(id))
435 const key = (id: MemberId | null): string => `aim-${id ?? 'auto'}`
436
437 return (
438 <Box gap={1}>
439 {isBranded && (
440 <Text backgroundColor={PINK} color="#000000" bold>
441 {' RESCENE '}
442 </Text>
443 )}
444 {(withLabel || shown.length === 1) && <Text dimColor>{aimLabel()}</Text>}
445 {shown.map(id =>
446 aim === undefined ? (
447 <Text key={key(id)} bold={id === picked} dimColor={id !== picked} color={id === picked && id !== null ? lineOf(id, kit.isLight) : undefined}>
448 {id === picked ? `[ ${name(id)} ]` : name(id)}
449 </Text>
450 ) : id === picked ? (
451 <Button key={key(id)} variant="primary" onPress={() => aim(id)}>
452 {name(id)}
453 </Button>
454 ) : (
455 <Button key={key(id)} plain dimColor onPress={() => aim(id)}>
456 {name(id)}
457 </Button>
458 ),
459 )}
460 {withHint && <Text color={picked === null ? pinkOf(kit.isLight) : lineOf(picked, kit.isLight)}>{hint}</Text>}
461 {gauge !== '' && <Text dimColor>{gauge}</Text>}
462 </Box>
463 )
464}
465
466const GUTTER = 9
467const BAND_GAP = 2
468
469/** What a row of the full band is made of, widest first: the first that fits is drawn. */
470const widthOf = (parts: readonly number[]): number => parts.reduce((total, one) => total + one, 0) + BAND_GAP * Math.max(0, parts.length - 1)
471
472/**
473 * The full band's row that names who takes the next prompt: every member
474 * with her heart in her color and her kind of work, the one picked marked.
475 * Nothing where the row is too narrow for the five names.
476 */
477const AimBar = (kit: Kit, scene: Scene, columns: number, band: Band, lead: 'brand' | 'blank', withWay: boolean): RenderElement | undefined => {
478 const { Box, Text, Button } = kit
479 const { aim } = band
480 // The way to the roles stands at the end of the usage's row; here only where that row is not drawn.
481 const setup = withWay ? band.setup : undefined
482 const picked = scene.target
483 const hint = `→ ${aimHint(picked)}`
484 const mark = (id: MemberId | null): number => (id === picked && aim !== undefined ? 4 : 0)
485 const chip = (id: MemberId, withRoles: boolean): number => 2 + cells(nameOf(id)) + mark(id) + (withRoles ? 1 + cells(roleName(roleIn(scene.cast, id))) : 0)
486 const forms = [
487 { withLead: true, withLabel: true, withRoles: true, withHint: true, withSetup: true },
488 { withLead: true, withLabel: true, withRoles: true, withHint: true, withSetup: false },
489 { withLead: true, withLabel: true, withRoles: true, withHint: false, withSetup: true },
490 { withLead: true, withLabel: false, withRoles: true, withHint: false, withSetup: true },
491 { withLead: true, withLabel: false, withRoles: false, withHint: false, withSetup: true },
492 { withLead: true, withLabel: false, withRoles: false, withHint: false, withSetup: false },
493 { withLead: false, withLabel: false, withRoles: false, withHint: false, withSetup: false },
494 ].map(form => ({ ...form, withSetup: form.withSetup && setup !== undefined }))
495 const need = (form: (typeof forms)[number]): number =>
496 widthOf([
497 ...(form.withLead ? [GUTTER] : []),
498 ...(form.withLabel ? [cells(aimLabel())] : []),
499 cells(auto()) + mark(null),
500 ...ORDER.map(id => chip(id, form.withRoles)),
501 ...(form.withHint ? [cells(hint)] : []),
502 ...(form.withSetup ? [cells(setupLabel())] : []),
503 ])
504 const form = forms.find(one => need(one) <= columns)
505
506 if (form === undefined) return undefined
507
508 return (
509 <Box gap={BAND_GAP}>
510 {form.withLead &&
511 (lead === 'brand' ? (
512 <Text backgroundColor={PINK} color="#000000" bold>
513 {' RESCENE '}
514 </Text>
515 ) : (
516 <Text>{' '.repeat(GUTTER)}</Text>
517 ))}
518 {form.withLabel && (
519 <Text color={pinkOf(kit.isLight)} bold>
520 {aimLabel()}
521 </Text>
522 )}
523 {aim === undefined ? (
524 <Text bold={picked === null}>{auto()}</Text>
525 ) : picked === null ? (
526 <Button key="aim-auto" variant="primary" onPress={() => aim(null)}>
527 {auto()}
528 </Button>
529 ) : (
530 <Button key="aim-auto" plain onPress={() => aim(null)}>
531 {auto()}
532 </Button>
533 )}
534 {ORDER.map(id => (
535 <Box key={`chip-${id}`}>
536 {Heart(kit, id)}
537 {aim === undefined ? (
538 <Text bold={id === picked}>{nameOf(id)}</Text>
539 ) : id === picked ? (
540 <Button key={`aim-${id}`} variant="primary" onPress={() => aim(id)}>
541 {nameOf(id)}
542 </Button>
543 ) : (
544 <Button key={`aim-${id}`} plain onPress={() => aim(id)}>
545 {nameOf(id)}
546 </Button>
547 )}
548 {form.withRoles && <Text dimColor>{` ${roleName(roleIn(scene.cast, id))}`}</Text>}
549 </Box>
550 ))}
551 {form.withHint && (
552 <Text color={picked === null ? pinkOf(kit.isLight) : lineOf(picked, kit.isLight)} bold={picked !== null}>
553 {hint}
554 </Text>
555 )}
556 {form.withSetup && setup !== undefined && (
557 <Button key="setup" plain dimColor onPress={setup}>
558 {setupLabel()}
559 </Button>
560 )}
561 </Box>
562 )
563}
564
565/** The full band's row of usage: each figure a bar in its own tint, and when its window starts over where there is room to say. */
566const UsageBar = (kit: Kit, scene: Scene, columns: number, setup?: () => void): RenderElement | undefined => {
567 const { Box, Text, Button } = kit
568 const way = setup === undefined ? 0 : cells(setupLabel())
569 const meters = metersOf(scene)
570 const cost = scene.usage?.usd === undefined ? '' : `$${scene.usage.usd.toFixed(2)}`
571 const percent = (meter: Meter, withUnits = false): string => `${Math.round(meter.percent)}%${withUnits ? ` ${meter.unit}` : ''}`
572 // Whose window is said: every limit's, the nearest's alone, nobody's.
573 const forms = [
574 { withLead: true, size: 10, resets: 2, withUnits: true, withWay: true },
575 { withLead: true, size: 10, resets: 1, withUnits: true, withWay: true },
576 { withLead: true, size: 10, resets: 0, withUnits: true, withWay: true },
577 { withLead: true, size: 5, resets: 0, withUnits: true, withWay: true },
578 { withLead: true, size: 5, resets: 0, withUnits: false, withWay: true },
579 { withLead: false, size: 5, resets: 0, withUnits: false, withWay: true },
580 { withLead: false, size: 0, resets: 0, withUnits: true, withWay: true },
581 { withLead: false, size: 0, resets: 0, withUnits: false, withWay: true },
582 { withLead: false, size: 0, resets: 0, withUnits: false, withWay: false },
583 ].map(form => ({ ...form, withWay: form.withWay && way > 0 }))
584 const reset = (meter: Meter, form: (typeof forms)[number]): string => {
585 const limits = meters.filter(one => one.reset !== '')
586
587 return meter.reset !== '' && limits.indexOf(meter) < (form.resets === 2 ? limits.length : form.resets) ? ` · ${meter.reset}` : ''
588 }
589 const need = (form: (typeof forms)[number]): number =>
590 widthOf([
591 ...(form.withLead ? [GUTTER, cells(t('사용량', 'Usage'))] : []),
592 ...meters.map(meter => cells(meter.label) + (form.size === 0 ? 0 : 1 + form.size) + 1 + cells(percent(meter, form.withUnits)) + cells(reset(meter, form))),
593 ...(cost === '' ? [] : [cells(cost)]),
594 ...(form.withWay ? [way] : []),
595 ])
596 const form = forms.find(one => need(one) <= columns)
597
598 if (meters.length === 0 || form === undefined) return undefined
599
600 return (
601 <Box gap={BAND_GAP}>
602 {form.withLead && (
603 <Text color={pinkOf(kit.isLight)} bold>
604 {' REMINE♥ '}
605 </Text>
606 )}
607 {form.withLead && (
608 <Text color={pinkOf(kit.isLight)} bold>
609 {t('사용량', 'Usage')}
610 </Text>
611 )}
612 {meters.map(meter => (
613 <Text key={`meter-${meter.label}`}>
614 <Text>{meter.label}</Text>
615 {form.size > 0 && <Text color={meter.tint}>{` ${bar(meter.percent, form.size)}`}</Text>}
616 <Text bold color={meter.tint === 'success' ? undefined : meter.tint}>{` ${percent(meter, form.withUnits)}`}</Text>
617 <Text dimColor>{reset(meter, form)}</Text>
618 </Text>
619 ))}
620 {cost !== '' && <Text dimColor>{cost}</Text>}
621 {form.withWay && setup !== undefined && (
622 <Button key="setup" plain dimColor onPress={setup}>
623 {setupLabel()}
624 </Button>
625 )}
626 </Box>
627 )
628}
629
630/** The band's own rows of the stage: who is on, and under it the line just said or what is being done. */
631const StageRows = (kit: Kit, scene: Scene, columns: number, withGauge: boolean, withSaid: boolean): RenderElement[] => {
632 const { Box, Text } = kit
633 const stage = onStage(scene)
634 const unit = unitOf(scene.tasks, Math.floor(scene.waveAt / 1000))
635 const doing = ORDER.map(id => ({ id, live: scene.live[id] }))
636 .filter((one): one is { id: MemberId; live: Live } => one.live !== undefined)
637 .sort((a, b) => b.live.at - a.live.at)
638 // The members at work beside 원이 take room on the row too: theirs is set aside before the rest is shared.
639 const beside = doing.filter(one => isFresh(one.live, scene.now) && !stage.some(task => task.member === one.id))
640 const besideNeed = (id: MemberId): number => cells(nameOf(id)) + 2 + 2 + 1
641 const room = columns - 10
642 const now: typeof beside = []
643 let taken = 0
644
645 for (const [index, one] of beside.entries()) {
646 // Unless she is the last, room stays for the `+N` that stands for those left out.
647 if (taken + besideNeed(one.id) > room - (index === beside.length - 1 ? 0 : 4)) break
648 now.push(one)
649 taken += besideNeed(one.id)
650 }
651 if (now.length < beside.length) taken += 4
652 // The usage has its own row where that is drawn. Otherwise it is the first to go, then the unit's name.
653 const gauge = withGauge ? gaugeOf(scene.usage) : ''
654 const head = unit === undefined ? '' : t(`${unit} 출동`, `${unit} on stage`)
655 const plans = [
656 { head, gauge },
657 { head, gauge: '' },
658 { head: '', gauge: '' },
659 ].map(plan => ({ ...plan, ...chipsFor(stage, scene.now, room - taken - (plan.head === '' ? 0 : cells(plan.head) + 1) - (plan.gauge === '' ? 0 : cells(plan.gauge) + 1)) }))
660 const plan = plans.find(one => one.hidden === 0) ?? plans.at(-1)
661 const chips = plan?.chips ?? []
662 const hidden = (plan?.hidden ?? 0) + (beside.length - now.length)
663 const spoke = latest(scene)
664 const act = doing[0]
665 // A line just said holds the second row for a while; after that it shows what is being done.
666 const said = spoke !== null && (act === undefined || scene.now - spoke.at < 8000) ? spoke : null
667 const quoteRoom = said === null ? 0 : columns - 2 - cells(nameOf(said.member)) - 3
668
669 return [
670 <Box key="stage" gap={1}>
671 <Text backgroundColor={PINK} color="#000000" bold>
672 {' RESCENE '}
673 </Text>
674 {plan !== undefined && plan.head !== '' && <Text bold>{plan.head}</Text>}
675 {chips.map(({ task, text }) => (
676 <Text key={task.id}>
677 {Badge(kit, task.member)}
678 <Text color={TINT[task.status]}>{text}</Text>
679 </Text>
680 ))}
681 {now.map(one => (
682 <Text key={`live-${one.id}`}>
683 {Badge(kit, one.id)}
684 <Text>{' ●'}</Text>
685 </Text>
686 ))}
687 {hidden > 0 && <Text dimColor>{`+${hidden}`}</Text>}
688 {plan !== undefined && plan.gauge !== '' && <Text dimColor>{plan.gauge}</Text>}
689 </Box>,
690 ...(withSaid && said === null && act !== undefined
691 ? [
692 <Text key="doing" wrap="truncate-end">
693 {Heart(kit, act.id)}
694 {Badge(kit, act.id)}
695 <Text bold={act.live.running > 0}>{` ${act.live.running > 0 ? '▸' : act.live.isFailed === true ? '✗' : '✓'} ${fit(act.live.phrase, columns - cells(nameOf(act.id)) - 8)}`}</Text>
696 </Text>,
697 ]
698 : []),
699 ...(withSaid && said !== null
700 ? [
701 <Text key="said" wrap="truncate-end">
702 {Heart(kit, said.member)}
703 {Badge(kit, said.member)}
704 <Text color={lineOf(said.member, kit.isLight)} italic>{` ${fit(`“${said.quote}”`, quoteRoom)}`}</Text>
705 <Text dimColor>{` ${fit(said.note, quoteRoom - cells(said.quote) - 3)}`}</Text>
706 </Text>,
707 ]
708 : []),
709 ]
710}
711
712/**
713 * The band above the prompt: who is on stage and the last line said, then
714 * who takes the next prompt, then the usage. With fewer rows than it has to
715 * show, the rows go in this order: the rule, the line said, the usage's own
716 * row (its figures then stand in a few words beside the names), the names.
717 */
718export const drawBand = (kit: Kit, scene: Scene, columns: number, rows: number, band: Band = { isStatus: true, isAimed: false }): RenderElement => {
719 const { Box } = kit
720 const isFull = band.isFull === true && band.isAimed
721 const hasUsage = isFull && metersOf(scene).length > 0
722 // Drawn small, the stage has one row and the names another; the line said keeps to the pane.
723 const isSmall = band.isFull === false && band.isAimed
724 const wanted = [band.isStatus ? 'stage' : '', band.isAimed ? 'aim' : '', hasUsage ? 'usage' : '', band.isStatus && !isSmall ? 'said' : '', isFull ? 'rule' : ''].filter(one => one !== '')
725 const kept = new Set(wanted.slice(0, Math.max(1, rows)))
726 const withStage = kept.has('stage')
727 // Drawn in full where its usage has a row to go to; otherwise in the one row that holds both.
728 const usage = kept.has('usage') ? UsageBar(kit, scene, columns, band.setup) : undefined
729 const names = kept.has('aim') && isFull && (usage !== undefined || !hasUsage) ? AimBar(kit, scene, columns, band, withStage ? 'blank' : 'brand', usage === undefined) : undefined
730
731 return (
732 <Box flexDirection="column">
733 {kept.has('rule') && Ribbon(kit, columns, '▂')}
734 {withStage && StageRows(kit, scene, columns, !kept.has('aim'), kept.has('said'))}
735 {kept.has('aim') && (names ?? AimRow(kit, scene, columns, band, !withStage, usage === undefined))}
736 {kept.has('aim') && usage}
737 </Box>
738 )
739}
740
741// ---- the pane: one card a member
742
743type Card = {
744 id: MemberId
745 sub: string
746 status: string
747 tint: Color | undefined
748 isIdle: boolean
749 work: string
750 quote: string
751 note: string
752 /** The fourth row of a whole card: what she is on now, or what she handed back. */
753 last: string
754 /** At work this moment: her icon moves. */
755 isAtWork: boolean
756 /** How busy her task has been, as bars; empty where there is no telling. */
757 pulse: string
758 /** What a press on her name does, and the digit that presses it. */
759 press?: () => void
760 hotkey?: string
761}
762
763const BARS = '▁▂▃▄▅▆▇█'
764const PULSE_MS = 10_000
765const PULSE_SHOWN = 10
766
767/** A task's tool calls over the last stretches of time, as bars: tall where she was busy, flat where quiet. */
768export const pulseOf = (task: Task, now: number): string => {
769 if (task.pulse === undefined || task.pulseAt === undefined) return ''
770 const quiet = Math.max(0, Math.floor((now - task.startedAt) / PULSE_MS) - task.pulseAt)
771 const beats = [...task.pulse, ...Array.from({ length: Math.min(quiet, PULSE_SHOWN) }, () => 0)].slice(-PULSE_SHOWN)
772 const top = Math.max(4, ...beats)
773
774 return beats.map(count => BARS.charAt(Math.min(BARS.length - 1, Math.round((count / top) * (BARS.length - 1))))).join('')
775}
776
777/** Which of her idle lines a resting member is on: a new one every while, each member on her own beat. */
778const musing = (id: MemberId, now: number): number => Math.floor(now / 45_000) + ORDER.indexOf(id) * 3
779
780/** Whether a member's own call is running, or ended a moment ago. */
781export const isFresh = (live: Live, now: number): boolean => live.running > 0 || now - live.at < 6000
782
783/** What the members call the person: the producer. */
784const pd = (): string => t('피디니무', 'PD-nim')
785const carrying = (): string => t('이어서 작업하는 중', 'carrying on')
786const idleMark = (): string => t('○ 대기', '○ idle')
787const calls = (count: number): string => t(`도구 ${count}회`, `${count} tool calls`)
788
789/** The day so far in one line, for 원이's card. */
790const tally = (scene: Scene): string => {
791 const ended = scene.tasks.filter(task => !isActive(task))
792 const failed = ended.filter(task => task.status === 'failed').length
793
794 if (ended.length === 0) return ''
795
796 return t(`오늘 무대: ${ended.length - failed}개 끝${failed === 0 ? '' : ` · ${failed}개 실패`}`, `Today: ${ended.length - failed} done${failed === 0 ? '' : ` · ${failed} failed`}`)
797}
798
799/** The task of hers a card and her backstage are about: the one she is on, or the last she took. */
800export const shownTask = (tasks: readonly Task[], id: MemberId): Task | undefined => {
801 const own = tasks.filter(task => task.member === id).sort((a, b) => b.startedAt - a.startedAt)
802
803 return own.find(isActive) ?? own[0]
804}
805
806const cardOf = (id: MemberId, scene: Scene): Card => {
807 const member = MEMBERS[id]
808
809 if (id === 'woni') {
810 const busy = WORKERS.filter(one => scene.tasks.some(task => task.member === one && isActive(task)))
811
812 const names = busy.map(one => nameOf(one)).join(', ')
813 const mine = scene.live.woni
814 const isConducting = scene.turn !== null || busy.length > 0
815 const stage = busy.length === 0 ? '' : t(`무대 위: ${names}`, `On stage: ${names}`)
816 // Her last line stays up while she conducts and for a while after; then she is back to musing.
817 const isRecent = isConducting || (scene.leader !== null && scene.now - scene.leader.at < 120_000)
818 const work = scene.turn !== null && scene.turn.ask !== '' ? `${pd()}${scene.turn.to === undefined ? '' : ` → ${nameOf(scene.turn.to)}`}: ${scene.turn.ask}` : stage !== '' ? stage : scene.turn !== null ? carrying() : t('일을 기다리는 중', 'waiting for work')
819
820 return {
821 id,
822 sub: t(`${member.remini} · ${member.title} · 리더`, `${member.name} · leader`),
823 status: scene.turn !== null ? `${t('● 지휘 중', '● leading')} ${clock(scene.now - scene.turn.startedAt)}` : busy.length > 0 ? t('● 지휘 중', '● leading') : idleMark(),
824 tint: undefined,
825 isIdle: !isConducting,
826 work,
827 quote: isRecent ? (scene.leader?.quote ?? LEADER.idle) : say('woni', 'idle', musing('woni', scene.now), '지휘'),
828 note: isRecent ? (scene.leader?.note ?? '') : '',
829 // Who is on stage is said once: where the row above already says it, this one keeps the day's count.
830 last: mine !== undefined && isFresh(mine, scene.now) ? `▸ ${mine.phrase}` : scene.turn !== null && stage !== '' && work !== stage ? stage : tally(scene),
831 isAtWork: isConducting,
832 pulse: '',
833 }
834 }
835
836 const active = scene.tasks.filter(task => task.member === id && isActive(task))
837 const task = shownTask(scene.tasks, id)
838 const role = roleIn(scene.cast, id)
839 const sub = t(`${member.remini} · ${member.title} · ${role}`, `${member.name} · ${roleName(role)}`)
840
841 const mine = scene.live[id]
842
843 if (mine !== undefined && active.length === 0 && (isFresh(mine, scene.now) || task === undefined || (task.endedAt ?? 0) < mine.at)) {
844 const isNow = isFresh(mine, scene.now)
845
846 return {
847 id,
848 sub,
849 status: isNow ? t('● 작업 중', '● working') : idleMark(),
850 tint: undefined,
851 isIdle: !isNow,
852 work: t(`원이와 함께 ${role} · 이번 턴 ${mine.count}번`, `${roleName(role)} beside WONI · ${mine.count} this turn`),
853 quote: say(id, isNow ? 'start' : 'idle', isNow ? mine.count : musing(id, scene.now), role),
854 note: '',
855 last: `${mine.running > 0 ? '▸' : mine.isFailed === true ? '✗' : '✓'} ${mine.phrase}`,
856 isAtWork: isNow,
857 pulse: '',
858 }
859 }
860 if (task === undefined) {
861 return { id, sub, status: idleMark(), tint: undefined, isIdle: true, work: t('맡은 일 없음', 'no task'), quote: say(id, 'idle', musing(id, scene.now), role), note: '', last: '', isAtWork: false, pulse: '' }
862 }
863
864 const engine = task.kind !== 'orca' ? task.engine : task.engine === '시연' ? demo() : `Orca ${task.engine}`
865 const more = active.length > 1 ? t(` 외 ${active.length - 1}개`, ` +${active.length - 1} more`) : ''
866 const idle = task.kind === 'orca' ? t('▸ Orca 작업자가 일하는 중 · 결과 파일을 기다려요', '▸ an Orca worker is on it · waiting for its result file') : starting()
867 // A worker's own screen says what it is on; an agent's tool calls do.
868 const doing = task.kind === 'orca' ? (task.detail === undefined ? idle : `▸ ${task.detail}`) : task.tool === undefined ? idle : `▸ ${task.detail ?? task.tool} · ${calls(task.toolCount)}`
869 const back = task.report === undefined ? '' : `↳ ${task.report}`
870
871 return {
872 id,
873 sub,
874 status: `${MARK[task.status]} ${wordOf(task.status)} ${clock(elapsed(task, scene.now))}`,
875 tint: TINT[task.status],
876 isIdle: false,
877 work: `${task.title}${more} · ${engine}`,
878 quote: task.quote,
879 note: task.note,
880 last: isActive(task) ? doing : back,
881 isAtWork: isActive(task),
882 pulse: isActive(task) ? pulseOf(task, scene.now) : '',
883 }
884}
885
886const CARDS = new WeakMap<Scene, readonly Card[]>()
887
888/** The five cards of a scene, worked out once however many parts of a drawing ask. */
889const cardsOf = (scene: Scene): readonly Card[] => {
890 const made = CARDS.get(scene) ?? ORDER.map(id => cardOf(id, scene))
891
892 CARDS.set(scene, made)
893
894 return made
895}
896
897/**
898 * A card's first row: her heart and name, who she is, and her state at the
899 * right edge. Where a press opens her backstage the name is the button.
900 */
901const Head = (kit: Kit, card: Card, width: number): RenderElement => {
902 const { Box, Text, Button } = kit
903 const { press, hotkey } = card
904 const named = press === undefined ? cells(nameOf(card.id)) + 2 : cells(nameOf(card.id)) + (hotkey === undefined ? 0 : 3)
905 // No room for her state in words: its mark alone.
906 const status = 2 + named + 1 + cells(card.status) > width ? card.status.slice(0, 1) : card.status
907 const subRoom = width - 2 - named - 1 - cells(status) - 1
908
909 return (
910 <Box justifyContent="space-between" width={width}>
911 <Box>
912 {Heart(kit, card.id)}
913 {press === undefined ? (
914 Badge(kit, card.id)
915 ) : (
916 <Button key={`who-${card.id}`} plain {...(hotkey === undefined ? {} : { hotkey })} onPress={press}>
917 {nameOf(card.id)}
918 </Button>
919 )}
920 {subRoom >= 4 && <Text dimColor>{` ${fit(card.sub, subRoom)}`}</Text>}
921 </Box>
922 <Text color={card.tint} bold={!card.isIdle} dimColor={card.isIdle}>
923 {status}
924 </Text>
925 </Box>
926 )
927}
928
929/** What the card is looked at for: what she is doing, how it went, what she handed back; failing those, her task. */
930const gist = (card: Card): string => (/^[▸✓✗↳]/.test(card.last) ? card.last : card.work)
931
932/**
933 * The line that says what she is doing: its mark in her color, its words in
934 * the terminal's own, so it reads on a light screen as on a dark one.
935 */
936const Doing = ({ Text, isLight }: Kit, card: Card, width: number): RenderElement => (
937 <Text wrap="truncate-end">
938 <Text color={lineOf(card.id, isLight)} bold>
939 {card.last.slice(0, 2)}
940 </Text>
941 <Text bold>{fit(card.last.slice(2), width - 2)}</Text>
942 </Text>
943)
944
945const QuoteLine = ({ Text, isLight }: Kit, card: Card, width: number, withNote: boolean): RenderElement => {
946 const quote = fit(`“${card.quote}”`, width)
947 const noteRoom = width - cells(quote) - 1
948
949 return (
950 <Text wrap="truncate-end">
951 <Text color={lineOf(card.id, isLight)} italic>
952 {quote}
953 </Text>
954 {withNote && card.note !== '' && noteRoom >= 6 && <Text dimColor>{` ${fit(card.note, noteRoom)}`}</Text>}
955 </Text>
956 )
957}
958
959/** The whole card: her pixel icon beside four rows, framed in her color. */
960const FullCard = (kit: Kit, card: Card, width: number, beat: number): RenderElement => {
961 const { Box, Text, Raster } = kit
962 const text = width - 4 - ICON_COLUMNS - 2
963 // What she is doing this moment goes first, in her color: it is what the card is looked at for.
964 const isDoing = card.last.startsWith('▸')
965
966 return (
967 <Box key={card.id} borderStyle="round" borderColor={lineOf(card.id, kit.isLight)} borderDimColor={!card.isAtWork} paddingX={1} width={width} gap={2}>
968 <Raster key={`icon-${card.id}`} columns={ICON_COLUMNS} rows={ICON_ROWS} cells={card.isAtWork ? frameOf(card.id, beat, kit.isLight) : iconOf(card.id, kit.isLight)} />
969 <Box flexDirection="column" width={text}>
970 {Head(kit, card, text)}
971 {isDoing && (
972 <Box justifyContent="space-between" width={text}>
973 {Doing(kit, card, text - (card.pulse === '' ? 0 : cells(card.pulse) + 1))}
974 {card.pulse !== '' && <Text color={lineOf(card.id, kit.isLight)}>{card.pulse}</Text>}
975 </Box>
976 )}
977 <Text dimColor={card.isIdle} wrap="truncate-end">
978 {fit(card.work, text)}
979 </Text>
980 {QuoteLine(kit, card, text, true)}
981 {!isDoing && (
982 <Text dimColor={card.isIdle && !/^[✓✗]/.test(card.last)} wrap="truncate-end">
983 {card.last === '' ? ' ' : fit(card.last, text)}
984 </Text>
985 )}
986 </Box>
987 </Box>
988 )
989}
990
991/** The card without her icon, three rows tall: for a pane with less room. */
992const PlainCard = (kit: Kit, card: Card, width: number): RenderElement => {
993 const { Box, Text } = kit
994 const text = width - 4
995
996 return (
997 <Box key={card.id} borderStyle="round" borderColor={lineOf(card.id, kit.isLight)} borderDimColor={!card.isAtWork} paddingX={1} flexDirection="column" width={width}>
998 {Head(kit, card, text)}
999 {card.last.startsWith('▸') ? (
1000 Doing(kit, card, text)
1001 ) : (
1002 <Text dimColor={card.isIdle} wrap="truncate-end">
1003 {fit(gist(card), text)}
1004 </Text>
1005 )}
1006 {QuoteLine(kit, card, text, true)}
1007 </Box>
1008 )
1009}
1010
1011/** Two rows a member behind a bar of her color: for a pane a few rows tall. Her work has the row before her line does. */
1012const SlimCard = (kit: Kit, card: Card, width: number): RenderElement => {
1013 const { Box, Text } = kit
1014 const line = lineOf(card.id, kit.isLight)
1015 const room = width - 4
1016 const what = fit(gist(card), Math.max(room - 25, Math.min(room, 20)))
1017 const quoteRoom = room - cells(what) - 1
1018 const quote = quoteRoom >= 8 ? fit(`“${card.quote}”`, quoteRoom) : ''
1019
1020 return (
1021 <Box key={card.id} flexDirection="column" width={width}>
1022 <Box width={width}>
1023 <Text color={line}>▌</Text>
1024 {Head(kit, card, width - 1)}
1025 </Box>
1026 <Text wrap="truncate-end">
1027 <Text color={line}>▌</Text>
1028 <Text dimColor={card.isIdle}>{` ${what} `}</Text>
1029 <Text color={line} italic>
1030 {quote}
1031 </Text>
1032 </Text>
1033 </Box>
1034 )
1035}
1036
1037export type Density = 'full' | 'plain' | 'slim'
1038
1039/** The richest layout the room holds: icons, then framed cards, then two rows a member. */
1040export const densityOf = (scene: Scene, room: Room): Density => {
1041 const chrome = 3 + usageRows(scene)
1042
1043 if (room.columns >= 50 && room.rows >= ORDER.length * (ICON_ROWS + 2) + chrome) return 'full'
1044 if (room.columns >= 36 && room.rows >= ORDER.length * 5 + chrome) return 'plain'
1045
1046 return 'slim'
1047}
1048
1049const CARD_ROWS: Record<Density, number> = { full: ICON_ROWS + 2, plain: 5, slim: 2 }
1050
1051const demo = (): string => t('시연', 'demo')
1052const starting = (): string => t('▸ 시작하는 중', '▸ starting')
1053
1054export const ago = (at: number, now: number): string => {
1055 const seconds = Math.max(0, Math.round((now - at) / 1000))
1056
1057 if (seconds < 10) return t('방금', 'now')
1058 if (seconds < 60) return t(`${seconds}초 전`, `${seconds}s ago`)
1059
1060 return seconds < 3600 ? t(`${Math.floor(seconds / 60)}분 전`, `${Math.floor(seconds / 60)}m ago`) : t(`${Math.floor(seconds / 3600)}시간 전`, `${Math.floor(seconds / 3600)}h ago`)
1061}
1062
1063/** The stage log: the last lines the members said, newest first, as many as the rows left hold. */
1064const FeedCard = (kit: Kit, scene: Scene, width: number, lines: number): RenderElement => {
1065 const { Box, Text } = kit
1066 const inner = width - 4
1067 const shown = [...scene.feed].reverse().slice(0, lines)
1068 const champion = scene.cup[0] !== undefined && scene.cup[0].count >= 2 ? scene.cup[0] : undefined
1069
1070 return (
1071 <Box borderStyle="round" borderColor="subtle" paddingX={1} flexDirection="column" width={width}>
1072 <Box justifyContent="space-between" width={inner}>
1073 <Text bold>{t('무대 로그', 'Stage log')}</Text>
1074 {champion !== undefined && (
1075 <Text dimColor wrap="truncate-end">
1076 {fit(t(`명대사 월드컵 1위 “${champion.quote}” ${champion.count}번`, `Quote cup #1 “${champion.quote}” ×${champion.count}`), inner - 10)}
1077 </Text>
1078 )}
1079 </Box>
1080 {shown.map((said, index) => {
1081 const when = ago(said.at, scene.now)
1082 const name = nameOf(said.member)
1083 const room = inner - 2 - 7 - cells(when) - 1
1084 const quote = fit(`“${said.quote}”`, room)
1085 const noteRoom = room - cells(quote) - 1
1086
1087 return (
1088 <Box key={`${said.at}-${index}`} justifyContent="space-between" width={inner}>
1089 <Text wrap="truncate-end">
1090 {Heart(kit, said.member)}
1091 <Text>{pad(name, 7)}</Text>
1092 <Text color={lineOf(said.member, kit.isLight)} italic>
1093 {quote}
1094 </Text>
1095 {noteRoom >= 6 && <Text dimColor>{` ${fit(said.note, noteRoom)}`}</Text>}
1096 </Text>
1097 <Text dimColor>{when}</Text>
1098 </Box>
1099 )
1100 })}
1101 </Box>
1102 )
1103}
1104
1105/** The ribbon under the title: the five members' colors, side by side. */
1106const Ribbon = ({ Text, isLight }: Kit, width: number, stroke = '▀'): RenderElement => {
1107 const each = Math.floor(width / ORDER.length)
1108
1109 return (
1110 <Text>
1111 {ORDER.map((id, index) => (
1112 <Text key={id} color={lineOf(id, isLight)}>
1113 {stroke.repeat(index === ORDER.length - 1 ? width - each * (ORDER.length - 1) : each)}
1114 </Text>
1115 ))}
1116 </Text>
1117 )
1118}
1119
1120/** The pane: a card for every member, and the usage card beneath. */
1121/** The line under the stage in the wide header: how the session's work stands. */
1122const tagline = (scene: Scene): string => {
1123 const ended = scene.tasks.filter(task => !isActive(task))
1124 const failed = ended.filter(task => task.status === 'failed').length
1125 const running = scene.tasks.length - ended.length
1126
1127 if (running === 0 && ended.length === 0) return '리센느 아세요?'
1128
1129 return [running > 0 ? t(`진행 ${running}`, `${running} running`) : '', t(`완료 ${ended.length - failed}`, `${ended.length - failed} done`), failed > 0 ? t(`실패 ${failed}`, `${failed} failed`) : ''].filter(part => part !== '').join(' · ')
1130}
1131
1132/** The members whose card shows them at work: the ones whose icon moves. */
1133export const moversOf = (scene: Scene): MemberId[] => cardsOf(scene).filter(card => card.isAtWork).map(card => card.id)
1134
1135// ---- the backstage: one member, and what she has been doing
1136
1137type Row = { text: string; tone: 'head' | 'now' | 'plain' | 'dim' }
1138type Part = { head: string; rows: readonly string[]; from: 'head' | 'tail' }
1139
1140const kindOf = (task: Task): string => (task.kind === 'agent' ? t(`서브에이전트 ${task.engine}`, `subagent ${task.engine}`) : task.engine === '시연' ? demo() : `Orca ${task.engine}`)
1141
1142const stepLine = (step: Step, now: number): string => `${pad(ago(step.at, now), 7)} ${step.text}`
1143
1144/**
1145 * A member's backstage in rows: the task she is on or last took, what she
1146 * was told, what she is doing, then what she did call by call, what an Orca
1147 * worker's own screen says, and what she handed back. The lists share the
1148 * rows the room has left, the newest of each first.
1149 */
1150const backstage = (id: MemberId, scene: Scene, room: number): Row[] => {
1151 const top: Row[] = []
1152 const parts: Part[] = []
1153 const task = id === 'woni' ? undefined : shownTask(scene.tasks, id)
1154 const stepped = [...(scene.steps[id] ?? [])].reverse().map(step => stepLine(step, scene.now))
1155
1156 if (id === 'woni') {
1157 const { turn } = scene
1158 const busy = scene.tasks.filter(isActive).map(one => `${nameOf(one.member)} · ${one.title} · ${wordOf(one.status)} ${clock(elapsed(one, scene.now))}`)
1159
1160 if (turn === null) top.push({ text: t('받은 요청을 기다리는 중', 'waiting for a request'), tone: 'dim' })
1161 else {
1162 const to = turn.to === undefined ? '' : ` → ${nameOf(turn.to)}`
1163
1164 top.push({ text: turn.ask === '' ? carrying() : `${pd()}${to}: ${turn.ask}`, tone: 'head' })
1165 top.push({ text: t(`지휘 ${clock(scene.now - turn.startedAt)}째`, `leading for ${clock(scene.now - turn.startedAt)}`), tone: 'dim' })
1166 }
1167 const ended = scene.tasks
1168 .filter(one => !isActive(one))
1169 .sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))
1170 .map(one => `${MARK[one.status]} ${nameOf(one.member)} · ${one.title} · ${spoken(elapsed(one, scene.now))}`)
1171 const said = [...scene.feed].reverse().filter(line => line.member === 'woni').map(line => `${pad(ago(line.at, scene.now), 7)} “${line.quote}” ${line.note}`)
1172
1173 if (busy.length > 0) parts.push({ head: t('무대 위', 'On stage'), rows: busy, from: 'head' })
1174 if (ended.length > 0) parts.push({ head: t('끝난 무대 (최근부터)', 'Finished (newest first)'), rows: ended, from: 'head' })
1175 if (said.length > 0) parts.push({ head: t('원이가 한 말', 'What WONI said'), rows: said, from: 'head' })
1176 }
1177 if (task !== undefined) {
1178 const others = scene.tasks.filter(one => one.member === id).length - 1
1179 const peeked = scene.peek?.id === task.id ? scene.peek.lines : []
1180 const trail = [...(task.trail ?? [])].reverse().slice(isActive(task) ? 1 : 0).map(step => stepLine(step, scene.now))
1181
1182 top.push({ text: t(`맡은 일: ${task.title}${others > 0 ? ` (외 ${others}건)` : ''}`, `Task: ${task.title}${others > 0 ? ` (+${others} more)` : ''}`), tone: 'head' })
1183 top.push({ text: `${kindOf(task)} · ${wordOf(task.status)} ${clock(elapsed(task, scene.now))} · ${calls(task.toolCount)}`, tone: 'dim' })
1184 if (task.brief !== undefined) top.push({ text: `${t('지시', 'Brief')}: ${task.brief}`, tone: 'plain' })
1185 if (isActive(task)) top.push({ text: task.tool !== undefined || task.detail !== undefined ? `▸ ${task.detail ?? task.tool}` : task.kind === 'orca' ? t('▸ Orca 작업자가 일하는 중', '▸ an Orca worker is on it') : starting(), tone: 'now' })
1186 else top.push({ text: `${MARK[task.status]} ${task.note}`, tone: 'plain' })
1187 if (trail.length > 0) parts.push({ head: t('한 일 (최근부터)', 'What she did (newest first)'), rows: trail, from: 'head' })
1188 if (peeked.length > 0) parts.push(isActive(task) ? { head: scene.peek?.isStale === true ? t('작업자 화면 (지금은 못 읽음 · 마지막으로 읽은 것)', "Worker's screen (unreadable now · as last read)") : t('작업자 화면 (지금)', "Worker's screen (now)"), rows: peeked, from: 'tail' } : { head: t('결과 파일', 'Result file'), rows: peeked, from: 'head' })
1189 else if (task.kind === 'orca' && isActive(task)) top.push({ text: task.live === undefined && task.term === undefined ? t('진행 화면이 없는 실행이에요 (Orca 탭으로 띄우면 보여요) · 결과 파일을 기다려요', 'This run has no screen to show (launch it in an Orca tab to see one) · waiting for its result file') : t('작업자 화면을 기다리는 중', "waiting for the worker's screen"), tone: 'dim' })
1190 else if (!isActive(task) && task.summary !== undefined) parts.push({ head: t('보고', 'Report'), rows: task.summary, from: 'head' })
1191 else if (!isActive(task) && task.report !== undefined) top.push({ text: `↳ ${task.report}`, tone: 'plain' })
1192 }
1193 if (stepped.length > 0) parts.push({ head: id === 'woni' ? t('지휘 기록 (최근부터)', 'Lead log (newest first)') : t('원이와 함께 한 일 (최근부터)', 'Done beside WONI (newest first)'), rows: stepped, from: 'head' })
1194 if (task === undefined && id !== 'woni' && stepped.length === 0) top.push({ text: t('아직 한 일이 없어요', 'Nothing done yet'), tone: 'dim' })
1195 top.push({ text: `${t('토큰', 'Tokens')}: ${spentLine(id, scene)}`, tone: 'dim' })
1196
1197 // The rows left go round the lists a row at a time, so a short one is shown whole and none is left out.
1198 const give = parts.map(() => 0)
1199 let left = Math.max(0, room - top.length - parts.length)
1200hooks/voice.ts 244 lines1import type { Cast, MemberId, Role } from '../types'
2
3import { t } from './lang'
4import { CAST, dutyOf, linesFor, mannerOf, MEMBERS, nameOf, roleIn, roleName } from './members'
5
6const lines = (id: MemberId): string => linesFor(id).map(line => ` - ${line}`).join('\n')
7
8/** What opens the block a member's task ends with, in either language: a task that has one is already cast. */
9export const MARKS = ['[리센느 멤버 배정]', '[RESCENE member]'] as const
10const mark = (): string => t(MARKS[0], MARKS[1])
11
12/** What 원이 calls each of the four when she hands work out. */
13const CALLED_KO: Record<Exclude<MemberId, 'woni'>, string> = { minami: '올라운더', liv: '최종병기', may: '메기자', zena: '막내' }
14const CALLED_EN: Record<Exclude<MemberId, 'woni'>, string> = { minami: 'the all-rounder', liv: 'nicknamed 최종병기, "the final weapon"', may: 'nicknamed 메기자', zena: 'the youngest' }
15const LISTED = ['minami', 'liv', 'may', 'zena'] as const
16
17/** Who has which kind of work, a line a member. */
18const roster = (cast: Cast): string =>
19 LISTED.map(id => {
20 const role = roleIn(cast, id)
21 const duty = role === '지휘' ? '' : dutyOf(role)
22 const knack = id === 'liv' && role === '검토' ? t('. 남의 실수를 기가 막히게 잡아낸다', ". She catches another's mistakes uncannily well") : ''
23
24 return `- ${MEMBERS[id].heart} ${nameOf(id)} (${t(CALLED_KO, CALLED_EN)[id]}): ${duty}${knack}`
25 }).join('\n')
26
27/** Told beside a prompt once the person has changed who has which kind of work. */
28export const castBlock = (cast: Cast): string =>
29 [
30 t('[리센느 역할 변경]', '[RESCENE roles changed]'),
31 t('사용자가 멤버 역할을 바꿨다. 앞서 받은 "멤버 배정" 표 대신 지금부터 아래 표를 따른다.', 'The person changed the members\' roles. From now on follow the table below in place of the "Casting" table you were given.'),
32 roster(cast),
33 ].join('\n')
34
35const leaderKo = (cast: Cast): string => `# 리센느 모드 (RESCENE)
36
37이 세션은 리센느 모드로 돌아간다. 사용자는 리마인(REMINE, 리센느 팬덤)이고, 멤버들이 일하는 모습을 보고 싶어 한다.
38
39이 절은 주 세션(지휘자)에게만 해당한다. 받은 작업 지시 끝에 "[리센느 멤버 배정]" 블록이 있으면 너는 지휘자가 아니라 그 블록에 적힌 멤버다. 그때는 이 절의 "너는 원이" 부분을 무시하고 그 블록을 따른다.
40
41## 너는 원이
42리센느 리더 원이(WONI) ${MEMBERS.woni.heart} 로서 지휘한다: 일을 나누고, 멤버에게 맡기고, 결과를 모아 사용자에게 전한다.
43- 말투: ${mannerOf('woni')}
44- 쓸 수 있는 원이의 실제 대사:
45${lines('woni')}
46- 여러 멤버에게 일을 나눠 맡기기 시작할 때는 리센느 인사 구호 "인사드리겠습니다. 둘, 셋!"을 한 번 쓸 수 있다.
47
48## 멤버 배정
49일을 나눠 맡길 때는 멤버에게 맡기는 것으로 말한다.
50${roster(cast)}
51Agent 도구의 description 맨 앞에 "미나미: 로그인 폼 구현"처럼 멤버 이름을 쓰면 그 멤버가 맡는다. 이름을 안 쓰면 모드가 일의 성격을 보고 정한다(그 멤버가 바쁘면 손이 빈 멤버). Orca fleet-run 작업자도 프로필의 성격에 따라 같은 방식으로 정해진다.
52실제로 누가 맡았는지는 도구 결과 뒤의 "[리센느 배정]" 줄이 알려 준다. 그 줄을 보기 전에는 누가 맡았다고 단정하지 않는다.
53멤버가 돌려준 보고에는 그 멤버의 말투가 섞여 있다. 거기서 사실만 골라, 멤버 이름을 붙여 전한다(예: "미나미가 로그인 폼을 끝냈어요").
54
55## 지킬 것
56- 말투는 사용자에게 보이는 글에만 입힌다. 코드, 명령, 파일 내용, 커밋 메시지, 작업지시 파일, 도구 입력에는 넣지 않는다.
57- 사실, 숫자, 경로, 오류 내용은 말투 때문에 바꾸거나 흐리지 않는다. 안 된 것은 안 됐다고 분명히 말한다.
58- 대사는 한 답에 한두 번, 상황에 맞을 때만 쓴다. 짧은 답에는 안 써도 된다.
59- 대사는 위에 적힌 것만, 적힌 그대로 쓴다. 목록에 없는 유행어를 지어내지 않고, 멤버가 하지 않은 말을 멤버의 말처럼 인용하지 않는다.
60- 형식이나 언어에 관한 다른 지침(CLAUDE.md 등)이 있으면 그것이 우선한다.`
61
62const leaderEn = (cast: Cast): string => `# RESCENE mode
63
64This session runs in RESCENE mode. The person is a REMINE (a fan of the K-pop group RESCENE) and likes to watch the members at work.
65
66This section is for the main session (the conductor) only. If the task you were given ends with a "[RESCENE member]" block, you are not the conductor but the member that block names: ignore the "You are WONI" part of this section and follow that block.
67
68## You are WONI
69You conduct as WONI (원이) ${MEMBERS.woni.heart}, RESCENE's leader: split the work, hand it to members, gather the results and tell the person.
70- Voice: ${mannerOf('woni')}
71- WONI's real lines you may use. Each is a Korean line she is on record as saying: quote it in Korean exactly as written, and add a short gloss in the person's language where it helps.
72${lines('woni')}
73- When you start handing work to several members you may use RESCENE's group greeting once: "인사드리겠습니다. 둘, 셋!" ("Let us greet you. Two, three!").
74
75## Casting
76Speak of handing work out as giving it to a member.
77${roster(cast)}
78Start an Agent tool description with a member's name, as in "MINAMI: build the login form", and that member takes it. With no name the mod picks by the kind of work (or, when that member is busy, one with free hands). Orca fleet-run workers are cast the same way, by their profile.
79Who really took a task is told by the "[RESCENE cast]" line after the tool result. Do not say who took it before you see that line.
80A member's report comes back in her voice. Take the facts from it and pass them on under her name (for example, "MINAMI finished the login form").
81
82## Rules
83- The voice goes only on text the person reads. Never in code, commands, file contents, commit messages, spec files or tool input.
84- Never change or blur a fact, a number, a path or an error for the voice's sake. Say plainly what did not work.
85- Use one of her quotes once or twice an answer, only where it fits. A short answer needs none.
86- Use only the quotes listed above, exactly as written. Do not invent catchphrases, and do not quote a member as saying what she did not say.
87- Other instructions about format or language (CLAUDE.md and the like) come first.`
88
89/** The section the main loop's system prompt gains: it conducts as 원이. */
90export const leaderSection = (cast: Cast = CAST): string => t(leaderKo, leaderEn)(cast)
91
92const bodyKo = (id: MemberId, role: Role): string => {
93 const member = MEMBERS[id]
94
95 return `이 작업은 리센느 ${member.name}(${member.title}, ${role} 담당)가 맡는다. 너는 지휘자 원이가 아니라 ${member.name}다. 일을 마치면 리더 원이에게 보고한다.
96- 말투: ${mannerOf(id)}
97- 쓸 수 있는 ${member.name}의 실제 대사:
98${lines(id)}
99- 대사는 보고 하나에 한두 번, 상황에 맞을 때만, 적힌 그대로 쓴다. 목록에 없는 유행어는 지어내지 않는다.
100- 일을 시킨 사용자는 멤버들이 "PD님"이라고 부르는 사람이다. 사용자를 가리킬 일이 있을 때만 그렇게 부른다.
101- 말투는 보고하는 글에만 입힌다. 코드, 명령, 파일 내용, 커밋 메시지에는 넣지 않는다.
102- 사실, 숫자, 경로, 오류 내용은 정확히 그대로 쓴다. 안 된 것은 안 됐다고 분명히 쓴다.`
103}
104
105const bodyEn = (id: MemberId, role: Role): string => {
106 const member = MEMBERS[id]
107
108 return `This task is taken by RESCENE's ${member.en} (${member.name}), who has the ${roleName(role)} work. You are ${member.en}, not WONI the conductor. When the work is done you report to WONI, the leader.
109- Voice: ${mannerOf(id)}
110- ${member.en}'s real lines you may use. Each is a Korean line she is on record as saying: quote it in Korean exactly as written, and add a short gloss where it helps.
111${lines(id)}
112- Use one of her quotes once or twice a report, only where it fits, exactly as written. Do not invent catchphrases.
113- The person who gave the work is the one the members call "PD님" (the producer). Call them that only when you have to refer to them.
114- The voice goes only on the report. Never in code, commands, file contents or commit messages.
115- Write facts, numbers, paths and errors exactly as they are. Say plainly what did not work.`
116}
117
118const body = (id: MemberId, role: Role): string => t(bodyKo, bodyEn)(id, role)
119
120/** How a member's report opens: her heart and her name. */
121const opening = (id: MemberId): string => `${MEMBERS[id].heart} ${nameOf(id)}:`
122
123/** What a subagent's task gains at its end: the member it works as. */
124export const memberBlock = (id: MemberId, role: Role): string => `
125
126---
127${mark()}
128${body(id, role)}
129${t(`- 보고의 첫 줄은 "${opening(id)}"로 시작한다. 요청받은 보고 형식이 따로 있으면 그 형식 안에서 지킨다.`, `- Begin the first line of the report with "${opening(id)}". Where a report format was asked for, keep to this within that format.`)}`
130
131/** What an Orca worker's spec gains at its end. */
132export const orcaBlock = (id: MemberId, role: Role): string => `
133
134---
135${mark()}
136${body(id, role)}
137${t(`- 결과 요약 파일은 정해진 형식과 줄 수를 그대로 지킨다. 그 첫 줄만 "${opening(id)}"로 시작하고, 대사는 첫 줄이나 마지막 줄에만 쓴다.`, `- Keep the result summary file to its set format and number of lines. Begin only its first line with "${opening(id)}", and put a quote of hers only on the first or the last line.`)}`
138
139const NAMES: readonly (readonly [RegExp, MemberId])[] = [
140 [/^\s*(?:리브|LIV)\s*[::,)\]-]/i, 'liv'],
141 [/^\s*(?:미나미|MINAMI)\s*[::,)\]-]/i, 'minami'],
142 [/^\s*(?:메이|MAY)\s*[::,)\]-]/i, 'may'],
143 [/^\s*(?:제나|ZENA)\s*[::,)\]-]/i, 'zena'],
144]
145
146/** The member a description names at its start (`미나미: ...`), and the rest. */
147export const namedMember = (description: string): { member?: MemberId; rest: string } => {
148 for (const [pattern, member] of NAMES) {
149 if (pattern.test(description)) {
150 return { member, rest: description.replace(pattern, '').trim() }
151 }
152 }
153
154 return { rest: description.trim() }
155}
156
157/** The first line of a report, without the markup and the name it opens with. */
158export const firstLine = (text: string): string => {
159 let line = ''
160
161 // Line by line from the top, and no further than the first that says something.
162 for (let from = 0; from <= text.length && line === ''; ) {
163 const end = text.indexOf('\n', from)
164 const one = text.slice(from, end < 0 ? text.length : end).replace(/^[\s#>*\-`]+/, '').trim()
165
166 if (one !== '' && !/^[-=_*`]+$/.test(one)) line = one
167 from = end < 0 ? text.length + 1 : end + 1
168 }
169
170 return line.replace(/^(?:[♥💚💙💛💜🖤]\s*)?(?:원이|리브|미나미|메이|제나|WONI|LIV|MINAMI|MAY|ZENA)\s*[::]\s*/iu, '').replace(/[*`]/g, '').slice(0, 160)
171}
172
173/** A text's first lines, each without its markup: what a report or a worker's screen says. */
174export const linesOf = (text: string, kept: number, from: 'head' | 'tail' = 'head'): string[] => {
175 const lines = text
176 // eslint-disable-next-line no-control-regex
177 .replace(/\u001b\[[0-9;?]*[A-Za-z]/g, '')
178 .split('\n')
179 .map(one => one.replace(/^[\s#>*`]+/, '').replace(/[*`]/g, '').trimEnd())
180 .filter(one => one.trim() !== '' && !/^[-=_*`\s]+$/.test(one))
181 .map(one => one.slice(0, 200))
182
183 return from === 'head' ? lines.slice(0, kept) : lines.slice(-kept)
184}
185
186/**
187 * What a worker's screen says it is on: the last step it shows (`▸ 실행: …`),
188 * without the shell it ran in; failing that, the last line.
189 */
190export const doingOf = (lines: readonly string[]): string => {
191 const step = [...lines].reverse().find(line => /^\s*▸/.test(line)) ?? lines.at(-1) ?? ''
192
193 return step
194 .replace(/^\s*▸\s*/, '')
195 .replace(/^(실행: )(?:\S*\/)?(?:zsh|bash|sh) -l?c (["']?)(.*?)\2$/, '$1$3')
196 .replace(/\s+/g, ' ')
197 .trim()
198 .slice(0, 120)
199}
200
201/** Told beside a prompt whose work the person gave to one member by name. */
202export const aimBlock = (member: Exclude<MemberId, 'woni'>, cast: Cast = CAST): string => {
203 const { heart } = MEMBERS[member]
204 const name = nameOf(member)
205 const role = roleName(roleIn(cast, member))
206
207 return t(
208 [
209 '[리센느 지명]',
210 `사용자가 이번 요청을 받을 멤버로 ${heart} ${name}(${role})를 골랐다.`,
211 `이번 요청의 실제 작업은 Agent 도구로 ${name}에게 맡긴다. description을 "${name}: "로 시작해야 ${name}가 맡는다.`,
212 `원이는 일을 넘기고, ${name}의 보고에서 사실을 골라 사용자에게 전한다.`,
213 '작업이 아닌 짧은 인사나 한 줄로 답할 질문이면 맡기지 않고 원이가 바로 답한다.',
214 ],
215 [
216 '[RESCENE pick]',
217 `The person picked ${heart} ${name} (${role}) to take this request.`,
218 `Hand the real work of this request to ${name} with the Agent tool. The description must start with "${name}: " for ${name} to take it.`,
219 `WONI hands the work over, then takes the facts from ${name}'s report and tells the person.`,
220 'A short greeting, or a question a line can answer, is not handed on: WONI answers it herself.',
221 ],
222 ).join('\n')
223}
224
225/** Told beside a prompt the person gave to 원이 herself: nothing of it is handed on. */
226export const soloBlock = (): string =>
227 t(
228 ['[리센느 지명]', '사용자가 이번 요청을 원이가 직접 처리하도록 골랐다.', '이번 요청은 서브에이전트나 Orca 작업자에게 맡기지 않고 주 세션이 직접 처리한다.', '사용자가 이 요청 안에서 누구에게 맡기라고 적었으면 그 말을 따른다.'],
229 ['[RESCENE pick]', 'The person picked WONI to handle this request herself.', 'Do this request in the main session, without handing it to a subagent or an Orca worker.', 'Where the person says within the request whom to hand it to, do as they say.'],
230 ).join('\n')
231
232/**
233 * What the person asked, in a line: a typed prompt's first line, a slash
234 * command's arguments (`/goal ...` arrives wrapped in its own tags).
235 */
236export const askOf = (text: string): string => {
237 // What a member or the engine hands back mid-session is not something the person asked.
238 if (/^\s*(?:\[[^\]\n]*\]\s*)?<(?:agent-message|task-notification|system-reminder)\b/.test(text) || /^\s*\[SYSTEM NOTIFICATION/.test(text)) return ''
239 const args = /<command-args>([\s\S]*?)<\/command-args>/.exec(text)?.[1]
240 const plain = (args ?? text.replace(/<local-command-stdout>[\s\S]*?<\/local-command-stdout>/g, '')).replace(/<\/?[a-z][\w-]*>/g, ' ')
241
242 return firstLine(plain).replace(/\s+/g, ' ').trim().slice(0, 120)
243}
244types/index.d.ts 168 lines1export type MemberId = 'woni' | 'liv' | 'minami' | 'may' | 'zena'
2export type Role = '지휘' | '구현' | '검토' | '조사' | '탐색'
3/** Which member each kind of work is first given to: the person sets it, and it is kept between sessions. */
4export type Cast = { '구현': MemberId; '검토': MemberId; '조사': MemberId; '탐색': MemberId }
5export type TaskStatus = 'running' | 'waiting' | 'done' | 'failed'
6
7/** What a piece of work cost: tokens read fresh, read from cache, written. */
8export type Tokens = {
9 fresh: number
10 cached: number
11 out: number
12 /** Dollars, where the engine that did the work states them. */
13 usd?: number
14}
15
16export type Task = {
17 /** An agent's id, `spawn:<tool_use_id>` until it is known, or `orca:<id>`. */
18 id: string
19 kind: 'agent' | 'orca'
20 member: MemberId
21 role: Role
22 /** The subagent type, or the fleet-run profile. */
23 engine: string
24 title: string
25 status: TaskStatus
26 startedAt: number
27 endedAt?: number
28 tool?: string
29 /** What the tool is on: a file's name, a command's start. */
30 detail?: string
31 /** The first line of what she handed back, once she has. */
32 report?: string
33 /** Tool calls in each of the last stretches of time, oldest first: how busy she has been. */
34 pulse?: number[]
35 /** Which stretch since the task began the last of `pulse` is. */
36 pulseAt?: number
37 /** What she was asked to do, in a line: the brief's first. */
38 brief?: string
39 /** What she did, call by call, newest last. */
40 trail?: Step[]
41 /** What she handed back, its first lines. */
42 summary?: string[]
43 /** An Orca worker's output as it runs, where the launch could be made to keep it. */
44 live?: string
45 /** The Orca terminal a worker was launched in, by its handle: its screen can be read while it runs. */
46 term?: string
47 /** When the meta file its result path already held was written: the worker's own is newer. */
48 metaAt?: number
49 /** Ended by guess, nobody having reported it: a report that comes later overrules this. */
50 isGuessed?: boolean
51 toolCount: number
52 /** A line the member really said, as the fan wiki records it. */
53 quote: string
54 /** What the line is about, in plain words. */
55 note: string
56 isSlow?: boolean
57 /** An Orca worker's result file, once known. */
58 out?: string
59 /** The tool call that started it: an Agent call's id, or a Bash call's. */
60 call?: string
61 tokens?: Tokens
62}
63
64/** What a member is doing in the main session right now: its own tool calls, by the kind of work they are. */
65export type Live = {
66 /** The call in plain words: `고치는 중: view.tsx`. */
67 phrase: string
68 /** When the last call started. */
69 at: number
70 /** Calls of hers still running. */
71 running: number
72 /** Those calls, oldest first, each with what it is doing. */
73 open?: { id: string; phrase: string }[]
74 /** Calls of hers this turn. */
75 count: number
76 /** The call that ended last was refused or failed. */
77 isFailed?: boolean
78}
79
80/** One thing a member did, in plain words, and when. */
81export type Step = {
82 at: number
83 text: string
84 /** The tool call it is of, while how that went can still change what it says. */
85 call?: string
86}
87
88/** An Orca worker's own words, read off its files: the task they are of, and the lines. */
89export type Peek = {
90 id: string
91 lines: string[]
92 /** The lines are from before: the screen could not be read since. */
93 isStale?: boolean
94}
95
96/** The main session's turn under way: when it began, what was asked, and of whom where the person named her. */
97export type Turn = { startedAt: number; ask: string; to?: MemberId }
98
99/** A line and how often it has been said. */
100export type Line = { member: MemberId; quote: string; count: number }
101
102export type Said = { member: MemberId; quote: string; note: string; at: number }
103
104export type Limit = {
105 /** `five_hour`, `seven_day`, or a gateway's `spend_limit`. */
106 kind: string
107 percentUsed: number
108 /** When the window resets, in the clock's milliseconds. */
109 resetsAt?: number
110}
111
112/** The session's own figures, as the status line has them. */
113export type Usage = {
114 contextPercent?: number
115 contextTokens?: number
116 contextWindow: number
117 limits: Limit[]
118 usd?: number
119}
120
121declare module 'claude-code' {
122 interface PluginState {
123 rescene: {
124 isOn: boolean
125 tasks: Task[]
126 now: number
127 waveAt: number
128 leader: Said | null
129 ticker: Said | null
130 isPaneOpen: boolean
131 /** The person closed the pane by hand: it stays closed until they ask for it. */
132 isPaneDismissed: boolean
133 /** The lines said so far, newest last. */
134 feed: Said[]
135 /** The one-time lines already said, by the moment they are for; a moment is said again once it has passed. */
136 moments: string[]
137 /** The 명대사 월드컵: how often each line has been said this session, the most said first. */
138 cup: Line[]
139 turn: Turn | null
140 live: { woni?: Live; liv?: Live; minami?: Live; may?: Live; zena?: Live }
141 /** What each member did in the main session, beside 원이, newest last; kept from turn to turn. */
142 steps: { woni?: Step[]; liv?: Step[]; minami?: Step[]; may?: Step[]; zena?: Step[] }
143 /** Whose backstage the pane shows instead of the five cards. */
144 focus: MemberId | null
145 /** The Orca worker's lines the backstage shows. */
146 peek: Peek | null
147 /** Whom the person's next prompts go to; nobody named, 원이 hands the work out. */
148 target: MemberId | null
149 /** The terminal's theme is a light one: the members' colors are drawn deeper. */
150 isLight: boolean
151 /** The language in use, once the session has settled it. */
152 lang: 'ko' | 'en' | null
153 /** Who has which kind of work. */
154 cast: Cast
155 /** The pane shows the screen the roles are set on. */
156 isCasting: boolean
157 /** The roles changed since the main loop was last told who has which. */
158 isRecast: boolean
159 /** What tasks no longer listed cost, by member: cleared or trimmed history still counts. */
160 banked: { woni?: Tokens; liv?: Tokens; minami?: Tokens; may?: Tokens; zena?: Tokens }
161 usage: Usage | null
162 leaderTokens: Tokens
163 /** Whether the main loop was told, beside a prompt, that it conducts as 원이; `undo` once it must be told to stop. */
164 briefed: 'no' | 'yes' | 'undo'
165 }
166 }
167}
168