A game layer over Claude Agent Teams in Orca, and the project lead that delegates each ticket to a member session: quest mode on the spinner, a party HUD above…

A game layer for Claude Code sessions that Orca runs as Claude Agent Teams. It is a Claude Code mod (a hooks module drawn inside Claude Code's terminal UI), not an Orca panel plugin.
on (default), zen (also hides short working notes) or off.flat draws the same game as text in the scene's colors, with no scene or characters: one line per teammate (name, ticket, phase, PR, CI), a score line (level, XP, latest rewards) and the phase's card above the spinner. The band's ≡ Text / ▦ Scene chip and the Panel's 0 key flip it for the session, back to the mode shown before.needs you! while it waits, hurt while CI is red), counts, the review, its CI, the work item, agent activity and project links as clickable chips, a Needs you: line, and Panel, Refresh and Pet (♥ Pet <name> when the scene has a pet of its own, which it hearts; Clawd otherwise).i (work item), o (review), c (CI), w (first project link) and a (activity) into an Orca browser tab. r refreshes, p pets, q or Esc closes. View (1–9 by tile order) focuses that teammate's Orca pane. Each section (Team, Review, My PRs, To Do, Actions) collapses from the ▾ before its heading, and /orca-game:layout hides, moves or resets them; the layout is kept per project in $.store.$.store across sessions.$.ui.status lines, and Jev's advisor state when Jev is installed, become rows of a strip under the band, and their band elements take the scene palette. See docs/reference/orchestrator-game.md (Other plugins in a game pane).cast adds a pet beside the hero and the character that stands for Jev: while Jev publishes, it acts out each new decision and CI state at the scene's edge (a referee unless the scene dresses it), and the Panel lists its latest decisions. See docs/reference/orchestrator-game.md (The cast).render: hd draws the spinner scene as a 9 to 10-row 2D-HD scene: a lit sky with clouds, fogged layers, outlined and shaded characters, day and night on the project's timezone, particles, the party following the hero, the crowned lead, floating team reactions and a thought cloud (hooks/scene-hd.ts and the hooks/hd-*.ts files). See docs/reference/orchestrator-game.md (Render: classic or hd).Ported from the o plugin of koombea/cineco-orchestrator by Jucer Orozco (quest mode, the pixel art, the band and the Fleet pane). Everything specific to that project (Jira, Tambora, cmux, the coordination dir, wave/push/sweep actions, autostart) was removed; the fleet now comes only from Orca's CLI.
The Night Watch (vigilante), Crystal Saga (jrpg) and Beast Tamer (tamer) scenes are also his, from PR #32 of that repo: worlds, costumes, foes, cards and finales, with the far-layer drawing (skyline, crystal spires, meadow, the signal) in hooks/scenery.ts.
Orca ships this folder as <resourcesPath>/claude-plugins/orca-game and starts Claude Agent Teams sessions with claude --plugin-dir <that folder>. It needs Claude Code 2.1.287 or later (function hooks). Other agents never load it.
To try it by hand from a checkout:
claude --plugin-dir "$(pwd)/resources/claude-plugins/orca-game"
The env is the launch snapshot. Orca's live answer from orca game state (every 30 s and after each turn) can change mode, scene, HUD, rewards, render and timezone since, and /orca-game:mode overrides the mode for the session:
| Variable | Values | Default | Effect |
|---|---|---|---|
ORCA_GAME_MODE | on, zen, flat, off | on | Quest mode |
ORCA_GAME_SCENE | a scene id | quest | Art set; an unknown id falls back to quest |
ORCA_GAME_HUD | 1/0, true/false | 1 | 0 hides the band |
ORCA_GAME_REWARDS | 1/0, true/false | 1 | 0 turns off XP, level-ups and the cheer confetti |
ORCA_GAME_RENDER | classic, hd | classic | The 5-row pixel art, or the 9 to 10-row 2D-HD scene |
ORCA_GAME_TIMEZONE | local or an IANA zone | local | The clock hd's day and night follow |
ORCA_GAME_LOCALE | en, es | en | Language of the game's words; anything else is English |
A project's own scene file comes from sceneFile in its orca.yaml, which Orca reads and serves through orca game state; the env never carries a path.
Other plugins can follow the game through its state { plugin: 'orca-game', key: 'absorbing' }, a boolean that is true exactly while the game draws other plugins' lines (mode not off and the HUD on). It is written at load and on every change from the env, Orca's live config or /orca-game:mode.
The words live in hooks/game-strings.ts (English) and hooks/locales/. Text drawn inside the pixel art (the HUD, the thought bubble) must be width-1 characters, so a wide character there (a CJK teammate or file name an agent reports) is drawn as ?; the text around the art shows it.
The config that orca game state reports overrides the variables while the session runs.
/orca-game:Typing /orca-game: lists every game command. Each is a plugin skill the mod answers itself (hooks/game-commands.ts), so no model turn runs and no shell is involved; the engine refuses a : in a name a mod registers, which is why they are skills.
| Command | What it does | |||
|---|---|---|---|---|
/orca-game:state | The game state from a fresh orca game state: each setting with its layer, this session's mode and render, the lead and its members with their slots, a held STOP, warnings and links | |||
/orca-game:layout [verb] | The Panel's sections with their state; reset, show <section>, hide <section>, `move <section> up\ | down` | ||
| `/orca-game:mode on\ | zen\ | flat\ | off` | The mode for this session; alone, prints the mode and render |
| `/orca-game:render classic\ | hd` | The render for this session; alone, prints the mode and render | ||
/orca-game:demo | A scripted fake party (every phase, action and reaction, one level-up) that runs no command and writes no XP; /orca-game:mode on returns to real data | |||
/orca-game:quest [args] | The long form taking all of the above (render hd, zen, demo) | |||
/orca-game:setup, /orca-game:lead | Write the project's orca.yaml with you; make the session the project's lead |
/quest [args] is the short form of /orca-game:quest, registered by the mod as immediate so it runs at once even while a turn streams. A mode or render set this way is this pane's alone: orca game state reports only Orca's layers, so /orca-game:mode and /orca-game:render print the configured value and its layer beside an override, as in Render: hd (this session; the project's setting is classic, from default). Project buttons (actions[].command in orca.yaml) keep the names the project chose and are not under /orca-game:.
orca game state --jsonPolled every 30 s and after each of the lead's turns. The answer is either the bare object or { "result": { ... } }. A missing or failing CLI means no fleet data: the band says so, the scene keeps working, and nothing throws. hooks/game-state.ts validates the shape and drops bad entries.
{
config: { mode, scene, hud, rewards, tracker },
sources: { ... },
warnings: string[],
worktree: { id, path, branch, displayName },
agents: [{ paneKey, displayName, agentType,
state: 'working' | 'waiting' | 'idle' | 'done',
toolName: string | null, isLeader: boolean,
terminalHandle?: string /* term_…, kept only in that shape */ }],
review: { provider: 'github' | 'gitlab' | 'bitbucket' | 'azure-devops' | 'gitea',
number, title, url,
state: 'open' | 'draft' | 'merged' | 'closed',
ci: 'pending' | 'success' | 'failure' | 'neutral' | 'none' } | null,
workItem: { provider: 'plane' | 'jira' | 'linear' | 'github' | 'gitlab',
identifier, title, url, status,
statusGroup: 'backlog' | 'todo' | 'in_progress' | 'review' | 'done' | 'cancelled' | 'unknown' } | null,
extensions?: { scenes: [{ id: '<pluginKey>/<id>' | 'file:<path>', pluginId, definition: SceneDefinition }] }
}
A session's own tool calls set its phase: an edit under docs/ or to a Markdown file is the Sage, any other edit the Blacksmith, tests and linters the Knight, git push, gh pr create or glab mr create the Herald, and a merge (gh pr merge, glab mr merge) or a tracker status change (orca plane status set, orca linear status set, a tracker MCP update) the King. Teammates get theirs from their current tool, then from the worktree (an open review is the Herald, a merged one or a work item in review or done is the King).
XP is earned only for a change seen between two polls of the same worktree, so a restart never re-awards: review merged 100, CI turning green 20, work item to review 50, to done 70. A lead (role not member) also earns the same for each member in its own worktree, plus 10 when a member appears; those use the ids the member's pane would, so a shared store never counts one change twice (hooks/fleet-rewards.ts). Those are the defaults: game state hands over the project's rewardWeights (0 turns a reward off and writes no row), and a host older than that gets the defaults; the scoring table is in docs/reference/orchestrator-game.md. A level needs 100 XP times the level, and a row that takes XP away can lower the total but never the level below 1. CI red hurts the working party.
hooks/scenes/<id>.ts exporting a SceneDefinition (hooks/scenes/scene-definition.ts): a Look (sky, far layer, plants), a ten-column Costume and a message Card for each phase, the Storyteller card and the band palette; optionally a foe for red CI and a merge finale.SCENES in hooks/scenes/index.ts, and its id to Orca's ORCHESTRATOR_GAME_BUILT_IN_SCENES (with a Settings label) so the picker offers it.ORCA_GAME_SCENE=<id> or the scene setting.The engine code reads only the definition, so nothing else changes.
An Orca plugin can also ship a scene as JSON (contributes.gameScenes, see docs/reference/plugin-development.md). Orca serves it in extensions.scenes under the id <pluginKey>/<id>; hooks/scenes/scene-shape.ts re-validates it, and sceneOf resolves it until a poll stops serving it. Any id nothing serves draws quest.
A project's own scene file (orchestratorGame.sceneFile, see docs/reference/orchestrator-game.md) arrives the same way under file:<path in the worktree> with pluginId: 'file'. Colors may be integers or #rrggbb strings, as Orca's parser takes them.
npm test): npx vitest run --config config/vitest.config.ts resources/claude-plugins/orca-game. They are *.spec.ts because claude plugin test claims every *.test.ts in a mod folder.tests/engine.test.ts, against Claude Code itself): claude plugin test resources/claude-plugins/orca-game.claude plugin validate resources/claude-plugins/orca-game checks the manifest and the module the way the engine loads it..claude-plugin/types/ (the claude-code module, its built-in tools and connected MCP tools) every time it loads the mod from a --plugin-dir folder in an interactive session; the folder is gitignored and never packaged. After one such load, npx tsc -p resources/claude-plugins/orca-game type-checks the mod. Without it, point a tsconfig.json kept outside the folder at the declaration file the plugin-authoring skill writes and include hooks, types and tests.hooks/register.tsx 116 lines1import type { Register } from 'claude-code'
2
3import { registerActionCommands } from './action-commands'
4import { registerArcade } from './arcade-pane'
5import { dropAdvisorCall } from './advisor-state'
6import { actOutGate, registerAdvisor } from './advisor-sync'
7import { clearAllies, registerAllies } from './allies-strip'
8import { registerBand } from './band'
9import { registerFleetPane } from './fleet-pane'
10import { POLL_EVERY_MS, refreshSoon } from './fleet-sync'
11import { questSessionStart, registerQuest } from './quest'
12import { leadAutostart } from './lead-autostart'
13import { registerLeadBrief } from './lead-brief'
14import { whileAsking } from './member-band'
15import { askedQuestionOf } from './member-pane'
16import { registerStateCommand } from './state-command'
17import { registerLayoutCommand } from './layout-command'
18import { registerLeadName, syncLeadName } from './lead-name'
19import { isHumanOrigin } from './quest-policy'
20import { clawdReact } from './quest-runtime'
21import { markLeadStarted, unmarkLeadStarted } from './lead-autostart'
22import { isSubagentTool, isTeammateSpawnOf, leadSubagentDenyOf } from './lead-subagent-guard'
23import { isGameLeadCommand, isLeadStarted, isMadeLeadOutput } from './lead-started'
24import { noteSubagentCall, noteSubagentDone, runningLeadSubagents } from './lead-subagents'
25import { viewOf } from './fleet-sync'
26import { questLeadActive, questToolCall, questTurnComplete } from './quest-turns'
27
28export const register: Register = (on) => {
29 registerQuest(on)
30 registerBand(on)
31 registerAllies(on)
32 registerAdvisor(on)
33 registerFleetPane(on)
34 registerArcade(on)
35 registerLeadName(on)
36 registerLeadBrief(on)
37 registerActionCommands(on)
38 registerStateCommand(on)
39 registerLayoutCommand(on)
40
41 on('session.start', async ($, e, next) => {
42 await clearAllies($)
43 const started = await next(e)
44 await questSessionStart($).catch(() => undefined)
45 $.clock.every(POLL_EVERY_MS, () => {
46 void refreshSoon($).catch(() => undefined)
47 void syncLeadName($).catch(() => undefined)
48 })
49 // Why not awaited: the skill runs once the session is idle, and session.start must not wait on it.
50 void leadAutostart($).catch(() => undefined)
51 return started
52 })
53
54 on('turn.complete', ($, e, next) => {
55 questTurnComplete(e.agentId)
56 if (e.agentId === undefined) {
57 if (dropAdvisorCall()) {
58 $.ui.invalidate('ui.render')
59 }
60 void refreshSoon($).catch(() => undefined)
61 void syncLeadName($).catch(() => undefined)
62 } else {
63 noteSubagentDone(runningLeadSubagents, e.agentId)
64 }
65 return next(e)
66 })
67
68 on('tool.call', async ($, e, next) => {
69 questToolCall(String(e.tool), e, e.agentId)
70 if (e.agentId !== undefined) {
71 noteSubagentCall(runningLeadSubagents, e.agentId, String(e.tool), await $.clock.now())
72 } else if (String(e.tool) === 'Bash') {
73 const command: unknown = Reflect.get(e, 'command')
74 if (isGameLeadCommand(command)) {
75 // Why after the call: only its output says whether this session or another pane leads.
76 const answered = await next(e)
77 await (isMadeLeadOutput(answered.result) ? markLeadStarted($) : unmarkLeadStarted($)).catch(
78 () => undefined
79 )
80 return answered
81 }
82 const question = askedQuestionOf(command)
83 if (question !== undefined) {
84 return await whileAsking($, question, () => next(e))
85 }
86 }
87 return next(e)
88 })
89
90 on('classic.PreToolUse', async ($, e, next) => {
91 const tool = String(e.tool)
92 if (!isSubagentTool(tool)) {
93 const verdict = await next(e)
94 actOutGate($, tool, Reflect.get(e, 'command'), verdict)
95 return verdict
96 }
97 const { state } = await viewOf($)
98 const reason = leadSubagentDenyOf({
99 role: state?.role,
100 isOrchestrator: await isLeadStarted($),
101 tool,
102 isTeammateSpawn: isTeammateSpawnOf(e),
103 cli: (await $.env.get('ORCA_GAME_CLI').catch(() => undefined)) ?? 'orca'
104 })
105 return reason === null ? next(e) : { deny: reason }
106 })
107
108 on('prompt.submit', ($, e, next) => {
109 if (isHumanOrigin(e.origin.kind)) {
110 questLeadActive()
111 clawdReact($, 'hello')
112 }
113 return next(e)
114 })
115}
116hooks/action-commands.ts 59 lines1import type { EngineInterface, On } from 'claude-code'
2
3import type { GameAction } from '../types'
4import { runAction } from './game-actions'
5import { gameStrings } from './game-strings'
6import { actionLabelOf } from './lead-actions'
7import { quest } from './quest-runtime'
8
9// Why module state: the engine keeps a registered name for the session, with no way to drop it.
10const registered = new Map<string, string>()
11const refused = new Set<string>()
12let current: readonly GameAction[] = []
13
14/** Registers each action's slash command after a refresh; a name the engine refuses is noted once. */
15export async function syncActionCommands(
16 $: EngineInterface,
17 actions: readonly GameAction[]
18): Promise<void> {
19 current = actions
20 const text = gameStrings().text
21 for (const action of actions) {
22 const name = action.command
23 if (name === undefined || refused.has(name)) {
24 continue
25 }
26 const description = text.actionCommandDescription(actionLabelOf(action))
27 if (registered.get(name) === description) {
28 continue
29 }
30 try {
31 await $.command.register({ name, description, argumentHint: '[text]' })
32 registered.set(name, description)
33 } catch {
34 refused.add(name)
35 $.ui.toast(text.actionCommandRefused(name))
36 }
37 }
38}
39
40/** Serves `/<command>`: a prompt goes to the lead with what was typed after it, MCP answers here. */
41export const registerActionCommands = (on: On): void => {
42 on('command.run', async ($, e, next) => {
43 if (!registered.has(e.command)) {
44 return next(e)
45 }
46 const action = current.find((one) => one.command === e.command)
47 if (action === undefined) {
48 return { text: gameStrings().text.actionCommandGone(e.command) }
49 }
50 // Why on the clock: the engine refuses a prompt submitted from inside a hook the turn waits on.
51 if ('prompt' in action) {
52 $.clock.after(0, () => void runAction($, action, quest.isDemo, e.args).catch(() => undefined))
53 return {}
54 }
55 const outcome = await runAction($, action, quest.isDemo).catch(() => undefined)
56 return outcome === undefined ? {} : { text: outcome.lines.join('\n') }
57 })
58}
59hooks/arcade-pane.tsx 160 lines1import type { EngineInterface, On } from 'claude-code'
2
3import { arcadeCaptionOf, arcadeLoopOf, type ArcadeLoop, type ArcadeScreen } from './arcade-loop'
4import { runnerOf, runnerStartOf, type Runner, type RunnerState } from './arcade-runner'
5import { ARCADE_RUNNER } from './arcade-runner-data'
6import { ARCADE_ROWS, arcadeGridOf, arcadeShapesOf, type ArcadeShape } from './arcade-runner-frame'
7import { arcadeWords } from './arcade-words'
8import { gameStrings } from './game-strings'
9import { cellsOf } from './pixel-canvas'
10import { currentScene, ready } from './quest-runtime'
11import type { SceneDefinition } from './scenes/index'
12
13// The runner in a pane of its own, opened as the demo's practice run: nothing it scores is kept.
14
15export const ARCADE_PANE = 'orca-game-arcade'
16export const ARCADE_JUMP_KEY = 'j'
17const RASTER_KEY = 'arcade'
18const JUMP_BUTTON = 'arcade-jump'
19const CLOSE_BUTTON = 'arcade-close'
20// Why a key besides Esc: a phone has none.
21const CLOSE_KEY = 'q'
22const MIN_COLUMNS = 20
23const MAX_COLUMNS = 160
24
25type View = { state: RunnerState; screen: ArcadeScreen; columns: number; tick: number }
26
27type Arcade = {
28 scene: SceneDefinition
29 runner: Runner<ArcadeShape>
30 loop: ArcadeLoop
31 view: View
32}
33
34// Why module memory and not $.state or $.store: a practice run ends with its pane and leaves nothing.
35let arcade: Arcade | undefined
36
37const cellsNow = ({ scene, runner, view }: Arcade): string =>
38 cellsOf(
39 arcadeGridOf({
40 scene,
41 runner,
42 state: view.state,
43 columns: view.columns,
44 rows: ARCADE_ROWS,
45 tick: view.tick,
46 caption: arcadeCaptionOf(view.screen, view.state.score)
47 })
48 )
49
50const closeArcade = (): void => {
51 arcade?.loop.stop()
52 arcade = undefined
53}
54
55async function blitArcade($: EngineInterface, own: Arcade): Promise<void> {
56 const { columns } = own.view
57 const result = await $.ui
58 .blit({
59 requestId: ARCADE_PANE,
60 key: RASTER_KEY,
61 cells: cellsNow(own),
62 columns,
63 rows: ARCADE_ROWS
64 })
65 .catch(() => ({ deny: 'failed' }))
66 if (result.deny !== undefined && arcade === own) {
67 closeArcade()
68 }
69}
70
71/** Opens the arcade pane on a fresh run of the built-in runner in the active scene. */
72export async function openArcade($: EngineInterface): Promise<void> {
73 closeArcade()
74 const scene = currentScene()
75 const runner = runnerOf(ARCADE_RUNNER, arcadeShapesOf(ARCADE_RUNNER, scene.looks.working))
76 const seed = await $.clock.now().catch(() => 0)
77 const view: View = { state: runnerStartOf(runner, seed), screen: 'ready', columns: 0, tick: 0 }
78 const loop = arcadeLoopOf({
79 runner,
80 seed,
81 clock: { now: () => $.clock.now(), every: (ms, fn) => $.clock.every(ms, fn) },
82 isActive: async () => {
83 const panes = await $.ui.panes().catch(() => [])
84 const pane = panes.find((one) => one.id === ARCADE_PANE)
85 if (pane === undefined && arcade?.view === view) {
86 // Why here too: a close the ui.close hook never saw still ends the run on its next frame.
87 arcade = undefined
88 }
89 return pane !== undefined && pane.isShown && pane.isFocused
90 },
91 draw: (state, screen) => {
92 view.state = state
93 view.screen = screen
94 view.tick += 1
95 if (arcade?.view === view && view.columns > 0) {
96 void blitArcade($, arcade)
97 }
98 }
99 })
100 arcade = { scene, runner, loop, view }
101 await $.ui.open({
102 id: ARCADE_PANE,
103 title: arcadeWords().title,
104 closeOnEscape: true,
105 focus: true
106 })
107}
108
109export const registerArcade = (on: On): void => {
110 on('ui.render', { component: 'Pane', requestId: ARCADE_PANE }, async ($, e) => {
111 await ready($)
112 const words = arcadeWords()
113 const own = arcade
114 if (own === undefined || e.surface !== 'terminal') {
115 // A pane that outlived its run (a reload) has nothing to play: it closes. Another surface
116 // only names it, since closing there would end the terminal's run too.
117 if (own === undefined) {
118 void $.ui.close({ id: ARCADE_PANE }).catch(() => undefined)
119 }
120 const { Text } = $.ui.resolve(e)
121 return <Text dimColor>{words.title}</Text>
122 }
123 own.view.columns = Math.max(MIN_COLUMNS, Math.min(MAX_COLUMNS, e.props.bodyColumns))
124 const { Box, Button, Raster } = $.ui.resolve(e)
125 const close = () => {
126 closeArcade()
127 void $.ui.close({ id: ARCADE_PANE }).catch(() => undefined)
128 }
129 return (
130 <Box flexDirection="column">
131 <Raster
132 key={RASTER_KEY}
133 columns={own.view.columns}
134 rows={ARCADE_ROWS}
135 cells={cellsNow(own)}
136 />
137 <Button
138 key={JUMP_BUTTON}
139 hotkey={ARCADE_JUMP_KEY}
140 autoFocus
141 onPress={() => own.loop.press()}
142 label={`${ARCADE_JUMP_KEY}: ${words.jump}`}
143 />
144 <Button
145 key={CLOSE_BUTTON}
146 hotkey={CLOSE_KEY}
147 onPress={close}
148 label={`${CLOSE_KEY}: ${gameStrings().text.close}`}
149 />
150 </Box>
151 )
152 })
153 on('ui.close', ($, e, next) => {
154 if (e.id === ARCADE_PANE) {
155 closeArcade()
156 }
157 return next(e)
158 })
159}
160hooks/advisor-state.ts 150 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface } from 'claude-code'
3
4import type { AdvisorLog, AdvisorLogEntry } from '../types'
5import {
6 advisorCallAfter,
7 advisorCallRowOf,
8 advisorPoseOf,
9 isAdvisorActingOf,
10 isAdvisorCallCue,
11 type AdvisorCall,
12 type AdvisorScene,
13 type StoredRow
14} from './advisor-calls'
15import {
16 advisorCuesOf,
17 cueTicksOf,
18 playingCueOf,
19 strongestCueOf,
20 type AdvisorCue,
21 type AdvisorDecision,
22 type AdvisorSeen,
23 type PlayingCue
24} from './advisor-cues'
25import { advisorOf, type Advisor } from './allies'
26import type { AdvisorPose } from './scene-cast'
27import type { SceneAdvisor } from './scenes/index'
28
29// Jev's advisor in this pane: what it last published, the gesture playing, its decisions, and Claude's open advisor call.
30
31const JEV_ADVISOR = { plugin: 'orca-jev-mod-skills', key: 'advisor' } as const
32export const ADVISOR_LOG_MAX = 5
33
34const EMPTY: AdvisorLog = { decisions: [] }
35const advisorLog = atom({ plugin: 'orca-game', key: 'advisor' } as const, EMPTY)
36
37type AdvisorRuntime = {
38 advisor: Advisor | null
39 seen: AdvisorSeen | null
40 cue: PlayingCue | undefined
41 call: AdvisorCall | undefined
42}
43
44// Module state: a hot reload starts it over, which only takes the gesture playing.
45const runtime: AdvisorRuntime = { advisor: null, seen: null, cue: undefined, call: undefined }
46
47/** Notes Jev's latest value; the decisions it brings, the first value's included, go to the log. */
48export function observeAdvisor(value: unknown, tick: number): AdvisorDecision[] {
49 const advisor = advisorOf(value)
50 runtime.advisor = advisor
51 if (advisor === null) {
52 // Why forget: a Jev that comes back starts from where it stands, not with a burst of gestures.
53 runtime.seen = null
54 // Why keep a call's cue: every render reads Jev, and without Jev a reaction would be wiped as it starts.
55 if (runtime.cue !== undefined && !isAdvisorCallCue(runtime.cue.cue)) {
56 runtime.cue = undefined
57 }
58 return []
59 }
60 const next = advisorCuesOf(runtime.seen, advisor)
61 runtime.seen = next.seen
62 const cue = strongestCueOf(next.cues)
63 if (cue !== undefined) {
64 playAdvisorCue(cue, tick)
65 }
66 return next.decisions
67}
68
69/** Plays `cue` from `tick` unless a stronger gesture is still playing; any source of cues comes here. */
70export const playAdvisorCue = (cue: AdvisorCue, tick: number): void => {
71 runtime.cue = playingCueOf(runtime.cue, cue, tick)
72}
73
74/** Jev's value as this pane reads it; a drawing that calls this redraws on Jev's writes. */
75export async function readJevAdvisor($: EngineInterface, tick: number): Promise<Advisor | null> {
76 const held = await $.state.get(JEV_ADVISOR).catch(() => ({ value: undefined }))
77 const advisor = advisorOf(held.value)
78 // Why no gesture here: a drawing may read a value older than the write just acted out.
79 if (advisor === null || runtime.seen === null) {
80 observeAdvisor(held.value, tick)
81 }
82 runtime.advisor = advisor
83 return advisor
84}
85
86export const advisorNow = (): Advisor | null => runtime.advisor
87
88/** Notes a row the session keeps; true when it opened or closed Claude's advisor call. */
89export function noteAdvisorRow(row: StoredRow, tick: number): boolean {
90 const next = advisorCallAfter(runtime.call, advisorCallRowOf(row), tick)
91 if (next.call === runtime.call && next.cue === undefined) {
92 return false
93 }
94 runtime.call = next.call
95 if (next.cue !== undefined) {
96 playAdvisorCue(next.cue, tick)
97 }
98 return true
99}
100
101/** Forgets an open call whose result never came: the turn ended, aborted or failed. */
102export function dropAdvisorCall(): boolean {
103 const wasOpen = runtime.call !== undefined
104 runtime.call = undefined
105 return wasOpen
106}
107
108const sceneOf = (isAbsorbing: boolean): AdvisorScene => ({
109 isJevShown: isAbsorbing && runtime.advisor !== null,
110 call: runtime.call,
111 playing: runtime.cue
112})
113
114/** The advisor as the scene draws it at `tick`: Jev's while the game draws its lines, and Claude's calls. */
115export const advisorPoseAt = (
116 tick: number,
117 sprite: SceneAdvisor,
118 isZen: boolean,
119 isAbsorbing: boolean
120): AdvisorPose | undefined => advisorPoseOf(sceneOf(isAbsorbing), tick, sprite, isZen)
121
122/** A gesture plays at `tick` worth showing the scene for; zen keeps the routine ones quiet. */
123export const isAdvisorActingAt = (tick: number, isZen: boolean, isAbsorbing: boolean): boolean =>
124 isAdvisorActingOf(sceneOf(isAbsorbing), tick, isZen)
125
126export const isAdvisorCueEndingAt = (tick: number): boolean =>
127 runtime.cue !== undefined && tick - runtime.cue.at === cueTicksOf(runtime.cue.cue.gesture)
128
129const entryOf = (decision: AdvisorDecision, at: number): AdvisorLogEntry => ({ ...decision, at })
130
131export async function logDecisions(
132 $: EngineInterface,
133 decisions: readonly AdvisorDecision[],
134 at: number
135): Promise<void> {
136 if (decisions.length === 0) {
137 return
138 }
139 await update($, advisorLog, (held) => {
140 const fresh = decisions.map((one) => entryOf(one, at))
141 const keys = new Set(fresh.map((one) => one.key))
142 const kept = (held?.decisions ?? []).filter((one) => !keys.has(one.key))
143 return { decisions: [...kept, ...fresh].slice(-ADVISOR_LOG_MAX) }
144 }).catch(() => undefined)
145}
146
147export async function advisorLogOf($: EngineInterface): Promise<AdvisorLog> {
148 return read($, advisorLog).catch(() => EMPTY)
149}
150hooks/advisor-sync.ts 45 lines1import type { EngineInterface, On } from 'claude-code'
2
3import { gateCueOf, type GateVerdict } from './advisor-cues'
4import { logDecisions, noteAdvisorRow, observeAdvisor, playAdvisorCue } from './advisor-state'
5import { refreshSoon } from './fleet-sync'
6import { quest } from './quest-runtime'
7
8export const registerAdvisor = (on: On): void => {
9 // Why here and not only while drawing: a decision is logged once, the moment Jev writes it.
10 on('state.set', { plugin: 'orca-jev-mod-skills', key: 'advisor' }, async ($, e, next) => {
11 const written = await next(e)
12 const decisions = observeAdvisor(e.value, quest.tick)
13 // Why: a gesture that starts while the lead is idle needs the band to mount the scene.
14 $.ui.invalidate('ui.render')
15 await logDecisions($, decisions, await $.clock.now())
16 // Why: a new main model or effort reaches the lead's Panel without waiting for the poll.
17 if (decisions.some((one) => one.title === null)) {
18 void refreshSoon($).catch(() => undefined)
19 }
20 return written
21 })
22 // Why main only: the scene is this session's; a subagent's advisor call is not the lead consulting.
23 on('session.append', async ($, e, next) => {
24 const stored = await next(e)
25 if (e.agentId === undefined && noteAdvisorRow(e.message, quest.tick)) {
26 $.ui.invalidate('ui.render')
27 }
28 return stored
29 })
30}
31
32/** Acts out the verdict Jev's gate (a settings hook beneath the game) returned for a tool call. */
33export function actOutGate(
34 $: EngineInterface,
35 tool: string,
36 command: unknown,
37 verdict: GateVerdict
38): void {
39 const cue = gateCueOf(tool, command, verdict)
40 if (cue !== undefined) {
41 playAdvisorCue(cue, quest.tick)
42 $.ui.invalidate('ui.render')
43 }
44}
45hooks/allies-strip.tsx 131 lines1import { update } from 'claude-code'
2import type { EngineInterface, On, RenderElement, RenderInput } from 'claude-code'
3
4import { readJevAdvisor } from './advisor-state'
5import {
6 allyPartsOf,
7 allyRowsOf,
8 restyledTextOf,
9 withStatus,
10 type AllyRow,
11 type AllyTone
12} from './allies'
13import { isAbsorbingConfig } from './game-config'
14import { gameStrings } from './game-strings'
15import { currentScene, gameConfig, quest, ready } from './quest-runtime'
16import type { BandPalette } from './scenes/scene-definition'
17
18const ALLIES = { plugin: 'orca-game', key: 'allies' } as const
19const MAX_ALLY_ROWS = 6
20
21export const isAbsorbing = (): boolean => isAbsorbingConfig(gameConfig())
22
23// A tone left out reads in the scene's parchment.
24const TONE_COLORS: Readonly<Partial<Record<AllyTone, string>>> = {
25 'ci-watching': 'cyan',
26 'ci-success': 'green',
27 'ci-failure': 'red',
28 block: 'red',
29 warn: 'yellow'
30}
31
32/** Jev and every intercepted status as the strip under the band, or nothing; reading both state values redraws on their writes. A member's strip sits on its pane's colors. */
33export async function alliesStrip(
34 $: EngineInterface,
35 e: RenderInput,
36 palette: BandPalette,
37 columns: number,
38 isPanelShown: boolean,
39 isOnPane = false
40): Promise<RenderElement[]> {
41 if (!isAbsorbing()) {
42 return []
43 }
44 const [advisor, held] = await Promise.all([
45 readJevAdvisor($, quest.tick),
46 $.state.get(ALLIES).catch(() => ({ value: undefined }))
47 ])
48 // Why no Jev: the shown Panel's advisor card draws Jev, so the strip keeps only the other plugins' lines.
49 const rows = allyRowsOf(isPanelShown ? null : advisor, held.value?.statuses ?? [], {
50 isZen: gameConfig().mode === 'zen',
51 maxRows: MAX_ALLY_ROWS,
52 more: gameStrings().text.moreAllies
53 })
54 if (rows.length === 0) {
55 return []
56 }
57 const { Box, Text } = $.ui.resolve(e)
58 const rowOf = (row: AllyRow) => {
59 const parts = allyPartsOf(row, columns)
60 return (
61 <Box key={row.key} flexDirection="row" overflow="hidden">
62 <Text wrap="truncate-end" backgroundColor={isOnPane ? undefined : palette.panel}>
63 {parts.label === undefined ? (
64 ''
65 ) : (
66 <Text
67 backgroundColor={palette.gold}
68 color={palette.ink}
69 bold
70 >{` ${parts.label} `}</Text>
71 )}
72 <Text
73 color={TONE_COLORS[row.tone] ?? (isOnPane ? undefined : palette.parchment)}
74 dimColor={row.tone === 'more'}
75 >
76 {` ${parts.text}`}
77 </Text>
78 {parts.badge === undefined ? '' : <Text color={palette.gold}>{` ${parts.badge} `}</Text>}
79 </Text>
80 </Box>
81 )
82 }
83 return [
84 <Box key="allies-strip" flexDirection="column">
85 {rows.map(rowOf)}
86 </Box>
87 ]
88}
89
90// Why: a plugin disabled, reloaded or crashed never clears its line; each session starts with none.
91export async function clearAllies($: EngineInterface): Promise<void> {
92 await $.state.set(ALLIES, { statuses: [] }).catch(() => undefined)
93}
94
95export const registerAllies = (on: On): void => {
96 // Own calls never reach this hook, so next.origin is always another plugin.
97 on('ui.status', async ($, e, next) => {
98 await ready($)
99 if (!isAbsorbing()) {
100 return next(e)
101 }
102 const plugin = next.origin.plugin
103 const isHeld = await update($, ALLIES, (held) => ({
104 statuses: withStatus(held?.statuses ?? [], plugin, e.text)
105 }))
106 .then(() => true)
107 .catch(() => false)
108 return isHeld ? { value: undefined } : next(e)
109 })
110
111 // Resolved once per plugin at load, so the mode and the scene are read when each element is built.
112 on('ui.resolve', { component: 'AbovePrompt' }, async ($, e, next) => {
113 const table = await next(e)
114 if (e.surface !== 'terminal') {
115 return table
116 }
117 const { Box, Text } = table
118 return {
119 ...table,
120 Text: (props: Parameters<typeof Text>[0]) =>
121 Text(isAbsorbing() ? restyledTextOf(props, currentScene().band) : props),
122 Box: (props: Parameters<typeof Box>[0]) =>
123 Box(
124 isAbsorbing() && props.borderStyle !== undefined
125 ? { ...props, borderColor: currentScene().band.gold }
126 : props
127 )
128 }
129 })
130}
131hooks/band.tsx 346 lines1import type { EngineInterface, On, RenderElement } from 'claude-code'
2
3import { alliesStrip } from './allies-strip'
4import { CHIP_ROWS, MENU_ROWS, menuChipsOf, stateChipsOf } from './band-chips'
5import { fitCells } from './cell-width'
6import { chipRowsOf, MORE_CHIP_KEY, type Chip, type PlacedChip } from './chip-rows'
7import { needPartsOf } from './fleet-labels'
8import { HUD_KEY, HUD_MIN_COLUMNS, HUD_ROWS, hudCellsOf, hudLineOf, xpTextOf } from './fleet-hud'
9import { runAction } from './game-actions'
10import { gameLinkOf } from './game-link'
11import { gameStrings } from './game-strings'
12import { memberBandRows } from './member-band'
13import { isMemberPane } from './member-pane'
14import { ACTION_CHIP_PREFIX, actionChipsOf } from './lead-actions'
15import { isPaneShown, openPane } from './fleet-pane'
16import { refreshSoon, viewOf } from './fleet-sync'
17import { countsOf, needsOf, partyOf } from './party'
18import { animate } from './quest'
19import {
20 clawdReact,
21 currentScene,
22 gameConfig,
23 isAdvisorActing,
24 isClawdReacting,
25 isSceneShown,
26 petScene,
27 ready
28} from './quest-runtime'
29import { levelOf } from './rewards'
30import { tapButtonsOf, type TapButton } from './tap-buttons'
31import { isTapLayout } from './tap-layout'
32import { flatBandRows } from './flat-rows'
33import { isFlatMode, toggleFlat } from './flat-toggle'
34
35const PET_KEY = 'band-pet-scene'
36const ACTION_ROWS = 1
37// A phone's band is one row of chips: the menu, then the actions that fit and a `+N` opening the Panel.
38const TAP_KEY = 'band-tap'
39const TAP_ROWS_MAX = 1
40
41// Core's own band is left out as before; a plugin drawn beneath keeps its place under the game's.
42const beneathOf = (drawn: RenderElement): RenderElement[] =>
43 drawn.type === 'engine' ? [] : [drawn]
44
45function openFleet($: EngineInterface): void {
46 void openPane($)
47 .then(() => refreshSoon($))
48 .catch(() => undefined)
49}
50
51export const registerBand = (on: On): void => {
52 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
53 if (e.props.hasSurvey) {
54 return next(e)
55 }
56 await ready($)
57 const config = gameConfig()
58 if (!config.hud) {
59 return next(e)
60 }
61 const v = await viewOf($)
62 const palette = currentScene().band
63 const columns = Math.max(1, Math.min(512, e.props.bodyColumns))
64 const [beneath, isPanelShown] = await Promise.all([next(e).then(beneathOf), isPaneShown($)])
65 const isMember = isMemberPane(v.state)
66 const allies = await alliesStrip($, e, palette, columns, isPanelShown, isMember)
67 // Why no menu, team or actions: a member reports to its lead and never runs the team.
68 if (isMember) {
69 const { Box } = $.ui.resolve(e)
70 return (
71 <Box flexDirection="column">
72 {memberBandRows($, e, { state: v.state, palette, columns })}
73 {allies}
74 {beneath}
75 </Box>
76 )
77 }
78 const { Box, Text, Button } = $.ui.resolve(e)
79 const text = gameStrings().text
80 // Why Jev too: CI often lands while the lead is idle, when no spinner draws the scene.
81 // Why isWorking: the spinner draws the scene all turn, and its stage count can lag or drop.
82 const showPet =
83 !e.props.isWorking && (isClawdReacting() || isAdvisorActing()) && !isSceneShown()
84 const pet = showPet ? [petScene(PET_KEY, columns)] : []
85 const rowsOf = (
86 prefix: string,
87 chips: readonly Chip[],
88 maxRows: number,
89 draw: (chip: PlacedChip) => RenderElement,
90 more?: (count: number) => string
91 ) =>
92 chipRowsOf(chips, { columns, maxRows, more }).map((row) => (
93 <Box key={`${prefix}-${row[0]?.key ?? 'empty'}`} flexDirection="row" gap={1}>
94 {row.map(draw)}
95 </Box>
96 ))
97 const menuActions: Readonly<Record<string, () => void>> = {
98 'band-panel': () => openFleet($),
99 'band-flat': () => void toggleFlat($).catch(() => undefined),
100 'band-refresh': () => void refreshSoon($).catch(() => undefined),
101 'band-pet': () => clawdReact($, 'pet')
102 }
103 const isTap = isTapLayout(columns)
104 const isFlat = isFlatMode()
105 // Why no Pet on a phone: its one row keeps the flat toggle; the Panel still offers Pet.
106 const menuChips = menuChipsOf(currentScene(), isFlat).filter(
107 (chip) => !isTap || chip.key !== 'band-pet'
108 )
109 const menuTaps: TapButton[] = menuChips.map((chip) => ({
110 key: chip.key,
111 // Why Panel keeps its word: actions drop into its +N before it turns into a bare icon.
112 labels: chip.key === 'band-panel' ? [chip.labels[0]] : chip.labels,
113 onPress: menuActions[chip.key] ?? (() => undefined),
114 backgroundColor: chip.key === 'band-panel' ? palette.gold : palette.panel,
115 color: chip.key === 'band-panel' ? palette.ink : palette.parchment,
116 bold: true
117 }))
118 const menuRows = isTap
119 ? [tapButtonsOf($, e, TAP_KEY, menuTaps, columns, { maxRows: TAP_ROWS_MAX })]
120 : rowsOf('band-menu', menuChips, MENU_ROWS, (chip) => (
121 <Button key={chip.key} plain onPress={menuActions[chip.key] ?? (() => undefined)}>
122 <Text
123 backgroundColor={chip.key === 'band-panel' ? palette.gold : palette.panel}
124 color={chip.key === 'band-panel' ? palette.ink : palette.parchment}
125 bold
126 >{` ${chip.label} `}</Text>
127 </Button>
128 ))
129 if (v.state === null) {
130 const art = await animate($, e, pet)
131 return (
132 <Box flexDirection="column">
133 {art.get(PET_KEY)}
134 <Text dimColor wrap="truncate-end">
135 {fitCells(`${text.team} · ${v.note === '' ? text.loading : v.note}`, columns)}
136 </Text>
137 {menuRows}
138 {allies}
139 {beneath}
140 </Box>
141 )
142 }
143 const state = v.state
144 const party = partyOf(state, config.rewards)
145 const counts = countsOf(state.agents)
146 const needs = needsOf(state)
147 const level = config.rewards ? levelOf(v.xp) : undefined
148 const hud = {
149 key: HUD_KEY,
150 columns,
151 rows: HUD_ROWS,
152 draw: (at: number) =>
153 hudCellsOf(currentScene(), party, columns, at, {
154 isHeroWorking: isSceneShown(),
155 level,
156 isDemo: v.isDemo
157 })
158 }
159 const isFramed = columns >= HUD_MIN_COLUMNS
160 const isArtShown = config.mode !== 'off' && e.surface === 'terminal'
161 const art = await animate($, e, isFramed && !isPanelShown ? [...pet, hud] : pet)
162 const worktreeId = state.worktree?.id ?? ''
163 const linkOf = (key: string, url: string, label: string, child: RenderElement) =>
164 url === '' ? child : gameLinkOf($, e, { key, url, worktreeId, label }, child)
165 const pill = (key: string, label: string, color: string, isBold = false) => (
166 <Text key={key} backgroundColor={palette.panel}>
167 {' '}
168 <Text color={color} bold={isBold}>
169 {label}
170 </Text>{' '}
171 </Text>
172 )
173 const chips = stateChipsOf(state, counts, party.length, v.note, v.isLoading)
174 const linked = new Map(chips.filter((one) => one.isLink).map((one) => [one.key, one]))
175 const actions = new Map(
176 (state.actions ?? []).map((one) => [`${ACTION_CHIP_PREFIX}${one.id}`, one])
177 )
178 const chipOf = (chip: PlacedChip): RenderElement => {
179 const link = linked.get(chip.key)
180 const todoAction =
181 link?.actionId === undefined
182 ? undefined
183 : actions.get(`${ACTION_CHIP_PREFIX}${link.actionId}`)
184 if (todoAction !== undefined) {
185 return (
186 <Button
187 key={chip.key}
188 plain
189 onPress={() => void runAction($, todoAction, v.isDemo).catch(() => undefined)}
190 >
191 <Text color={link?.color ?? palette.parchment} backgroundColor={palette.panel}>
192 {` ${chip.label} `}
193 </Text>
194 </Button>
195 )
196 }
197 if (link !== undefined) {
198 return linkOf(
199 chip.key,
200 link.href ?? '',
201 chip.label,
202 <Text
203 key={chip.key}
204 color={link.color ?? palette.parchment}
205 backgroundColor={palette.panel}
206 >
207 {` ${chip.label} `}
208 </Text>
209 )
210 }
211 switch (chip.key) {
212 case 'band-team':
213 return (
214 <Button key={chip.key} plain onPress={() => openFleet($)}>
215 <Text backgroundColor={palette.gold} color={palette.ink} bold>
216 {' ⚑ '}
217 <Text bold>{chip.label.replace(/^⚑ /, '')}</Text>{' '}
218 </Text>
219 </Button>
220 )
221 case 'working':
222 return pill(chip.key, chip.label, 'cyan')
223 case 'done':
224 return pill(chip.key, chip.label, 'green')
225 case 'waiting':
226 return pill(chip.key, chip.label, counts.waiting > 0 ? 'red' : 'gray', counts.waiting > 0)
227 case 'note':
228 return <Text key={chip.key} color="yellow">{` ${chip.label} `}</Text>
229 case MORE_CHIP_KEY:
230 return <Text key={chip.key} dimColor>{` ${chip.label} `}</Text>
231 case 'loading':
232 return <Text key={chip.key} dimColor>{` ${chip.label} `}</Text>
233 default:
234 return pill(chip.key, chip.label, 'gray')
235 }
236 }
237 const actionPressOf = (key: string) => () => {
238 const action = actions.get(key)
239 if (action === undefined) {
240 openFleet($)
241 return
242 }
243 void runAction($, action, v.isDemo).catch(() => undefined)
244 }
245 const tapRow = isTap
246 ? tapButtonsOf(
247 $,
248 e,
249 TAP_KEY,
250 [
251 ...menuTaps,
252 ...actionChipsOf([...actions.values()]).map((chip) => ({
253 key: chip.key,
254 labels: chip.labels,
255 onPress: actionPressOf(chip.key),
256 backgroundColor: palette.panel,
257 color: palette.parchment
258 }))
259 ],
260 columns,
261 {
262 maxRows: TAP_ROWS_MAX,
263 more: {
264 key: 'band-actions-more',
265 labelOf: (count) => `+${count}`,
266 onPress: () => openFleet($),
267 backgroundColor: palette.panel,
268 color: palette.parchment
269 }
270 }
271 )
272 : null
273 // The lead's and the project's buttons; the +N that hides the rest opens the Panel's full row.
274 const actionRows = rowsOf(
275 'band-actions',
276 actionChipsOf([...actions.values()]),
277 ACTION_ROWS,
278 (chip) => (
279 <Button key={chip.key} plain onPress={actionPressOf(chip.key)}>
280 <Text backgroundColor={palette.panel} color={palette.parchment}>
281 {` ${chip.label} `}
282 </Text>
283 </Button>
284 ),
285 (count) => `+${count}`
286 )
287 return (
288 <Box flexDirection="column">
289 {art.get(PET_KEY)}
290 {/* Why: the shown Panel draws the same team; the band keeps the cloud's scene and progress. */}
291 {isPanelShown ? (
292 level !== undefined && (
293 <Box key="band-progress" flexDirection="row" overflow="hidden">
294 <Text color={palette.parchment} backgroundColor={palette.panel} wrap="truncate-end">
295 {fitCells([...(v.isDemo ? ['DEMO'] : []), xpTextOf(level)].join(' · '), columns)}
296 </Text>
297 </Box>
298 )
299 ) : isFlat ? (
300 flatBandRows($, e, { state, palette, columns, level, recent: v.recent, isDemo: v.isDemo })
301 ) : isFramed || !isArtShown ? (
302 art.get(HUD_KEY)
303 ) : (
304 <Box key="band-hud-line" flexDirection="row" overflow="hidden">
305 <Text color={palette.parchment} backgroundColor={palette.panel} wrap="truncate-end">
306 {hudLineOf(party, columns, { level, isDemo: v.isDemo })}
307 </Text>
308 </Box>
309 )}
310 {rowsOf('band-chips', chips, CHIP_ROWS, chipOf, (count) => `+${count}`)}
311 {needs.length > 0 && (
312 <Box key="band-needs" flexDirection="row" overflow="hidden">
313 {config.openLinks === 'system' ? (
314 // Why one Text per part: a system-browser link is a Button, which no Text may hold.
315 needPartsOf(needs, columns).map((part) =>
316 linkOf(
317 part.key,
318 part.url ?? '',
319 part.text,
320 <Text key={part.key} color="red" bold wrap="truncate-end">
321 {part.text}
322 </Text>
323 )
324 )
325 ) : (
326 <Text color="red" bold wrap="truncate-end">
327 {needPartsOf(needs, columns).map((part) =>
328 linkOf(
329 part.key,
330 part.url ?? '',
331 part.text,
332 <Text key={part.key}>{part.text}</Text>
333 )
334 )}
335 </Text>
336 )}
337 </Box>
338 )}
339 {isTap ? tapRow : [...actionRows, ...menuRows]}
340 {allies}
341 {beneath}
342 </Box>
343 )
344 })
345}
346hooks/fleet-pane.tsx 410 lines1import type { EngineInterface, On, RenderElement } from 'claude-code'
2
3import type { GameAgent } from '../types'
4import { advisorCard, advisorCardViewOf } from './advisor-card'
5import { advisorPinsOf, ownAdvisorOf } from './advisor-report'
6import { advisorNow } from './advisor-state'
7import { ADVISOR_TILE_KEY } from './advisor-tile'
8import { countChipsOf } from './band-chips'
9import { flatLine } from './flat-block'
10import { flatPaneMembers } from './flat-rows'
11import { isFlatMode } from './flat-toggle'
12import { paneMenuItemsOf } from './pane-menu'
13import { flatScoreLineOf } from './flat-view'
14import { chipRowsOf, type PlacedChip } from './chip-rows'
15import { memberHeadingOf } from './fleet-labels'
16import { memberCard } from './fleet-member-card'
17import { HUD_ROWS, hudCellsOf } from './fleet-hud'
18import { projectRowsOf, type SectionMarks } from './fleet-project'
19import { reviewSectionOf } from './fleet-review-section'
20import { focusTerminal, openGameLink, viewOf } from './fleet-sync'
21import { TILE_COLUMNS, TILE_ROWS, tileCellsOf } from './fleet-tiles'
22import { leadTitleOf } from './game-lead'
23import { cardStateOf, leadStateOf } from './lead-state'
24import { runAction } from './game-actions'
25import { gameLinkOf } from './game-link'
26import { gameStrings } from './game-strings'
27import { countsOf, memberOf, needsOf } from './party'
28import { animate } from './quest'
29import { currentScene, gameConfig, isSceneShown, ready } from './quest-runtime'
30import { paneLinksOf, type PaneLink } from './pane-links'
31import { projectKeyOf, toggleSection, type PanelSection } from './panel-layout'
32import { changeFleetLayout, hintLayoutOnce, readFleetLayout } from './panel-layout-store'
33import { levelOf } from './rewards'
34import { fleetAdvisorSummaryOf, memberModelLinesOf, memberModelOf } from './member-advisor'
35import { tapButtonsOf, type TapButton } from './tap-buttons'
36import { viewPickerOf } from './view-picker'
37import { isTapLayout } from './tap-layout'
38
39export const PANE_ID = 'orca-game-fleet'
40const HUD_KEY = 'pane-hud'
41// A card's text column: the pane less the tile, the round border and the padding on each side.
42const CARD_CHROME = TILE_COLUMNS + 4
43const FLEET_CHIP_COLORS: Readonly<Record<string, string>> = {
44 'fleet-working': 'cyan',
45 'fleet-idle': 'gray',
46 'fleet-done': 'green'
47}
48
49export async function openPane($: EngineInterface): Promise<void> {
50 const title = gameStrings().text.fleet
51 await $.ui.open({ id: PANE_ID, title, closeOnEscape: true, focus: true })
52 await hintLayoutOnce($).catch(() => undefined)
53}
54
55/** True while the Panel is placed and is the tab its surface shows. */
56export async function isPaneShown($: EngineInterface): Promise<boolean> {
57 const panes = await $.ui.panes().catch(() => [])
58 return panes.some((one) => one.id === PANE_ID && one.isShown && one.isPlaced)
59}
60
61export const registerFleetPane = (on: On): void => {
62 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
63 await ready($)
64 const v = await viewOf($)
65 const config = gameConfig()
66 const scene = currentScene()
67 const palette = scene.band
68 const { Box, Text, Button } = $.ui.resolve(e)
69 const text = gameStrings().text
70 const close = () => void $.ui.close({ id: PANE_ID }).catch(() => undefined)
71 const columns = Math.max(1, Math.min(512, e.props.bodyColumns))
72 const isTap = isTapLayout(columns)
73 const tapOf = (one: Omit<TapButton, 'color' | 'backgroundColor'>): TapButton => ({
74 ...one,
75 color: palette.parchment,
76 backgroundColor: palette.panel
77 })
78 const menu = (key: string, hotkey: string | undefined, label: string, onPress: () => void) => (
79 <Button key={key} plain hotkey={hotkey} onPress={onPress}>
80 <Text backgroundColor={palette.panel} color={palette.parchment} bold>{` ${label} `}</Text>
81 </Button>
82 )
83 // Digits follow tile order; r p q are the menu's, i o c w a g t the links'; actions take the rest.
84 const viewPressOf = (agent: GameAgent, handle: string) => () =>
85 void focusTerminal($, handle).then((isFocused) => {
86 if (!isFocused) {
87 $.ui.toast(text.couldNotOpen(agent.displayName))
88 }
89 })
90 const viewHotkeyOf = (index: number) => (index < 9 ? String(index + 1) : undefined)
91 const view = (agent: GameAgent, handle: string, index: number) => (
92 <Button
93 key={`view-${agent.paneKey}`}
94 plain
95 hotkey={viewHotkeyOf(index)}
96 onPress={viewPressOf(agent, handle)}
97 >
98 <Text backgroundColor={palette.panel} color={palette.parchment} bold>
99 {` ${text.view} `}
100 </Text>
101 </Button>
102 )
103 // On a phone View leaves the card's narrow text column for a chip under it.
104 const tapView = (agent: GameAgent, handle: string, index: number) =>
105 tapButtonsOf(
106 $,
107 e,
108 `view-tap-${agent.paneKey}`,
109 [
110 {
111 ...tapOf({
112 key: `view-${agent.paneKey}`,
113 labels: [`▸ ${text.view}`],
114 hotkey: viewHotkeyOf(index),
115 onPress: viewPressOf(agent, handle)
116 }),
117 bold: true
118 }
119 ],
120 columns
121 )
122 const isFlat = isFlatMode()
123 const menuItems = paneMenuItemsOf($, scene, isFlat, close)
124 const picker = await viewPickerOf($, e, palette, columns)
125 const menuRow = (
126 <Box flexDirection="column">
127 {isTap ? (
128 tapButtonsOf(
129 $,
130 e,
131 'pane-menu-tap',
132 // Close leads on a phone: it is how the pane leaves, and the top rows are what shows.
133 [...menuItems.slice(-1), ...menuItems.slice(0, -1)].map((one) => ({
134 ...tapOf(one),
135 bold: true
136 })),
137 columns
138 )
139 ) : (
140 <Box flexDirection="row" columnGap={1} flexWrap="wrap">
141 {menuItems.map((one) => menu(one.key, one.hotkey, one.labels[0], one.onPress))}
142 </Box>
143 )}
144 {picker}
145 </Box>
146 )
147 const isLead = v.state?.role === 'lead'
148 const cardMembers = (v.state?.agents ?? [])
149 .filter((agent) => agent.isMember === true)
150 .map((agent) => ({
151 key: agent.paneKey,
152 name: memberHeadingOf(agent).name,
153 model: memberModelOf(agent),
154 isJev: Boolean(agent.advisor?.model)
155 }))
156 const advisorView = await advisorCardViewOf($, { isLead, members: cardMembers })
157 const ownJev = isLead ? advisorNow() : null
158 const ownAdvisor = ownJev === null ? undefined : ownAdvisorOf(ownJev, await advisorPinsOf($))
159 const advisorTiles = advisorView === null ? [] : [advisorView.tile]
160 const advisorOf = (art: Map<string, RenderElement>) =>
161 advisorView === null
162 ? null
163 : advisorCard($, e, palette, advisorView.card, art.get(ADVISOR_TILE_KEY), !isFlat)
164 if (v.state === null) {
165 return (
166 <Box flexDirection="column">
167 <Text dimColor>{`${text.fleet} · ${v.note === '' ? text.loading : v.note}`}</Text>
168 {advisorOf(await animate($, e, advisorTiles))}
169 {menuRow}
170 </Box>
171 )
172 }
173 const state = v.state
174 const project = projectKeyOf(state.worktree?.id)
175 const layout = await readFleetLayout($, project)
176 const isCollapsed = (section: PanelSection) => layout.collapsed.includes(section)
177 const togglePressOf = (section: PanelSection) => () =>
178 void changeFleetLayout($, project, (now) => toggleSection(now, section)).catch(
179 () => undefined
180 )
181 // Why a separate mark: the To Do and My PRs headings already open their list or run an action.
182 const marks: SectionMarks = {
183 isCollapsed,
184 markOf: (section) => (
185 <Button key={`layout-toggle-${section}`} plain onPress={togglePressOf(section)}>
186 <Text dimColor>{`${isCollapsed(section) ? '▸' : '▾'} `}</Text>
187 </Button>
188 )
189 }
190 const members = state.agents.map((agent) => ({
191 agent,
192 member: memberOf(agent, state, config.rewards)
193 }))
194 const party = members.filter((one) => !one.agent.isLeader).map((one) => one.member)
195 const level = config.rewards ? levelOf(v.xp) : undefined
196 const lead = leadStateOf(state)
197 const isLeadAwake = lead !== undefined && lead !== 'sleeping'
198 const extras = { isHeroWorking: isSceneShown() || isLeadAwake, level, isDemo: v.isDemo }
199 const art = await animate($, e, [
200 {
201 key: HUD_KEY,
202 columns,
203 rows: HUD_ROWS,
204 draw: (at: number) => hudCellsOf(scene, party, columns, at, extras)
205 },
206 ...members.map(({ member }) => ({
207 key: `tile-${member.key}`,
208 columns: TILE_COLUMNS,
209 rows: TILE_ROWS,
210 draw: (at: number) =>
211 tileCellsOf(scene, member.isDone ? 'handed' : member.phase, member.state, at, member.mood)
212 })),
213 ...advisorTiles
214 ])
215 const counts = countsOf(state.agents)
216 const fleetChipOf = (chip: PlacedChip): RenderElement => {
217 const isTeam = chip.key === 'fleet-team'
218 const color = FLEET_CHIP_COLORS[chip.key] ?? (counts.waiting > 0 ? 'red' : 'gray')
219 return (
220 <Text
221 key={chip.key}
222 backgroundColor={isTeam ? palette.gold : palette.panel}
223 color={isTeam ? palette.ink : color}
224 bold={isTeam || (chip.key === 'fleet-waiting' && counts.waiting > 0)}
225 >
226 {` ${chip.label} `}
227 </Text>
228 )
229 }
230 // On a phone the counts shorten to the band's marks so they keep to one row.
231 const fleetChips = (
232 <Box key="pane-fleet-chips" flexDirection="column" marginTop={1}>
233 {chipRowsOf(
234 [
235 { key: 'team', priority: 0, labels: [`⚑ ${text.fleet}`, '⚑'] as const },
236 ...countChipsOf(counts)
237 ].map((chip) => ({ ...chip, key: `fleet-${chip.key}` })),
238 { columns, maxRows: 1 }
239 ).map((row) => (
240 <Box key={`pane-fleet-${row[0]?.key ?? 'empty'}`} flexDirection="row" columnGap={1}>
241 {row.map(fleetChipOf)}
242 </Box>
243 ))}
244 </Box>
245 )
246 const fleetModels = fleetAdvisorSummaryOf(state.agents.filter((agent) => agent.isMember))
247 const needs = needsOf(state)
248 const cardRoom = Math.max(1, columns - CARD_CHROME)
249 const advisorRowsOf = (agent: GameAgent) => {
250 const advisor = agent.isLeader ? (agent.advisor ?? ownAdvisor) : agent.advisor
251 return memberModelLinesOf({ advisor, launch: agent.launch }, cardRoom).map((line, row) => (
252 <Text key={`jev-${agent.paneKey}-${row}`} color={palette.gold} wrap="wrap">
253 {line}
254 </Text>
255 ))
256 }
257 const worktreeId = state.worktree?.id ?? ''
258 const link = (key: string, url: string, label: string, child: RenderElement) =>
259 gameLinkOf($, e, { key, url, worktreeId, label }, child)
260 const linkPressOf = (one: PaneLink) => () =>
261 one.action === undefined
262 ? void openGameLink($, one.url, worktreeId).then((isOpen) => {
263 if (!isOpen) {
264 $.ui.toast(text.couldNotOpenLink(one.label))
265 }
266 })
267 : void runAction($, one.action, v.isDemo).catch(() => undefined)
268 const linkButton = (one: PaneLink) => (
269 <Button key={one.key} plain hotkey={one.hotkey} onPress={linkPressOf(one)}>
270 <Text backgroundColor={palette.panel} color={palette.parchment}>{` ${one.label} `}</Text>
271 </Button>
272 )
273 const links = paneLinksOf(state)
274 const linksRow = isTap ? (
275 <Box key="pane-links" flexDirection="column">
276 <Text dimColor>{text.openHeading}</Text>
277 {tapButtonsOf(
278 $,
279 e,
280 'pane-links-tap',
281 links.map((one) =>
282 tapOf({
283 key: one.key,
284 hotkey: one.hotkey,
285 labels: [one.label],
286 onPress: linkPressOf(one)
287 })
288 ),
289 columns
290 )}
291 </Box>
292 ) : (
293 <Box key="pane-links" flexDirection="row" columnGap={1} flexWrap="wrap">
294 <Text dimColor>{text.openHeading}</Text>
295 {links.map(linkButton)}
296 </Box>
297 )
298 const projectSections = await projectRowsOf($, e, state, palette, v.isDemo, columns, marks)
299 const flatScore = flatScoreLineOf({ level, recent: v.recent ?? [], isDemo: v.isDemo, columns })
300 const cards = isFlat
301 ? flatPaneMembers($, e, {
302 state,
303 palette,
304 columns,
305 isTap,
306 viewLabel: text.view,
307 pressOf: viewPressOf,
308 hotkeyOf: viewHotkeyOf
309 })
310 : members.flatMap(({ agent, member }, index) => [
311 memberCard($, e, agent, member, {
312 scene,
313 room: cardRoom,
314 leadTitle: agent.isLeader ? leadTitleOf(state, text.lead) : null,
315 cardState: cardStateOf(agent, state),
316 tile: art.get(`tile-${member.key}`),
317 view:
318 agent.terminalHandle === undefined || isTap
319 ? undefined
320 : view(agent, agent.terminalHandle, index),
321 advisorRows: advisorRowsOf(agent),
322 link
323 }),
324 ...(agent.terminalHandle === undefined || !isTap
325 ? []
326 : [tapView(agent, agent.terminalHandle, index)])
327 ])
328 const team = (
329 <Box key="section-team" flexDirection="column">
330 <Box key="layout-header-team" flexDirection="row">
331 {marks.markOf('team')}
332 <Text bold color={counts.waiting > 0 ? 'red' : undefined}>
333 {`${text.team} ${state.agents.length}`}
334 </Text>
335 </Box>
336 {!isCollapsed('team') && cards}
337 {!isCollapsed('team') && advisorOf(art)}
338 </Box>
339 )
340 const drawn: Readonly<Partial<Record<PanelSection, RenderElement>>> = {
341 team,
342 ...reviewSectionOf($, e, state, marks, link),
343 ...projectSections
344 }
345 const shown = layout.order.filter(
346 (section) => !layout.hidden.includes(section) && drawn[section] != null
347 )
348 const sections = shown.map((section) => drawn[section])
349 // A one-cell mark is too small to tap, so a phone also gets a chip per section.
350 const sectionChips = isTap
351 ? tapButtonsOf(
352 $,
353 e,
354 'layout-tap',
355 shown.map((section) =>
356 tapOf({
357 key: `layout-tap-${section}`,
358 labels: [`${isCollapsed(section) ? '▸' : '▾'} ${text.layoutSections[section]}`],
359 onPress: togglePressOf(section)
360 })
361 ),
362 columns
363 )
364 : null
365 return (
366 <Box flexDirection="column">
367 {isFlat ? flatScore !== undefined && flatLine($, e, flatScore, palette) : art.get(HUD_KEY)}
368 {isTap && menuRow}
369 {sectionChips}
370 <Box flexDirection="row" columnGap={1} flexWrap="wrap">
371 {isTap
372 ? fleetChips
373 : [
374 <Text key="fleet" backgroundColor={palette.gold} color={palette.ink} bold>
375 {` ⚑ ${text.fleet} `}
376 </Text>,
377 <Text key="working" color="cyan">
378 {text.working(counts.working)}
379 </Text>,
380 <Text key="idle" color="gray">
381 {text.idle(counts.idle)}
382 </Text>,
383 <Text key="done" color="green">
384 {text.done(counts.done)}
385 </Text>,
386 <Text
387 key="waiting"
388 color={counts.waiting > 0 ? 'red' : 'gray'}
389 bold={counts.waiting > 0}
390 >
391 {text.waiting(counts.waiting)}
392 </Text>
393 ]}
394 {v.note !== '' && <Text color="yellow">{v.note}</Text>}
395 {v.isLoading && <Text dimColor>{text.refreshing}</Text>}
396 {fleetModels !== null && <Text color={palette.gold}>{fleetModels}</Text>}
397 </Box>
398 {needs.map((need) => (
399 <Text key={`need-${need.name}`} color="red" wrap="truncate-end">
400 {` ! ${need.name} ${need.text}`}
401 </Text>
402 ))}
403 {sections}
404 {links.length > 0 && linksRow}
405 {!isTap && menuRow}
406 </Box>
407 )
408 })
409}
410hooks/fleet-sync.ts 265 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface } from 'claude-code'
3
4import type { FleetState, GameState, RecentReward } from '../types'
5import { syncActionCommands } from './action-commands'
6import { ADVISOR_REPORT_ENV, advisorPinsOf, advisorReportOf } from './advisor-report'
7import { readJevAdvisor } from './advisor-state'
8import { cliInvocationOf } from './cli-invocation'
9import {
10 gameFleetArgvOf,
11 gameStateArgvOf,
12 gameViewArgvOf,
13 openLinkArgvOf,
14 tabCreateArgvOf,
15 terminalFocusArgvOf
16} from './game-config'
17import { gameStateOf, parseGameState } from './game-state'
18import type { GameViewId } from './game-view'
19import { gameLocale, gameStrings, setGameLocale } from './game-strings'
20import { raiseTeamEvent, syncHdTeam } from './hd-team'
21import { fleetPhaseOf, partyOf } from './party'
22import { isLeadStarted } from './lead-started'
23import { runningLeadSubagents, withLeadSubagents } from './lead-subagents'
24import { teamEventsOf } from './team-events'
25import {
26 clawdReact,
27 demoNow,
28 gameConfig,
29 quest,
30 questFleet,
31 questLiveConfig
32} from './quest-runtime'
33import { earnedRewardsOf } from './fleet-rewards'
34import { RECENT_KEY, recentRewardsOf, withRecent } from './recent-rewards'
35import { awardXp, levelOf, signedXpOf, xpLedgerOf } from './rewards'
36import { setContributedScenes } from './scenes/index'
37
38export const POLL_EVERY_MS = 30000
39const STUCK_MS = 60000
40const CLI_TIMEOUT_MS = 15000
41const XP_KEY = 'xp-ledger'
42
43const EMPTY: FleetState = { isLoading: false, refreshedAt: 0, state: null, note: '', xp: 0 }
44
45export const fleet = atom({ plugin: 'orca-game', key: 'fleet' } as const, EMPTY)
46
47// Why: a hot reload keeps the atom, so its state may have the shape an older mod stored.
48async function readFleet($: EngineInterface): Promise<FleetState> {
49 const v = await read($, fleet)
50 return { ...v, state: v.state === null ? null : (gameStateOf(v.state) ?? null) }
51}
52
53/** Runs the Orca CLI, through cmd.exe when Orca handed a Windows batch file; undefined when it could not. */
54async function runCli(
55 $: EngineInterface,
56 argv: readonly string[] | undefined,
57 extraEnv: Record<string, string> = {}
58) {
59 if (argv === undefined) {
60 return undefined
61 }
62 const comspec = await $.env.get('ComSpec').catch(() => undefined)
63 const invocation = cliInvocationOf(argv, comspec)
64 if (invocation === undefined) {
65 return undefined
66 }
67 const env = { ...invocation.env, ...extraEnv }
68 const init =
69 Object.keys(env).length > 0 ? { timeoutMs: CLI_TIMEOUT_MS, env } : { timeoutMs: CLI_TIMEOUT_MS }
70 return await $.process.run(invocation.argv, init).catch(() => undefined)
71}
72
73// Module state: whether Orca holds a report from this pane that a Jev gone quiet must take back.
74let hasReportedAdvisor = false
75
76/** Jev's model, effort and Orca's pins for this pane's lead; nothing while Jev never published. */
77async function advisorEnvOf($: EngineInterface): Promise<Record<string, string>> {
78 const advisor = await readJevAdvisor($, quest.tick).catch(() => null)
79 if (advisor === null && !hasReportedAdvisor) {
80 return {}
81 }
82 const pins = await advisorPinsOf($)
83 hasReportedAdvisor = advisor !== null
84 return { [ADVISOR_REPORT_ENV]: advisorReportOf(advisor, pins) }
85}
86
87async function readState($: EngineInterface): Promise<GameState | undefined> {
88 const argv = gameStateArgvOf(await $.env.get('ORCA_GAME_CLI').catch(() => undefined))
89 const out = await runCli($, argv, await advisorEnvOf($))
90 return out?.exitCode === 0 ? parseGameState(out.stdout) : undefined
91}
92
93/** What a fresh `orca game state --json` answered, for a report the person asked for. */
94export type GameStateOutput =
95 | { kind: 'missing' }
96 | { kind: 'failed'; reason: string }
97 | { kind: 'ok'; stdout: string }
98
99export async function readGameStateOutput($: EngineInterface): Promise<GameStateOutput> {
100 const argv = gameStateArgvOf(await $.env.get('ORCA_GAME_CLI').catch(() => undefined))
101 if (argv === undefined) {
102 return { kind: 'missing' }
103 }
104 const out = await runCli($, argv)
105 if (out === undefined) {
106 return { kind: 'failed', reason: `${argv[0]} could not be started` }
107 }
108 if (out.exitCode !== 0) {
109 const line = (out.stderr.trim() || out.stdout.trim()).split('\n')[0]?.trim() ?? ''
110 return { kind: 'failed', reason: `exit ${out.exitCode}${line === '' ? '' : `: ${line}`}` }
111 }
112 return { kind: 'ok', stdout: out.stdout }
113}
114
115/** What `orca game fleet` prints for this pane's project; undefined when Orca could not answer. */
116export async function readFleetText($: EngineInterface): Promise<string | undefined> {
117 const cli = await $.env.get('ORCA_GAME_CLI').catch(() => undefined)
118 const out = await runCli($, gameFleetArgvOf(cli))
119 return out?.exitCode === 0 && out.stdout.trim() !== '' ? out.stdout.trim() : undefined
120}
121
122/** Brings a teammate's Orca pane forward; false when Orca could not. */
123export async function focusTerminal($: EngineInterface, handle: string): Promise<boolean> {
124 const cli = await $.env.get('ORCA_GAME_CLI').catch(() => undefined)
125 const out = await runCli($, terminalFocusArgvOf(cli, handle))
126 return out?.exitCode === 0
127}
128
129/** Opens a link where the project asks: the system browser, else (or when that fails) an Orca tab. */
130export async function openGameLink(
131 $: EngineInterface,
132 url: string,
133 worktreeId: string
134): Promise<boolean> {
135 const cli = await $.env.get('ORCA_GAME_CLI').catch(() => undefined)
136 const external = gameConfig().openLinks === 'system' ? openLinkArgvOf(cli, url) : undefined
137 if (external !== undefined) {
138 const out = await runCli($, external)
139 // Why fall back: an older Orca or a host with no desktop has no system browser to offer.
140 if (out?.exitCode === 0) {
141 return true
142 }
143 }
144 const out = await runCli($, tabCreateArgvOf(cli, url, worktreeId))
145 return out?.exitCode === 0
146}
147
148/** Asks Orca to store a view for this project or every project; false when it could not. */
149export async function storeGameView(
150 $: EngineInterface,
151 view: GameViewId,
152 scope: 'project' | 'global'
153): Promise<boolean> {
154 const cli = await $.env.get('ORCA_GAME_CLI').catch(() => undefined)
155 const out = await runCli($, gameViewArgvOf(cli, view, scope))
156 return out?.exitCode === 0
157}
158
159type Score = { xp: number; recent: readonly RecentReward[] }
160
161async function syncXp(
162 $: EngineInterface,
163 before: GameState | null,
164 next: GameState
165): Promise<Score> {
166 const [stored, kept] = await Promise.all([
167 $.store.get(XP_KEY).then(xpLedgerOf, () => xpLedgerOf(undefined)),
168 $.store.get(RECENT_KEY).then(recentRewardsOf, () => [])
169 ])
170 const earned = earnedRewardsOf(before, next)
171 const now = await $.clock.now()
172 const { ledger, awarded } = awardXp(stored, earned, now)
173 if (awarded.length === 0) {
174 return { xp: stored.total, recent: kept }
175 }
176 const recent = withRecent(kept, awarded, now)
177 await $.store.set(XP_KEY, ledger).catch(() => undefined)
178 await $.store.set(RECENT_KEY, recent).catch(() => undefined)
179 for (const one of awarded) {
180 $.ui.toast(`${signedXpOf(one.xp)} XP · ${one.label}`)
181 }
182 const art = gameStrings().art
183 const merged = awarded.find((one) => one.kind === 'review-merged')?.review
184 if (merged !== undefined) {
185 raiseTeamEvent($, 'merged', art.mergedFloat(merged))
186 } else {
187 const sum = awarded.reduce((total, one) => total + one.xp, 0)
188 raiseTeamEvent($, 'xp', `${signedXpOf(sum)} ${art.xp}`)
189 }
190 if (levelOf(ledger.total).level > levelOf(stored.total).level) {
191 clawdReact($, 'level')
192 }
193 return { xp: ledger.total, recent }
194}
195
196export async function refresh($: EngineInterface): Promise<void> {
197 const before = await readFleet($)
198 const startedAt = await $.clock.now()
199 await update($, fleet, (s) => ({ ...s, isLoading: true, refreshedAt: startedAt }))
200 const next = await readState($)
201 if (next !== undefined) {
202 if (setContributedScenes(next.extensions.scenes.map((one) => one.definition))) {
203 $.ui.invalidate('ui.render')
204 }
205 // Why: ORCA_GAME_LOCALE is fixed at launch; the state carries a UI language changed since.
206 if (next.locale !== undefined && next.locale !== gameLocale()) {
207 setGameLocale(next.locale)
208 $.ui.invalidate('ui.render')
209 }
210 questLiveConfig($, next.config, next.sources)
211 }
212 const config = gameConfig()
213 const kept: Score = { xp: before.xp, recent: before.recent ?? [] }
214 const { xp, recent } =
215 next !== undefined && config.rewards
216 ? await syncXp($, before.state, next).catch(() => kept)
217 : kept
218 const note = next === undefined ? gameStrings().text.unavailable : (next.warnings[0] ?? '')
219 const doneAt = await $.clock.now()
220 await update($, fleet, (s) => ({
221 ...s,
222 isLoading: false,
223 refreshedAt: doneAt,
224 state: next ?? s.state,
225 note,
226 xp,
227 recent
228 }))
229 quest.clockMs = doneAt
230 if (next !== undefined) {
231 syncHdTeam(next, config.rewards)
232 for (const one of teamEventsOf(before.state, next)) {
233 raiseTeamEvent($, one.kind, one.text)
234 }
235 await syncActionCommands($, next.actions ?? []).catch(() => undefined)
236 const waiting = next.agents.find((agent) => !agent.isLeader && agent.state === 'waiting')
237 questFleet($, fleetPhaseOf(partyOf(next, config.rewards)), waiting?.displayName ?? '')
238 }
239}
240
241export async function refreshSoon($: EngineInterface): Promise<void> {
242 if (quest.isDemo) {
243 return
244 }
245 const state = await read($, fleet)
246 if (state.isLoading && (await $.clock.now()) - state.refreshedAt < STUCK_MS) {
247 return
248 }
249 void refresh($).catch(() => undefined)
250}
251
252export type FleetView = FleetState & { isDemo: boolean }
253
254export async function viewOf($: EngineInterface): Promise<FleetView> {
255 const v = await readFleet($)
256 if (!quest.isDemo) {
257 const now = await $.clock.now()
258 const state =
259 v.state && withLeadSubagents(v.state, runningLeadSubagents, now, await isLeadStarted($))
260 return { ...v, state, isDemo: false }
261 }
262 const frame = demoNow()
263 return { ...v, state: frame.state, xp: frame.xp, note: '', isLoading: false, isDemo: true }
264}
265hooks/quest.tsx 324 lines1import type { EngineInterface, On, RenderElement, RenderInput } from 'claude-code'
2
3import {
4 GAME_COMMANDS,
5 QUEST_ALIAS,
6 VIEW_COMMAND,
7 gameCommandOf,
8 type GameCommand
9} from './game-commands'
10import { cardKeyOf, isHumanOrigin, isWorkingNote, noteKey } from './quest-policy'
11import { briefPathOf } from './brief-line'
12import {
13 configuredConfig,
14 currentScene,
15 gameConfig,
16 publishAbsorbing,
17 quest,
18 ready,
19 resetQuest,
20 shownPhase,
21 stageCellsOf,
22 stageOf,
23 startDemo,
24 type Animated
25} from './quest-runtime'
26import { gameStrings, localizedNameOf } from './game-strings'
27import { SCENE_KEY, sceneColumnsOf } from './scene'
28import { forgetFrames, rememberFrame } from './frame-gate'
29import { isSceneMode } from './quest-policy'
30import { openArcade } from './arcade-pane'
31import { arcadeWords } from './arcade-words'
32import { flatLine } from './flat-block'
33import { dropScenes } from './flat-toggle'
34import { flatPhaseLineOf } from './flat-view'
35import { shownCaption } from './quest-state'
36import { viewCommandOf, viewListOf } from './view-command'
37import { currentView, switchView, viewChoicesOf } from './view-switch'
38
39const isOff = (): boolean => gameConfig().mode === 'off'
40const isSceneDrawn = (): boolean => isSceneMode(gameConfig().mode)
41
42// Why: `game state` knows only Orca's layers, so a /quest value it does not report says it is this pane's.
43const shownSetting = (key: 'mode' | 'render'): string => {
44 const value = gameConfig()[key]
45 const configured = configuredConfig()[key]
46 return value === configured
47 ? value
48 : gameStrings().text.sessionOverride(value, configured, quest.liveSources[key])
49}
50
51function hidden($: EngineInterface, e: RenderInput) {
52 const { Box } = $.ui.resolve(e)
53 return <Box display="none" />
54}
55
56export async function animate(
57 $: EngineInterface,
58 e: RenderInput,
59 items: readonly Animated[]
60): Promise<Map<string, RenderElement>> {
61 await ready($)
62 if (!isSceneDrawn() || e.surface !== 'terminal' || items.length === 0) {
63 quest.animations.delete(e.requestId)
64 forgetFrames(e.requestId)
65 return new Map()
66 }
67 quest.animations.set(e.requestId, items)
68 const { Raster } = $.ui.resolve(e)
69 return new Map(
70 items.map((item) => {
71 const cells = item.draw(quest.tick)
72 rememberFrame(e.requestId, item.key, cells)
73 return [
74 item.key,
75 <Raster key={item.key} columns={item.columns} rows={item.rows} cells={cells} />
76 ]
77 })
78 )
79}
80
81export const fleetColumnsOf = (e: RenderInput): number => sceneColumnsOf(e.viewport?.columns)
82
83export async function questSessionStart($: EngineInterface): Promise<void> {
84 resetQuest()
85 await ready($)
86 await $.command
87 .register({
88 name: QUEST_ALIAS,
89 description: gameStrings().text.questDescription,
90 argumentHint: '[off|on|zen|flat|demo|demo arcade|render classic|hd|hd-compact]',
91 immediate: true
92 })
93 .catch(() => undefined)
94}
95
96const registerCommand = (on: On, command: GameCommand) => {
97 on('command.run', { command }, async ($, e) => {
98 await ready($)
99 const asked = gameCommandOf(command, e.args)
100 const text = gameStrings().text
101 if ('error' in asked) {
102 return { text: asked.error }
103 }
104 if ('show' in asked) {
105 const mode = quest.isDemo ? 'demo' : shownSetting('mode')
106 const render = text.questRenderShown(shownSetting('render'))
107 return { text: `${text.questShown(mode)} ${render} ${text.questModesHelp}` }
108 }
109 if ('render' in asked) {
110 quest.renderOverride = asked.render
111 $.ui.invalidate('ui.render')
112 return { text: text.questRenderShown(shownSetting('render')) }
113 }
114 if ('arcade' in asked) {
115 await openArcade($)
116 return { text: arcadeWords().opened }
117 }
118 if ('demo' in asked) {
119 startDemo()
120 $.ui.invalidate('ui.render')
121 await publishAbsorbing($)
122 return { text: text.questDemo }
123 }
124 quest.isDemo = false
125 quest.modeOverride = asked.mode
126 if (!isSceneMode(asked.mode)) {
127 dropScenes()
128 }
129 $.ui.invalidate('ui.render')
130 await publishAbsorbing($)
131 return { text: text.questShown(shownSetting('mode')) }
132 })
133}
134
135const registerView = (on: On) => {
136 on('command.run', { command: VIEW_COMMAND }, async ($, e) => {
137 await ready($)
138 const asked = viewCommandOf(e.args)
139 if ('error' in asked) {
140 return { text: asked.error }
141 }
142 if ('list' in asked) {
143 const { scopes, suggested } = await viewChoicesOf($)
144 return { text: viewListOf(currentView(), suggested, scopes) }
145 }
146 return { text: await switchView($, asked.view, asked.scope) }
147 })
148}
149
150// Zen hides short notes written between tool calls; this learns which text was one.
151const registerNotes = (on: On) => {
152 on('turn.step', async function* ($, e, next) {
153 const stream = next(e)
154 const blocks = new Map<number, string>()
155 for await (const chunk of stream) {
156 if (chunk.kind === 'text') {
157 blocks.set(chunk.index, (blocks.get(chunk.index) ?? '') + chunk.text)
158 }
159 yield chunk
160 }
161 const result = await stream.result
162 if (e.agentId !== undefined) {
163 return result
164 }
165 let isChanged = false
166 for (const text of [...blocks.values(), result.answer]) {
167 const key = noteKey(text)
168 if (key === '') {
169 continue
170 }
171 if (isWorkingNote(result, text)) {
172 if (!quest.replies.has(key) && !quest.notes.has(key)) {
173 quest.notes.add(key)
174 isChanged = true
175 }
176 continue
177 }
178 if (!quest.replies.has(key)) {
179 quest.replies.add(key)
180 isChanged = true
181 }
182 isChanged = quest.notes.delete(key) || isChanged
183 }
184 if (isChanged && gameConfig().mode === 'zen') {
185 $.ui.invalidate('ui.render')
186 }
187 return result
188 })
189}
190
191const registerSpinner = (on: On) => {
192 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
193 await ready($)
194 if (!isSceneDrawn() || e.surface !== 'terminal') {
195 quest.stages.delete(e.requestId)
196 forgetFrames(e.requestId)
197 if (isOff() || e.surface !== 'terminal') {
198 return next(e)
199 }
200 // Flat keeps what the hero shows, the phase's card and the caption, as one line of text.
201 const scene = currentScene()
202 const columns = sceneColumnsOf(e.viewport?.columns)
203 const line = flatPhaseLineOf(scene, shownPhase(), shownCaption(), columns)
204 const spinner = await next(e)
205 const { Box } = $.ui.resolve(e)
206 return (
207 <Box flexDirection="column">
208 {flatLine($, e, line, scene.band)}
209 {spinner}
210 </Box>
211 )
212 }
213 const stage = stageOf(sceneColumnsOf(e.viewport?.columns), e.viewport?.rows)
214 quest.stages.set(e.requestId, stage)
215 const cells = stageCellsOf(stage)
216 rememberFrame(e.requestId, SCENE_KEY, cells)
217 const spinner = await next(e)
218 const { Box, Raster } = $.ui.resolve(e)
219 return (
220 <Box flexDirection="column">
221 <Raster key={SCENE_KEY} columns={stage.columns} rows={stage.rows} cells={cells} />
222 {spinner}
223 </Box>
224 )
225 })
226}
227
228const registerQuietRows = (on: On) => {
229 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
230 await ready($)
231 if (isOff() || e.props.isErrored || e.props.isInterrupted) {
232 return next(e)
233 }
234 return hidden($, e)
235 })
236 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
237 await ready($)
238 return isOff() || e.props.isErrored ? next(e) : hidden($, e)
239 })
240 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
241 await ready($)
242 if (isOff() || e.props.calls.some((call) => call.isErrored)) {
243 return next(e)
244 }
245 return hidden($, e)
246 })
247 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
248 await ready($)
249 return isOff() ? next(e) : hidden($, e)
250 })
251}
252
253const registerCards = (on: On) => {
254 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
255 await ready($)
256 const key = noteKey(e.props.text)
257 const isNote = quest.notes.has(key) && !quest.replies.has(key)
258 if (gameConfig().mode === 'zen' && isNote) {
259 return hidden($, e)
260 }
261 const drawn = await next(e)
262 if (isOff() || key === '') {
263 return drawn
264 }
265 // A card keeps the phase it was first drawn in, so scrolling back never repaints it.
266 const cardKey = cardKeyOf(e.requestId, e.props.text)
267 if (!quest.cardPhases.has(cardKey)) {
268 quest.cardPhases.set(cardKey, shownPhase() ?? 'working')
269 }
270 const card = currentScene().cards[quest.cardPhases.get(cardKey) ?? 'working']
271 const { Box, Text } = $.ui.resolve(e)
272 return (
273 <Box flexDirection="column" borderStyle="round" borderColor={card.color} paddingX={1}>
274 {e.props.isFirstOfReply ? (
275 <Text bold color={card.color}>{`${card.icon} ${localizedNameOf(card.name)}`}</Text>
276 ) : (
277 <Box />
278 )}
279 {drawn}
280 </Box>
281 )
282 })
283 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
284 await ready($)
285 if (isOff()) {
286 return next(e)
287 }
288 if (!isHumanOrigin(e.props.origin.kind)) {
289 return hidden($, e)
290 }
291 const teller = currentScene().teller
292 const { Box, Text } = $.ui.resolve(e)
293 // Why not when expanded: ctrl+o shows the line Orca typed, as for any compact row.
294 const brief = e.props.isExpanded ? null : briefPathOf(e.props.text)
295 if (brief !== null) {
296 return (
297 <Box flexDirection="column" borderStyle="round" borderColor={teller.color} paddingX={1}>
298 <Text bold color={teller.color}>{`📜 ${gameStrings().text.briefReceived}`}</Text>
299 <Text dimColor>{brief}</Text>
300 </Box>
301 )
302 }
303 const drawn = await next(e)
304 return (
305 <Box flexDirection="column" borderStyle="round" borderColor={teller.color} paddingX={1}>
306 <Text bold color={teller.color}>{`${teller.icon} ${localizedNameOf(teller.name)}`}</Text>
307 {drawn}
308 </Box>
309 )
310 })
311}
312
313export const registerQuest = (on: On): void => {
314 const commands: readonly GameCommand[] = [QUEST_ALIAS, ...GAME_COMMANDS]
315 for (const command of commands) {
316 registerCommand(on, command)
317 }
318 registerView(on)
319 registerNotes(on)
320 registerSpinner(on)
321 registerQuietRows(on)
322 registerCards(on)
323}
324hooks/lead-autostart.ts 44 lines1import type { EngineInterface } from 'claude-code'
2
3import { refresh, viewOf } from './fleet-sync'
4import { LEAD_SKILL, isLeadAutostart } from './game-lead'
5import { STARTED_KEY, startedOf } from './lead-started'
6
7const STARTED_MAX = 50
8
9/** Records that this session runs the lead rules; true when it already did. */
10export async function markLeadStarted($: EngineInterface): Promise<boolean> {
11 const session = await $.session.id()
12 const started = startedOf(await $.store.get(STARTED_KEY).catch(() => undefined))
13 if (started.includes(session)) {
14 return true
15 }
16 await $.store.set(STARTED_KEY, [...started.slice(1 - STARTED_MAX), session])
17 return false
18}
19
20/** Forgets this session as the lead: `game lead` named another pane instead. */
21export async function unmarkLeadStarted($: EngineInterface): Promise<void> {
22 const session = await $.session.id()
23 const started = startedOf(await $.store.get(STARTED_KEY).catch(() => undefined))
24 if (started.includes(session)) {
25 await $.store.set(
26 STARTED_KEY,
27 started.filter((one) => one !== session)
28 )
29 }
30}
31
32/** Loads the lead rules once per session, in a project whose orca.yaml asks for it. */
33export async function leadAutostart($: EngineInterface): Promise<void> {
34 await refresh($)
35 const { state } = await viewOf($)
36 if (state === null || !isLeadAutostart(state)) {
37 return
38 }
39 if (await markLeadStarted($)) {
40 return
41 }
42 await $.command.run({ command: LEAD_SKILL, args: '' })
43}
44hooks/lead-brief.ts 63 lines1import type { EngineInterface, On } from 'claude-code'
2
3import { readFleetText, refresh, viewOf } from './fleet-sync'
4import { LEAD_SKILL } from './game-lead'
5import { isLeadStarted } from './lead-started'
6
7/** The session starts after which a lead no longer sees its members; a fresh start runs `game lead`. */
8export type LeadBriefSource = 'resume' | 'compact'
9
10export const isLeadBriefSource = (source: unknown): source is LeadBriefSource =>
11 source === 'resume' || source === 'compact'
12
13/** What a lead reads first when it comes back: its members now, paused ones included. */
14export const leadBriefOf = (source: LeadBriefSource, fleet: string): string =>
15 [
16 `Orca: this lead conversation was ${source === 'resume' ? 'resumed' : 'compacted'}. Your members now (\`game fleet\`); answer about them from this or a fresh read, never from memory:`,
17 '',
18 fleet,
19 ...(source === 'compact'
20 ? [
21 '',
22 'If the summary lost your standing rules, run `"$ORCA_GAME_CLI" game lead` again before acting.'
23 ]
24 : [])
25 ].join('\n')
26
27/** Whether this pane is the project's started lead, with no other live lead. */
28async function isReturningLead($: EngineInterface): Promise<boolean> {
29 if (!(await isLeadStarted($))) {
30 return false
31 }
32 await refresh($)
33 const { state } = await viewOf($)
34 return state?.role === 'lead' && state.leadElsewhere === undefined
35}
36
37/** The brief for this pane's started lead; undefined for any other session or when Orca cannot answer. */
38export async function leadBriefContextOf(
39 $: EngineInterface,
40 source: LeadBriefSource
41): Promise<string | undefined> {
42 const fleet = await readFleetText($)
43 return fleet === undefined ? undefined : leadBriefOf(source, fleet)
44}
45
46export const registerLeadBrief = (on: On): void => {
47 on('classic.SessionStart', async ($, e, next) => {
48 const answered = await next(e)
49 const source = e.source
50 if (!isLeadBriefSource(source) || !(await isReturningLead($).catch(() => false))) {
51 return answered
52 }
53 if (source === 'resume') {
54 // Why: a restart killed the lead's report watcher, and its rules say whether to resume members.
55 void $.command.run({ command: LEAD_SKILL, args: '' }).catch(() => undefined)
56 }
57 const brief = await leadBriefContextOf($, source).catch(() => undefined)
58 return brief === undefined
59 ? answered
60 : { ...answered, additionalContext: [...(answered.additionalContext ?? []), brief] }
61 })
62}
63