SLOPSHOPPER

orca-game

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…

panebandspinnerrowsguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · orca-game
│ ┃ orca-game-fleet ✕ ╭─────────────────────────────────────────────────────────────────╮ │ ┃ Fleet · orca game state unavailable │ │ │ ┃ │ ⟨Claude Code's own drawing⟩ │ │ ┃ q: quit r: refresh 0: flat view p: pet ╰─────────────────────────────────────────────────────────────────╯ │ │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ╭───────────────────────────────────────────────────────────────────╮ │ │ ⟨Claude Code's own drawing⟩ │ │ ╰───────────────────────────────────────────────────────────────────╯ │ │ │ › /quest │ ⎿ orca-game: Quest: on. Render: classic. /orca-game:mode on|zen|fl │ │ ▐ o ▌▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ✻ Thinking… Team · orca game state unavailable band-panel band-refresh band-flat band-pet ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Team · orca game state unavailable band-panel band-refresh band-flat band-pet
Pane · orca-game-fleet
Fleet · orca game state unavailable q: quit r: refresh 0: flat view p: pet
Pane · orca-game-arcade
Arcade · practice
Spinner
▐ o ▌▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ✻ Thinking…
Your message
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ │ ⟨Claude Code's own drawing⟩ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
Claude's reply
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ⟨Claude Code's own drawing⟩ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

orca-game

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.

  • Quest mode. The spinner becomes a pixel-art scene: Clawd walks a parallax landscape dressed as the character of the current flow phase, with a thought bubble saying what it is doing. Each reply comes in a card signed by its phase's character, your own messages in a Storyteller card, tool rows are hidden unless they fail. on (default), zen (also hides short working notes) or off.
  • Flat mode. 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.
  • The band above the prompt: a HUD with the lead as the hero and every teammate in the party (walking while it works, hopping with 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).
  • The Fleet pane (Panel): the HUD at full width and one card per agent with its own animated tile and activity link, then the review and the work item as links. Open takes 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.
  • Rewards. XP and level-ups, kept in the mod's $.store across sessions.
  • Allies. Other plugins keep running: their $.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).
  • The cast. A scene's 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).
  • hd. 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).

Origin

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.

How Orca loads it

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"

Configuration

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:

VariableValuesDefaultEffect
ORCA_GAME_MODEon, zen, flat, offonQuest mode
ORCA_GAME_SCENEa scene idquestArt set; an unknown id falls back to quest
ORCA_GAME_HUD1/0, true/false10 hides the band
ORCA_GAME_REWARDS1/0, true/false10 turns off XP, level-ups and the cheer confetti
ORCA_GAME_RENDERclassic, hdclassicThe 5-row pixel art, or the 9 to 10-row 2D-HD scene
ORCA_GAME_TIMEZONElocal or an IANA zonelocalThe clock hd's day and night follow
ORCA_GAME_LOCALEen, esenLanguage 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.

Commands: /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.

CommandWhat it does
/orca-game:stateThe 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:demoA 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:leadWrite 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:.

Fleet data: orca game state --json

Polled 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 }] }
}

Phases and rewards

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.

Adding a scene

  1. Add 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.
  2. Add it to 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.
  3. Select it with 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.

Development

  • Pure logic tests (vitest, part of Orca's 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.
  • Engine tests (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.
  • Types: there is no command to generate them. Claude Code writes .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.
Source 91 files
hooks/register.tsx 116 lines
1import 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}
116
hooks/action-commands.ts 59 lines
1import 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}
59
hooks/arcade-pane.tsx 160 lines
1import 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}
160
hooks/advisor-state.ts 150 lines
1import { 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}
150
hooks/advisor-sync.ts 45 lines
1import 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}
45
hooks/allies-strip.tsx 131 lines
1import { 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}
131
hooks/band.tsx 346 lines
1import 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}
346
hooks/fleet-pane.tsx 410 lines
1import 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}
410
hooks/fleet-sync.ts 265 lines
1import { 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}
265
hooks/quest.tsx 324 lines
1import 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}
324
hooks/lead-autostart.ts 44 lines
1import 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}
44
hooks/lead-brief.ts 63 lines
1import 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