SLOPSHOPPER

rescene

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. 리센느 팬 모드: 원이가…

newpanebandspinnerguardcommand
★ 2v0.9.1MITupdated 2026-10-07techkwon/rescene
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · rescene
│ ┃ RESCENE ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ RESCENE Green room │ rescene │ │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Read(src/auth.ts) │ 💚 WONI “안녕하세요, 원이입니다. 잘 부탁드립니다.” a new │ │ ┃ ▌♥ 1: WONI 원이 · leader ⎿ Read 6 lines │ session │ │ ┃ ▌ waiting for work “안녕하세요, 원이입니다. ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ ▌♥ 2: LIV 리브 · review ⎿ Added 2 lines, removed 1 line │ ┃ ▌ ✗ Failed: Run the test sui… “정수리 옮길 ⏺ Bash(bun test) │ ┃ ▌♥ 3: MINAMI 미나미 · build ⎿ 3 pass, 1 fail │ ┃ ▌ ✓ Ran: Clean build output … “쿄 아손데콩! │ ┃ ▌♥ 4: MAY 메이 · research ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ▌ no task “반겨주는 역할이거든요... 강아지마 │ ┃ ▌♥ 5: ZENA 제나 · scout ♥ WONI “오이쉬!” 42s │ ┃ ▌ ✓ Ran: Show environment fi… “나는, 신라.. │ ┃ battery 51% · 5h 31% › /rescene │ ┃ r: Roles · press a name for her backstage (1 ⎿ rescene: “이봐협에서 나왔습니다.” The RESCENE members panel is open. │ ⎿ rescene: 💚 WONI (lead) ○ idle │ ⎿ rescene: ♥ LIV (review) ● working · review beside WONI · 1 this │ ⎿ rescene: 💙 MINAMI (build) ● working · build beside WONI · 4 this │ ⎿ rescene: 💛 MAY (research) ○ idle │ ⎿ rescene: 💜 ZENA (scout) ● working · scout beside WONI · 4 this t │ │ ✻ 💚 WONI directing traffic… ▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂ RESCENE [ auto ] ♥ WONI lead ♥ LIV review ♥ MINAMI build ♥ MAY research ♥ ZENA scout REMINE♥ Usage battery ▰▰▰▰▰▱▱▱▱▱ 51% left 5h ▰▰▰▱▱▱▱▱▱▱ 31% used $0.42 Roles ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂ RESCENE [ auto ] ♥ WONI lead ♥ LIV review ♥ MINAMI build ♥ MAY research ♥ ZENA scout REMINE♥ Usage battery ▰▰▰▰▰▱▱▱▱▱ 51% left 5h ▰▰▰▱▱▱▱▱▱▱ 31% used $0.42 Roles
Pane · RESCENE
RESCENE Green room REMINE ♥ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▌♥ 1: WONI 원이 · leader ○ idle ▌ waiting for work “안녕하세요, 원이입니다. 잘 부탁드… ▌♥ 2: LIV 리브 · review ● working ▌ ✗ Failed: Run the test sui… “정수리 옮길 거예요.” ▌♥ 3: MINAMI 미나미 · build ● working ▌ ✓ Ran: Clean build output … “쿄 아손데콩!” ▌♥ 4: MAY 메이 · research ○ idle ▌ no task “반겨주는 역할이거든요... 강아지마냥.” ▌♥ 5: ZENA 제나 · scout ● working ▌ ✓ Ran: Show environment fi… “나는, 신라... 신라의..… battery 51% · 5h 31% r: Roles · press a name for her backstage (1-5) · /resc…
Turn line
♥ WONI “오이쉬!” 42s
README

rescene — RESCENE mode for Claude Code

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.

Three members at work in the demo

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.

Contents

Install (3 minutes)

What you need

  • Claude Code: the program you run as claude in a terminal. If you do not have it yet, install it first from the Claude Code page.
  • The mod was built and tested on Claude Code 2.1.291. Check yours with claude --version, and bring an old one up to date with claude update.
  • Nothing else. There is no Node.js or other program to install.

Installing

  1. Open a terminal and run claude.
  2. Paste this one line into Claude Code's prompt and press Enter.
   /plugin install rescene --marketplace techkwon/rescene
  1. Answer what the screen asks.
  2. Add marketplace? → y
  3. The screen that asks for a scope → press Enter on the first one (user). The mod is then there in every project.
  4. The screen that asks for settings → go on as it is unless you want something changed. You can change it later.
  5. When it says Installed rescene. Plugin is now active., you are done. It is on at once, with no restart.

Checking that it is installed

Type /rescene in the prompt. The members panel opens on the right and the reply says The RESCENE members panel is open.

Another way (from a copy of the repository)

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.

Updating and removing

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.

First steps

  1. Watch the demo: type /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.
  2. Hand out work: ask as you usually do, and WONI hands the work to members by the kind of work it is. To choose a member yourself, name her.
   Have LIV review src/index.js, and have MINAMI add a usage section to the README.
  1. Look closer: press a member's name in the panel, or type /rescene minami, to see what she did, step by step (her backstage).
  2. See your usage: the card at the bottom of the panel and the band above the prompt show the context left and your plan's limits. For the same in text, /rescene usage.

Work handed to members costs tokens for each member. WONI does simple things herself and hands out only what is worth splitting.

Members and roles

MemberHeartRoleWhat she takes
WONI 원이 (the leader)💚leadSplits the work, gathers the results and tells you. This is the main session
LIV 리브 (nicknamed 최종병기, "the final weapon")♥reviewReview, verification, tests
MINAMI 미나미 (the all-rounder)💙buildBuilding, fixing
MAY 메이 (nicknamed 메기자)💛researchResearch, documents, write-ups
ZENA 제나 (the youngest)💜scoutExploring code, locating things, errands
  • The mod casts a task by the kind of work it is. When that member is busy, another with free hands takes it.
  • The voice goes only on the report. The members are told to keep it out of code, commands, commit messages and file contents.
  • LIV's color is black, which a dark screen may not show, so her heart is drawn as ♥.
  • The main session's own tool calls (reading, editing, searching, the web and so on) also show on the card of the member whose kind of work they are.
  • The members' lines are real ones, in Korean. The mod never translates or invents a catchphrase. In English mode the model is given each line with what it means or when it fits, and told to quote it in Korean as she said it.

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.

The roles screen

What you see

The panel on the right

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.

Backstage

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.

MINAMI's backstage, in Korean mode

The band above the prompt

The band above the prompt, in Korean mode

  • The row of names 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.
  • The usage row: battery is how much of this conversation's context window is left, and 5h and 7d are how much of your plan's limits is used. Roles at the end opens the roles screen.
  • While there is work, a row of the members at work stands above these (hidden while the panel is open). When the terminal is narrow the hint goes first, then the roles, then the bars; at the narrowest only Send to [ auto ] is left.

And more

  • Spinner text: while you wait it says who is doing what, such as 💚 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.
  • Unit names: one member at work goes by her unit with WONI (우아즈, 원나미, 쪼물딱즈, 맏막즈), two or more by theirs (06즈, 리트와 메트, 메미즈, 막내즈 and others), shown as On stage now: …. WONI working alone is WONI running single-core. The unit names are the fans' own, and stay in Korean.
  • Lines for the moment, each said once when its moment comes: at 80% of a limit MAY's “전 총량의 법칙을 믿습니다.”, at 95% WONI's “그냥 굶어라.”, at 80% of the context MAY's “이게 과유불급이에요.”, right after the conversation is summarized WONI's “누구게?”, with four tasks running at once MAY's “여러분 여러분! 너무 시끄러워요!”, when one file is read five times in a turn WONI's “와 이래 많이 봅니까, 우리? 그만 좀 봅시다.”, and when a task of ZENA's passes three minutes WONI's “너 김가영이야?”
  • The quote cup: the line said most this session stands first above the stage log. /rescene cup gives the whole ranking.
  • Light and dark screens: when the name of your Claude Code theme has 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.

WONI reporting once the work is done, in Korean mode

Commands

You typeWhat it does
/resceneOpens the members panel
/rescene demoRuns four make-believe tasks for about 15 seconds to show the screen. No real work is done
/rescene on · /rescene offTurns the mode on and off. Off, the voice instructions and the drawings stop
/rescene usageYour usage in text: context, limits, cost, tokens by member
/rescene cupThe 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 minamiYour 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 roleOpens the roles screen
/rescene role liv buildGives LIV the build work, in exchange with the member who had it. Roles: build · review · research · scout
/rescene role autoPuts the roles back to auto (the default)
/rescene langSays which language is in use and how to change it
/rescene lang en · ko · autoChanges the language of the screens and the instructions, or gives it back to auto. A language you pick stays for later sessions
/rescene clearClears 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).

Settings

You choose these on the screen that asks at install time, and change them later by typing /plugin configure rescene@rescene in the prompt.

KeyNameDefaultWhat it does
leaderVoiceWONI's voiceonThe main session speaks as WONI, the leader
memberVoiceMembers' voiceonA subagent reports in the voice of the member it was cast as
languageLanguageautoWrite the value as text. auto (Korean for a Korean reader, English for anyone else) · ko · en. Within a session, /rescene lang changes it
bandBand above the promptonShows who takes the next prompt and your usage, and the members at work while there is work
bandStyleShape of the bandfullWrite 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)
autoOpenOpen the panel by itselfonOpens 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
themeScreen brightnessautoWrite the value as text. auto (follows the Claude Code theme) · dark · light
orcaVoiceOrca workers' voiceonHands a fleet-run worker a copy of its spec with the member's voice block at its end (only where you use Orca)
keepScreenKeep an Orca worker's screenoffAdds tee after a one-step fleet-run so <out>.live.log is kept. Off by default, because it changes your command

Language (Korean and English)

The default is auto: Korean for a Korean reader, English for anyone else.

  • Auto takes the reader for Korean when (the first that applies decides)
  • Claude Code has an answer language set (language, not left at its default): Korean if it names Korean (Korean, ko, 한국어 and the like), English if it names any other
  • one of the environment variables LC_ALL, LC_MESSAGES, LANG starts with ko
  • the runtime's locale is ko
  • the time zone is Asia/Seoul

Otherwise 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.

  • Changing it yourself: /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 English changes: the panel, the band, the toasts, the command replies and the instructions given to the model. Members go by WONI, LIV, MINAMI, MAY and ZENA, and roles by lead, build, review, research and scout.
  • What it does not change: the members' lines. They are what the members really said, so they stay in Korean, and the model is told what each means or when it fits instead of being handed a translation to quote. Unit names (06즈 and the like) stay as they are too.
  • This is the language of what the mod writes. It does not decide what language the model answers in: the model follows your language and Claude Code's own instructions.
  • What was already on the screen before a change (a note in the stage log, say) stays in the language it was written in.

Troubleshooting

What you seeWhat to try
/plugin install … is not availableThat 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 foundCheck that the address is techkwon/rescene
There is no /resceneThe 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 itselfIt does not where the terminal is narrower than 144 columns. Type /rescene and it opens at any width
No icons, only cards of textThat 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 readSet "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 workTurn 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 roomSet "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.

What this mod does on your machine

A mod runs with your own permissions, not in a sandbox. So here is everything it reads, runs, writes and changes.

  • Sends nothing. There are no network calls. The two programs below are run only to read text back on the same machine.
  • Reads: the first line of each prompt, each tool call's name and target (a file name, the start of a command), the opening of a subagent's report, usage figures and the name of the Claude Code theme, all to draw the session's own panel and band. Of the environment it reads five variables: 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.
  • Runs two programs, both for the optional Orca integration:
  • 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.
  • Writes one kind of file: beside an Orca 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.
  • Changes
  • It adds one section, "RESCENE mode", to the system prompt (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).
  • It appends a member block to the prompt a subagent is given (agent.spawn), and adds one line naming the member to an Agent tool's result (tool.call).
  • It draws the spinner text, the line after a turn, the band above the prompt and its own panel (ui.render).
  • It rewrites a Bash command in two cases only: the --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.
  • It takes no permission decision: it never allows or denies a tool call, it only looks at the result. It hooks no setting as it is made (the theme's name is read when a prompt is sent).
  • It registers one command, /rescene, and no tools or agents.

Having an AI install and use it

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.

Orca fleet-run integration (optional)

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.

  • A worker is cast by what its profile is for (build, review, research, scout). A general profile such as codex-hard is cast by the name of its spec file (LIV when it has audit, review or 점검 in it).
  • Only a 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.
  • The original spec is left alone. A copy with the member's voice block at its end (<name>.rescene-<member>.md) is made in the same folder and the worker is run from that copy (not with orcaVoice off).
  • The <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.
  • What a worker is on right now: when the command is one 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.
  • When the screen cannot be read twice running, what the card says she is on is taken down, her backstage says unreadable now · as last read, and the reads are spaced out to at most a minute.
  • When the command that opens the terminal (that alone, with no filter) fails, the task is closed as failed at once. A failure of a command with a filter may be the filter's, so the result file is still waited for.
  • A 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.
  • The voice copies and .live.log files stay beside the spec and result files. The mod does not delete them: you may, once the work is done.

Development

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 .
PathWhat it holds
.claude-plugin/plugin.jsonName, version, settings
.claude-plugin/marketplace.jsonWhat makes this repository an install address
hooks/register.tsxEvery hook: casting, voice instructions, the command, usage, the Orca integration
hooks/view.tsxThe panel, the band, the backstage, the roles screen
hooks/lang.tsPicking the language (Korean or English) and telling who reads Korean
hooks/members.ts · voice.ts · action.ts · orca.ts · sprites.tsMembers and lines, the voice texts, the phrase for each tool, reading shell commands, the pixel icons
types/index.d.tsThe contract for the session's state
tests/115 tests

The rules for changing the code are in AGENTS.md.

Notices and sources

  • This is an unofficial mod made by a fan. It has nothing to do with THE MUZE Entertainment, and uses no official logo, photo or remini drawing. The pixel icons were drawn for this mod.
  • The members' details (colors, hearts, nicknames, unit names) and lines come from Namuwiki (read 2026-10-06). Namuwiki is a wiki written by fans, not an official source. Only lines on record as really said are used, and no catchphrase is made up. "미음" and its twists (미으무야~, 망했또띠, 망해또르띠아) are the members' own slang for "we're done for", and "이봐협에서 나왔습니다." is a meme among REMINE. Only WONI's "오이쉬!" is spelled the way the maker chose rather than as recorded ("오이쉬에~").
  • The mod uses the first line of a prompt, the record of tool use and the usage figures **only on
Source 9 files
hooks/register.tsx 1721 lines
1import { 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 lines
1import 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}
112
hooks/lang.ts 85 lines
1// 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}
85
hooks/members.ts 496 lines
1import 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}
496
hooks/sprites.ts 213 lines
1import 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
213
hooks/orca.ts 247 lines
1// 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}
247
hooks/view.tsx 1529 lines
1import 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)
1200
hooks/voice.ts 244 lines
1import 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}
244
types/index.d.ts 168 lines
1export 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