From caa4228e0fa9eabae0f3367c16ff0a0dddd960a3 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:57:57 -0400 Subject: [PATCH 01/21] fix(runtime): report an open agent question as blocked to tui-idle waits (#24533) OpenCode's question tool (and Pi/OMP ask tools and custom modals) put the hook row in a waiting state but paint no dialog wording the blocked-text layer knows, so the hook lane read the wait as pending and the tui-idle wait timed out with no blocked reason. For agents whose hooks are authoritative, a wait the hook reports with no recognised dialog text now blocks with the existing agent-interactive-prompt reason, unless input reached the pane after the row (it may have answered the question before the next hook arrived). --- .../tui-idle-hook-lane-runtime.test.ts | 26 +++++++++++++++++++ src/main/runtime/tui-idle-hook-lane.test.ts | 18 +++++++++++++ src/main/runtime/tui-idle-hook-lane.ts | 18 ++++++++++--- 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/main/runtime/tui-idle-hook-lane-runtime.test.ts b/src/main/runtime/tui-idle-hook-lane-runtime.test.ts index 312bd3b8e67..1e18c9096a1 100644 --- a/src/main/runtime/tui-idle-hook-lane-runtime.test.ts +++ b/src/main/runtime/tui-idle-hook-lane-runtime.test.ts @@ -27,6 +27,15 @@ const OPENCODE_PERMISSION_DIALOG = [ ' Allow once Allow always Reject ctrl+f fullscreen ⇆ select enter confirm', '' ].join('\r\n') +// OpenCode 1.18's question tool, which paints no dialog wording the blocked layer knows. +const OPENCODE_QUESTION = [ + ' ┃ Which color do you prefer?', + ' ┃ 1. Red', + ' ┃ 2. Blue', + ' ┃ 3. Type your own answer', + ' ┃ ↑↓ select enter submit esc dismiss', + '' +].join('\r\n') // Shorter than the 2 s poll, so only the synchronous verdict can settle a wait. const WAIT_MS = 150 @@ -194,4 +203,21 @@ describe('tui-idle hook lane through the runtime', () => { }) ).toBe('ready') }) + + it('blocks on an open question the hook reports, and settles once it is answered', async () => { + const options = { launchAgent: 'opencode' as const, data: OPENCODE_QUESTION } + expect(await waitOutcome({ ...options, rows: () => [] })).toBe('timeout') + expect( + await waitOutcome({ + ...options, + rows: () => [row({ agentType: 'opencode', state: 'waiting', receivedAt: Date.now() + 1 })] + }) + ).toBe('blocked:agent-interactive-prompt') + expect( + await waitOutcome({ + ...options, + rows: () => [row({ agentType: 'opencode', receivedAt: Date.now() + 1 })] + }) + ).toBe('ready') + }) }) diff --git a/src/main/runtime/tui-idle-hook-lane.test.ts b/src/main/runtime/tui-idle-hook-lane.test.ts index 87683f266cf..e9fe3a88587 100644 --- a/src/main/runtime/tui-idle-hook-lane.test.ts +++ b/src/main/runtime/tui-idle-hook-lane.test.ts @@ -160,6 +160,24 @@ describe('readTuiIdleHookTurn', () => { ).toEqual(WORKING) }) + it('blocks on a wait the hook reports with no dialog text the arbiter knows (a question)', () => { + const receivedAt = Date.now() - 1000 + for (const state of ['waiting', 'blocked'] as const) { + expect(readTuiIdleHookTurn({ ...base, hookRows: [row({ receivedAt, state })] })).toEqual({ + state: 'permission', + blockedReason: 'agent-interactive-prompt' + }) + } + // Input since the wait opened may have answered it before the next hook arrived. + expect( + readTuiIdleHookTurn({ + ...base, + hookRows: [row({ receivedAt, state: 'waiting' })], + lastInputAt: receivedAt + 1 + }) + ).toEqual({ state: 'permission', blockedReason: null }) + }) + it('takes the newest joined row', () => { const now = Date.now() expect( diff --git a/src/main/runtime/tui-idle-hook-lane.ts b/src/main/runtime/tui-idle-hook-lane.ts index 04bfaa1bbdd..492419afb82 100644 --- a/src/main/runtime/tui-idle-hook-lane.ts +++ b/src/main/runtime/tui-idle-hook-lane.ts @@ -8,7 +8,8 @@ type HookTurnState = 'done' | 'working' | 'permission' /** * The main agent's turn as the hook server's store last saw it for one pane, and the permission - * arbiter's verdict on the pane's blocked text with that turn as its explicit status. + * arbiter's verdict on the pane's blocked text with that turn as its explicit status (or, for a + * wait the hook reports with no text the arbiter knows, a generic interactive prompt). */ export type TuiIdleHookTurn = { state: HookTurnState @@ -65,10 +66,19 @@ export function readTuiIdleHookTurn(read: TuiIdleHookTurnRead): TuiIdleHookTurn return null } const state = hookLeadTurnState(row) - if (state === null || (state === 'done' && row.receivedAt < (read.lastInputAt ?? -1))) { + const predatesInput = row.receivedAt < (read.lastInputAt ?? -1) + if (state === null || (state === 'done' && predatesInput)) { return null } - return { state, blockedReason: read.resolveBlockedText(state, row) } + const blockedReason = read.resolveBlockedText(state, row) + // Why the hook alone blocks: a question or custom modal paints no dialog text the arbiter knows, + // and these hooks report its answer. Input since may have answered it before the hook arrived. + return { + state, + blockedReason: + blockedReason ?? + (state === 'permission' && !predatesInput ? 'agent-interactive-prompt' : null) + } } /** The tui-idle verdicts the hook lane can reach (a subset of `TuiIdleVerdict`). */ @@ -104,7 +114,7 @@ export function evaluateHookTurn( case 'working': return { kind: 'working' } case 'permission': - // Why pending: the arbiter saw no prompt in the tail, so the poll's screen read decides. + // Why pending: input after the wait opened may have answered it; the next hook decides. return { kind: 'pending', quietForeground: 'closed' } } } From 4ea6673dba8ed1ea65fb162d93ee097c43ea30fe Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:58:49 -0400 Subject: [PATCH 02/21] fix(runtime): read Codex's 'Implement this plan?' menu as blocked (#24536) After a Plan-mode turn Codex shows a menu that owns the keyboard, but its Stop hook has already fired, so a tui-idle wait settled ready and sent text into the menu. A Codex blocked text anchor now names it an interactive prompt while its key row ends the tail, written against a new 0.160.0 capture that is replayed by the readiness census. --- ...odex-0-160-0-plan-implement-menu.meta.json | 9 +++++ .../codex-0-160-0-plan-implement-menu.txt | 1 + ...dex-0-160-0-plan-implement-menu@agent.json | 36 +++++++++++++++++++ ...x-0-160-0-plan-implement-menu@unknown.json | 35 ++++++++++++++++++ src/main/runtime/agent-state-rules/codex.json | 15 ++++++++ 5 files changed, 96 insertions(+) create mode 100644 src/main/runtime/__fixtures__/codex-0-160-0-plan-implement-menu.meta.json create mode 100644 src/main/runtime/__fixtures__/codex-0-160-0-plan-implement-menu.txt create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--codex-0-160-0-plan-implement-menu@agent.json create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--codex-0-160-0-plan-implement-menu@unknown.json diff --git a/src/main/runtime/__fixtures__/codex-0-160-0-plan-implement-menu.meta.json b/src/main/runtime/__fixtures__/codex-0-160-0-plan-implement-menu.meta.json new file mode 100644 index 00000000000..75d232e1347 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0-160-0-plan-implement-menu.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-10-02T05:46:28.602Z", + "platform": "darwin", + "command": ["codex", "--no-daemon", "-c", "check_for_update_on_startup=false"], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.160.0, isolated HOME/CODEX_HOME with the project pre-trusted. A Plan-mode turn ends in the 'Implement this plan?' menu, which owns the screen until '3' (No, stay in Plan mode) answers it at 70s; cut 25s later at the empty composer.", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/codex-0-160-0-plan-implement-menu.txt b/src/main/runtime/__fixtures__/codex-0-160-0-plan-implement-menu.txt new file mode 100644 index 00000000000..62ae231e2c3 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0-160-0-plan-implement-menu.txt @@ -0,0 +1 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1003h[?1006h[?25l[?2026h[?25l>_ OpenAI Codex (v0.160.0)loadingHello, world. Hello, you.⣀⣤⣤⣤⣀⣀⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣦⣤⣤⣤⣤⣤⣤⡀⢀⣾⣿⣿⠿⠋⠉⠉⢉⣭⣿⣿⣿⣿⣿⠿⣿⣿⣿⣿⣷⣄⣀⣾⣿⣿⠃⣀⣴⣾⣿⣿⡿⠟⠋⣀⡀⠈⠙⢿⣿⣿⣧⣀⣶⣿⣿⣿⣿⡇⢸⣿⣿⡿⠛⠉⢀⣠⣴⣿⣿⣿⣷⣦⣀⠈⢻⣿⣿⡇⣰⣿⣿⡿⢿⣿⣿⡇⢸⣿⣿⢀⣤⣶⣿⣿⣿⠟⠛⠻⣿⣿⣿⣿⣮⣿⣿⡷⢰⣿⣿⡟⠁⢸⣿⣿⡇⢸⣿⣿⣿⣿⠿⠿⣿⣿⣿⣦⣄⡀⠈⠛⠿⣿⣿⣿⣧⡀⣾⣿⣿⠃⢸⣿⣿⡇⢸⣿⣿⠋⠁⠈⠙⢻⣿⣿⣿⣶⣤⡀⠹⢿⣿⣷⡄⢸⣿⣿⣇⠸⣿⣿⣷⣦⣼⣿⣿⢸⣿⣿⠻⢿⣿⣿⡇⠘⣿⣿⣿⠈⢿⣿⣿⣦⡀⠈⠛⠿⣿⣿⣿⣿⣄⡀⢀⣠⣼⣿⣿⣿⣿⡇⣿⣿⣿⠇⢻⣿⣿⣿⣷⣦⣀⠈⠙⠻⣿⣿⣿⣶⣶⣿⣿⣿⣿⣿⣿⣿⡇⣠⣿⣿⣿⢸⣿⣿⡿⢿⣿⣿⣿⣦⣠⣴⣿⣿⣿⡿⠟⠋⢸⣿⣿⣿⣿⣷⣴⣿⣿⣿⠃⠘⣿⣿⣷⠈⠛⢿⣿⣿⣿⠿⠋⠉⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⠻⣿⣿⣿⣤⣀⠈⠉⢀⣤⣶⣿⣿⣿⠿⠟⠁⣰⣿⣿⡟⠋⠁⠈⠿⣿⣿⣿⣿⣶⣾⣿⣿⣿⣿⠟⠋⠁⣠⣼⣿⣿⡟⠙⠛⠛⠛⠻⠛⢿⣿⣿⣿⣿⣷⣾⣿⣿⣿⡿⠏⠈⠙⠛⠿⠿⠿⠿⠛⠉›Ask Codex to do anything?forshortcuts[0 q [?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l/Users/xxxxxx/orca-lanes/codex-plan-scratch[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;codex-plan-scratch[?2026h[?25lGPT-6.1-Sol default·/Users/xxxxxx/orca-lanes/codex-plan-scratch[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ codex-plan-scratch[?2026h[?25h[?2026l]0;⠹ codex-plan-scratch]0;⠸ codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ codex-plan-scratch]0;⠴ codex-plan-scratch]0;⠦ codex-plan-scratch]0;⠧ codex-plan-scratch]0;⠇ codex-plan-scratch]0;⠏ codex-plan-scratch]0;codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l› /plan switch to Plan mode /plan[?25h[?2026l[?2026h[?25l• Modelchangedtogpt-6.1-solmediumforPlanmode. Ask Codex to do anythingmdim · /Users/xxxxx/orca-lanes/codex-plan-scratch Plan mode(shift+tabtocycle)?forshortcuts[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25lPlan adding one line to notes.md.Twostepsmax,asknoquestions.[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l› Planaddingonelinetonotes.md.Twostepsmax,asknoquestions.Ask Codex to do anything?forshortcuts[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ codex-plan-scratch[?2026h[?25l•Working(0s • esc to interrupt)  Plan mode[?25h[?2026l]0;⠋ codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ ⠙ | codex-plan-scratch[?2026h[?25l·⠙[?25h[?2026l]0;⠹ ⠹ | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ ⠸ | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ ⠼ | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ ⠴ | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ ⠦ | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ ⠧ | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ ⠇ | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ ⠏ | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25l1[?25h[?2026l]0;⠋ ⠋ | codex-plan-scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ ⠙ | codex-plan-scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ ⠹ | codex-plan-scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ ⠸ | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ ⠼ | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ ⠴ | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ ⠦ | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ ⠧ | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ ⠇ | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ ⠏ | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l• I’ll [?25h[?2026l[?2026h[?25lcheck[?25h[?2026l[?2026h[?25l`[?25h[?2026l]0;⠋ ⠋ | codex-plan-scratch[?2026h[?25l⠋[?25h[?2026l[?2026h[?25lnotes.md[?25h[?2026l]0;⠙ ⠙ | codex-plan-scratch[?2026h[?25l⠙[?25h[?2026l]0;⠹ ⠹ | codex-plan-scratch[?2026h[?25landtherepository[?25h[?2026l[?2026h[?25lguidance,thengive[?25h[?2026l]0;⠹ codex-plan-scratch]0;⠹ Plan notes.md addition | codex-plan-scratch[?2026h[?25lPlan notes.md addition[?25h[?2026l]0;⠸ Plan notes.md addition | codex-plan-scratch[?2026h[?25laplanwith[?25h[?2026l]0;⠼ Plan notes.md addition | codex-plan-scratch[?2026h[?25lnomore[?25h[?2026l[?2026h[?25lthan[?25h[?2026l[?2026h[?25ltwo[?25h[?2026l[?2026h[?25lsteps[?25h[?2026l]0;⠴ Plan notes.md addition | codex-plan-scratch[?2026h[?25l [?25h[?2026l[?2026h[?25l• I’llchecknotes.mdandtherepositoryguidance,thengiveaplanwithnomorethantwosteps.[?25h[?2026l]0;⠦ Plan notes.md addition | codex-plan-scratch]0;⠧ Plan notes.md addition | codex-plan-scratch]0;⠇ Plan notes.md addition | codex-plan-scratch]0;⠏ Plan notes.md addition | codex-plan-scratch[?2026h[?25l•Working(2s • esc to interrupt)[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25l3[?25h[?2026l]0;⠋ Plan notes.md addition | codex-plan-scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ Plan notes.md addition | codex-plan-scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ Plan notes.md addition | codex-plan-scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ Plan notes.md addition | codex-plan-scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l4[?25h[?2026l]0;⠋ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•Ranpwd; rg --files -g 'notes.md' -g 'AGENTS.md' -g '!node_modules' -g '!vendor' └ /Users/xxxxxx/orca-lanes/codex-plan-scratch notes.md•[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25l5[?25h[?2026l]0;⠋ Plan notes.md addition | codex-plan-scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ Plan notes.md addition | codex-plan-scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l]0;⠇ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l]0;⠋ Plan notes.md addition | codex-plan-scratch[?2026h[?25l6[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25l7[?25h[?2026l]0;⠋ Plan notes.md addition | codex-plan-scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ Plan notes.md addition | codex-plan-scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ Plan notes.md addition | codex-plan-scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ Plan notes.md addition | codex-plan-scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•Explored └ Readnotes.md + Show details [?25h[?2026l]0;⠼ Plan notes.md addition | codex-plan-scratch[?2026h[?25l•[?25h[?2026l]0;⠴ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ Plan notes.md addition | codex-plan-scratch[?2026h[?25l8[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠦ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25l9[?25h[?2026l]0;⠋ Plan notes.md addition | codex-plan-scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ Plan notes.md addition | codex-plan-scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ Plan notes.md addition | codex-plan-scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ Plan notes.md addition | codex-plan-scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ Plan notes.md addition | codex-plan-scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l• Proposed Plan1 [?25h[?2026l]0;⠇ Plan notes.md addition | codex-plan-scratch[?2026h[?25l1.[?25h[?2026l[?2026h[?25lAppend[?25h[?2026l[?2026h[?25l`[?25h[?2026l]0;⠏ Plan notes.md addition | codex-plan-scratch[?2026h[?25lOne[?25h[?2026l]0;⠋ Plan notes.md addition | codex-plan-scratch]0;⠙ Plan notes.md addition | codex-plan-scratch[?2026h[?25lline[?25h[?2026l]0;⠹ Plan notes.md addition | codex-plan-scratch]0;⠸ Plan notes.md addition | codex-plan-scratch[?2026h[?25ladded[?25h[?2026l[?2026h[?25lOne line added.beneath[?25h[?2026l[?2026h[?25ltheexisting`#[?25h[?2026l]0;⠼ Plan notes.md addition | codex-plan-scratch[?2026h[?25l# scratchheadingin[?25h[?2026l[?2026h[?25l`notes.md[?25h[?2026l]0;⠴ Plan notes.md addition | codex-plan-scratch]0;⠦ Plan notes.md addition | codex-plan-scratch[?2026h[?25lnotes.md,[?25h[?2026l]0;⠧ Plan notes.md addition | codex-plan-scratch]0;⠇ Plan notes.md addition | codex-plan-scratch[?2026h[?25lusing[?25h[?2026l]0;⠏ Plan notes.md addition | codex-plan-scratch[?2026h[?25lthis[?25h[?2026l]0;⠋ Plan notes.md addition | codex-plan-scratch]0;⠙ Plan notes.md addition | codex-plan-scratch[?2026h[?25ltext[?25h[?2026l[?2026h[?25lasthedefault[?25h[?2026l]0;⠹ Plan notes.md addition | codex-plan-scratch[?2026h[?25lsince[?25h[?2026l]0;⠸ Plan notes.md addition | codex-plan-scratch]0;⠼ Plan notes.md addition | codex-plan-scratch]0;⠴ Plan notes.md addition | codex-plan-scratch]0;⠦ Plan notes.md addition | codex-plan-scratch[?2026h[?25l• Proposed Plan 2.[?25h[?2026l[?2026h[?25l • Proposed Plan1. AppendOne line added.beneaththeexisting# scratchheadinginnotes.md,usingthistextasthedefaultsince2.[?25h[?2026l[?2026h[?25l nonewasspecified.2.[?25h[?2026l]0;⠧ Plan notes.md addition | codex-plan-scratch[?2026h[?25lCheck[?25h[?2026l[?2026h[?25lthediffconfirmsexactlyoneaddedline[?25h[?2026l]0;⠇ Plan notes.md addition | codex-plan-scratch]0;⠏ Plan notes.md addition | codex-plan-scratch[?2026h[?25l[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l2. Checkthediffconfirmsexactlyoneaddedlineandnootherchanges.[?25h[?2026l]0;⠋ Plan notes.md addition | codex-plan-scratch]0;⠙ Plan notes.md addition | codex-plan-scratch[?2026h[?25l2. Checkthediffconfirmsexactlyoneaddedlineandnootherchanges.•Working(12s • esc to interrupt)[?25h[?2026l[?2026h[?25h[?2026l]0;Plan notes.md addition | codex-plan-scratch[?2026h[?25l/Users/xxxxxx/orca-lanes/codex-plan-scratchHello, world. Hello, you.• Model changed to gpt-6.1-solmediumforPlanmode. › Planaddingonelinetonotes.md.Twostepsmax,asknoquestions. • I’llchecknotes.mdandtherepositoryguidance,thengiveaplanwithnomorethantwosteps.• Ran pwd; rg --files -g 'notes.md' -g 'AGENTS.md' -g '!node_modules' -g '!vendor' └ /Users/xxxxxx/orca-lanes/codex-plan-scratch notes.md • Explored └ Readnotes.md + Show details  •Proposed Plan 1. AppendOne line added.beneaththeexisting# scratchheadinginnotes.md,usingthistextasthedefaultsincenonewasspecified.2. Check the diff confirmsexactlyoneaddedlineandnootherchanges. Worked for 12s • 1:45 AM Implement this plan? › 1. Yes, implement this plan Switch to Default and start coding  2. Yes, clear context andimplementStart a fresh thread (current context: 2% used)3.No,stayinPlanmodeContinue planning with the modelenter select · esc back [?2026l[?2026h>_ OpenAI Codex (v0.160.0) /Users/xxxxxx/orca-lanes/codex-plan-scratch Hello, world. Hello, you.• Modelchangedtogpt-6.1-solmediumforPlanmode. › Planaddingonelinetonotes.md.Twostepsmax,asknoquestions. • I’ll check notes.md and the repository guidance, then give a plan with no more thantwosteps. • Ran pwd; rg --files -g 'notes.md' -g 'AGENTS.md' -g '!node_modules' -g '!vendor' └ /Users/xxxxxx/orca-lanes/codex-plan-scratch notes.md • Explored └ Readnotes.md  + Show details • Proposed Plan1. AppendOne line added.beneaththeexisting# scratchheadinginnotes.md,usingthistextasthedefaultsince none was specified.2. Checkthediffconfirmsexactlyoneaddedlineandnootherchanges.  Worked for 12s • 1:45 AM  ›Ask Codex to do anythingGPT-6.1-Sol medium·/Users/xxxxxx/orca-lanes/codex-plan-scratch·Plan notes.md additionPlan mode? for shortcuts[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--codex-0-160-0-plan-implement-menu@agent.json b/src/main/runtime/__fixtures__/readiness-census/transcript--codex-0-160-0-plan-implement-menu@agent.json new file mode 100644 index 00000000000..d17f5cf724e --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--codex-0-160-0-plan-implement-menu@agent.json @@ -0,0 +1,36 @@ +{ + "description": "codex recording at 120x40 replayed on the agent pane, one entry per chunk", + "observations": { + "clocked": [ + "0-21: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "22-36: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start", + "37-44: now=working edge=working quiet=ready-strong wait=ready@start", + "45-54: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start", + "55-60: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "61-111: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start", + "112-118: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "119-121: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start", + "122-123: now=working edge=working quiet=ready-strong wait=ready@start", + "124-180: now=working edge=working quiet=working wait=pending", + "181-212: now=working edge=working quiet=ready-strong wait=ready@start", + "213-435: now=working edge=working quiet=working wait=pending", + "436-500: now=working edge=working quiet=ready-strong wait=ready@start", + "501-502: now=working edge=working quiet=working wait=pending", + "503-507: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "508-547: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start", + "548-551: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "552-558: now=blocked:agent-interactive-prompt edge=blocked:agent-interactive-prompt quiet=blocked:agent-interactive-prompt wait=blocked:agent-interactive-prompt@start", + "559-596: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "597-601: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start" + ], + "clockless": [ + "0-36: verdict=pending:closed wait=pending", + "37-44: verdict=working wait=pending", + "45-121: verdict=pending:closed wait=pending", + "122-502: verdict=working wait=pending", + "503-551: verdict=pending:closed wait=pending", + "552-558: verdict=blocked:agent-interactive-prompt wait=blocked:agent-interactive-prompt@start", + "559-601: verdict=pending:closed wait=pending" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--codex-0-160-0-plan-implement-menu@unknown.json b/src/main/runtime/__fixtures__/readiness-census/transcript--codex-0-160-0-plan-implement-menu@unknown.json new file mode 100644 index 00000000000..29bebffabb2 --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--codex-0-160-0-plan-implement-menu@unknown.json @@ -0,0 +1,35 @@ +{ + "description": "codex recording at 120x40 replayed on the unknown pane, one entry per chunk", + "observations": { + "clocked": [ + "0-36: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll", + "37-44: now=working edge=working quiet=ready-strong wait=ready@start", + "45-54: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start", + "55-60: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "61-111: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start", + "112-118: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "119-121: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start", + "122-123: now=working edge=working quiet=ready-strong wait=ready@start", + "124-180: now=working edge=working quiet=working wait=pending", + "181-212: now=working edge=working quiet=ready-strong wait=ready@start", + "213-435: now=working edge=working quiet=working wait=pending", + "436-500: now=working edge=working quiet=ready-strong wait=ready@start", + "501-502: now=working edge=working quiet=working wait=pending", + "503-507: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "508-547: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start", + "548-551: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "552-558: now=blocked:agent-interactive-prompt edge=blocked:agent-interactive-prompt quiet=blocked:agent-interactive-prompt wait=blocked:agent-interactive-prompt@start", + "559-596: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "597-601: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start" + ], + "clockless": [ + "0-36: verdict=pending:open wait=ready@poll", + "37-44: verdict=working wait=pending", + "45-121: verdict=pending:closed wait=pending", + "122-502: verdict=working wait=pending", + "503-551: verdict=pending:closed wait=pending", + "552-558: verdict=blocked:agent-interactive-prompt wait=blocked:agent-interactive-prompt@start", + "559-601: verdict=pending:closed wait=pending" + ] + } +} diff --git a/src/main/runtime/agent-state-rules/codex.json b/src/main/runtime/agent-state-rules/codex.json index b52d85a8d6f..7167cf75626 100644 --- a/src/main/runtime/agent-state-rules/codex.json +++ b/src/main/runtime/agent-state-rules/codex.json @@ -3,6 +3,21 @@ "engineVersion": 1, "profile": { "screenSource": "live" }, "anchors": [ + { + "id": "plan_implement_menu", + "why": "A Plan-mode turn ends in this menu after Codex's Stop hook fired, and it owns the keyboard, so text sent to the pane picks a choice. Codex paints it by cell diff, which drops spaces from the text copy, and leaves it in the tail once answered, so the key row must end the tail.", + "when": { + "region": "text", + "find": { "lastOf": "implement this plan?" }, + "after": { + "all": [ + { "regex": "no,\\s*stay\\s*in\\s*plan\\s*mode" }, + { "regex": "enter\\s*select\\s*·\\s*esc\\s*back\\s*$" } + ] + } + }, + "answer": { "state": "blocked", "reason": "agent-interactive-prompt" } + }, { "id": "ready_header", "why": "The startup header with its model and directory labels is the stable ready text across versions; Codex prints permissions only in YOLO mode. Its last occurrence counts, so a header repainted after a dialog proves the dialog was answered.", From 564f4d021af1f79bf966ac52575d6702591bbb3e Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:59:24 -0400 Subject: [PATCH 03/21] feat: live updates for agent state rules (#24387) Orca downloads a newer agent-state-rules.json from a fixed GitHub release (stable or next channel), validates it like the bundled rules, and applies it without a restart; a local override wins over the download, which wins over the bundled rules. A hand-started workflow from main is the only publisher; merging publishes nothing. --- .../scripts/render-readme-downloads-badge.mjs | 41 +- .../workflows/agent-state-rules-publish.yml | 94 ++++ .github/workflows/release-policy.yml | 4 +- config/scripts/agent-state-rules-bundle.mjs | 174 ++++++++ .../scripts/agent-state-rules-bundle.test.mjs | 179 ++++++++ .../scripts/readme-downloads-badge.test.mjs | 19 + config/scripts/release-policy.mjs | 16 +- config/scripts/release-policy.test.mjs | 37 ++ .../release-tag-pattern-census.test.mjs | 184 ++++++++ config/scripts/release-tag-patterns.mjs | 30 ++ src/main/orcad/orcad-entry.ts | 7 + src/main/orcad/orcad-push-startup.test.ts | 4 + ...roxy-guarded-fetch-call-site-audit.test.ts | 4 +- .../active-agent-state-rules.ts | 55 +++ .../agent-state-rules-bundle.test.ts | 68 +++ .../agent-state-rules-bundle.ts | 69 +++ .../agent-state-rules-engine.ts | 25 +- .../agent-state-rules-live-update.test.ts | 406 ++++++++++++++++++ .../agent-state-rules-live-update.ts | 259 +++++++++++ .../agent-state-rules-release.json | 14 + .../agent-state-rules-schema.ts | 2 +- .../agent-state-text-anchors.test.ts | 4 +- .../agent-state-text-anchors.ts | 16 +- .../agent-state-title-anchors.ts | 6 +- .../agent-state-rules/blocked-text-layer.ts | 19 +- .../terminal-tail-sentinel-index.test.ts | 12 +- .../runtime/terminal-tail-sentinel-index.ts | 27 +- src/main/runtime/terminal-wait-detection.ts | 15 +- src/main/runtime/terminal-wait-tail-state.ts | 4 +- .../startup/main-process-runtime-service.ts | 6 + .../updater-agent-state-rules-release.test.ts | 119 +++++ src/shared/global-settings-types.ts | 6 + 32 files changed, 1850 insertions(+), 75 deletions(-) create mode 100644 .github/workflows/agent-state-rules-publish.yml create mode 100644 config/scripts/agent-state-rules-bundle.mjs create mode 100644 config/scripts/agent-state-rules-bundle.test.mjs create mode 100644 config/scripts/readme-downloads-badge.test.mjs create mode 100644 config/scripts/release-tag-pattern-census.test.mjs create mode 100644 config/scripts/release-tag-patterns.mjs create mode 100644 src/main/runtime/agent-state-rules/active-agent-state-rules.ts create mode 100644 src/main/runtime/agent-state-rules/agent-state-rules-bundle.test.ts create mode 100644 src/main/runtime/agent-state-rules/agent-state-rules-bundle.ts create mode 100644 src/main/runtime/agent-state-rules/agent-state-rules-live-update.test.ts create mode 100644 src/main/runtime/agent-state-rules/agent-state-rules-live-update.ts create mode 100644 src/main/runtime/agent-state-rules/agent-state-rules-release.json create mode 100644 src/main/updater-agent-state-rules-release.test.ts diff --git a/.github/scripts/render-readme-downloads-badge.mjs b/.github/scripts/render-readme-downloads-badge.mjs index efe0d670cf5..9c98bf9229e 100644 --- a/.github/scripts/render-readme-downloads-badge.mjs +++ b/.github/scripts/render-readme-downloads-badge.mjs @@ -1,5 +1,7 @@ import { mkdir, writeFile } from 'node:fs/promises' import { dirname } from 'node:path' +import { pathToFileURL } from 'node:url' +import { isAgentStateRulesTag } from '../../config/scripts/release-tag-patterns.mjs' const repository = process.env.GITHUB_REPOSITORY ?? 'stablyai/orca' const token = process.env.GITHUB_TOKEN @@ -24,6 +26,22 @@ async function fetchJson(url) { return response.json() } +// Why rules releases are excluded: every running app fetches them every few hours, so they +// count fetches, not installs. +export function countReleaseDownloads(releases) { + let total = 0 + for (const release of releases) { + if (release.draft || isAgentStateRulesTag(release.tag_name ?? '')) { + continue + } + + for (const asset of release.assets ?? []) { + total += asset.download_count ?? 0 + } + } + return total +} + async function getTotalReleaseDownloads() { let page = 1 let total = 0 @@ -37,16 +55,7 @@ async function getTotalReleaseDownloads() { return total } - for (const release of releases) { - if (release.draft) { - continue - } - - for (const asset of release.assets ?? []) { - total += asset.download_count ?? 0 - } - } - + total += countReleaseDownloads(releases) page += 1 } } @@ -104,9 +113,11 @@ function renderBadge(value) { ` } -const total = await getTotalReleaseDownloads() -const badge = renderBadge(formatDownloads(total)) +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + const total = await getTotalReleaseDownloads() + const badge = renderBadge(formatDownloads(total)) -await mkdir(dirname(outputPath), { recursive: true }) -await writeFile(outputPath, badge) -console.log(`Rendered ${outputPath} from ${total} downloads.`) + await mkdir(dirname(outputPath), { recursive: true }) + await writeFile(outputPath, badge) + console.log(`Rendered ${outputPath} from ${total} downloads.`) +} diff --git a/.github/workflows/agent-state-rules-publish.yml b/.github/workflows/agent-state-rules-publish.yml new file mode 100644 index 00000000000..8a24adbc73f --- /dev/null +++ b/.github/workflows/agent-state-rules-publish.yml @@ -0,0 +1,94 @@ +name: Publish agent state rules + +# The only publisher of agent state rules releases. Merging never publishes: a maintainer runs +# this from main, first to `next` (RC and dev builds), then promotes the identical file to +# `stable` after a soak. Every job runs on the dispatched commit, so the publish job runs exactly +# the script and rules the gate tested. +# +# Why GITHUB_TOKEN: release-policy.yml deletes an agent-state-rules-* release any other author +# publishes. Its releases start no workflow, so release-policy.yml sees them only when someone +# edits one by hand, and then accepts them as bot-authored prereleases. +on: + workflow_dispatch: + inputs: + action: + description: 'publish-next builds from main and publishes to next; promote-stable copies the next file to stable' + required: true + type: choice + options: + - publish-next + - promote-stable + bundled_only: + description: 'publish-next: tell apps to fall back to the rules they shipped with; promote-stable then carries it to stable' + required: false + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: agent-state-rules-publish + cancel-in-progress: false + +jobs: + gate: + if: github.ref == 'refs/heads/main' && inputs.action == 'publish-next' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node + - name: Run the rules gate (schema, regex safety, the bundle, every transcript replay) + shell: bash + run: | + set -euo pipefail + mapfile -t gate_files < <(git ls-files \ + ':(glob)src/main/runtime/agent-state-rules/**/*.test.ts' \ + ':(glob)src/main/runtime/readiness-census*.test.ts' \ + ':(glob)src/main/runtime/*transcript.test.ts' \ + ':(glob)src/main/runtime/*transcripts.test.ts' \ + config/scripts/agent-state-rules-bundle.test.mjs) + pnpm exec vitest run --config config/vitest.config.ts "${gate_files[@]}" + + publish-next: + needs: gate + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: agent-state-rules + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Publish to next + env: + GH_TOKEN: ${{ github.token }} + BUNDLED_ONLY: ${{ inputs.bundled_only }} + shell: bash + run: | + set -euo pipefail + flags=() + [[ "$BUNDLED_ONLY" == "true" ]] && flags+=(--bundled-only) + node config/scripts/agent-state-rules-bundle.mjs publish-next "${flags[@]}" + + promote-stable: + if: github.ref == 'refs/heads/main' && inputs.action == 'promote-stable' + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: agent-state-rules + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Copy the next file to stable + env: + GH_TOKEN: ${{ github.token }} + run: node config/scripts/agent-state-rules-bundle.mjs promote-stable diff --git a/.github/workflows/release-policy.yml b/.github/workflows/release-policy.yml index 53decc5e7e3..512a5fb8ea8 100644 --- a/.github/workflows/release-policy.yml +++ b/.github/workflows/release-policy.yml @@ -22,7 +22,9 @@ jobs: # Why: release events run this file from the tagged commit, so load the module from the same commit. - uses: actions/checkout@v6 with: - sparse-checkout: config/scripts/release-policy.mjs + sparse-checkout: | + config/scripts/release-policy.mjs + config/scripts/release-tag-patterns.mjs sparse-checkout-cone-mode: false persist-credentials: false - name: Enforce release policy diff --git a/config/scripts/agent-state-rules-bundle.mjs b/config/scripts/agent-state-rules-bundle.mjs new file mode 100644 index 00000000000..170d6b4c122 --- /dev/null +++ b/config/scripts/agent-state-rules-bundle.mjs @@ -0,0 +1,174 @@ +// Builds and publishes the agent state rules bundle (agent-state-rules.json). The app's loader +// validates the same file (src/main/runtime/agent-state-rules/agent-state-rules-bundle.ts), and +// agent-state-rules-bundle.test.mjs proves the build passes it; this script only assembles the +// file and enforces the publishing rules a single file cannot express. +// +// node config/scripts/agent-state-rules-bundle.mjs build [--bundled-only] +// node config/scripts/agent-state-rules-bundle.mjs publish-next [--bundled-only] +// node config/scripts/agent-state-rules-bundle.mjs promote-stable + +import { spawnSync } from 'node:child_process' +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { agentStateRulesTag } from './release-tag-patterns.mjs' + +const RULES_DIR = join(import.meta.dirname, '..', '..', 'src/main/runtime/agent-state-rules') +export const AGENT_STATE_RULES_ASSET = 'agent-state-rules.json' + +function readJson(path) { + return JSON.parse(readFileSync(path, 'utf8')) +} + +/** The live-updatable agents' files, in release order, under the release's version. */ +export function buildAgentStateRulesBundle({ bundledOnly = false } = {}) { + const release = readJson(join(RULES_DIR, 'agent-state-rules-release.json')) + const files = release.liveUpdatable.map((id) => readJson(join(RULES_DIR, `${id}.json`))) + const bundle = { + version: release.version, + engineVersion: files[0].engineVersion, + ...(bundledOnly ? { bundledOnly: true } : {}), + files + } + return `${JSON.stringify(bundle, null, 2)}\n` +} + +function runGh(args) { + const result = spawnSync('gh', args, { encoding: 'utf8' }) + return { status: result.status ?? 1, stdout: result.stdout ?? '', stderr: result.stderr ?? '' } +} + +function ghOrThrow(gh, args) { + const result = gh(args) + if (result.status !== 0) { + throw new Error(`gh ${args.join(' ')} failed: ${result.stderr.trim()}`) + } + return result.stdout +} + +/** The file on `tag`'s release, or null when there is no release yet. */ +function downloadPublished(gh, repo, tag) { + const args = [ + 'release', + 'download', + tag, + '--repo', + repo, + '-p', + AGENT_STATE_RULES_ASSET, + '-O', + '-' + ] + const result = gh(args) + if (result.status === 0) { + return result.stdout + } + // Why throw on anything else, a missing asset included: that means an earlier upload broke. + if (/release not found/i.test(result.stderr)) { + return null + } + throw new Error(`gh ${args.join(' ')} failed: ${result.stderr.trim()}`) +} + +/** + * Puts `text` on the channel's release for its engine, as a prerelease that never becomes Latest + * (the app updater follows Latest). `gh` is injectable so the sequence is testable. + */ +export function publishAgentStateRules({ repo, channel, text, target, gh = runGh }) { + const candidate = JSON.parse(text) + const tag = agentStateRulesTag(candidate.engineVersion, channel) + const publishedText = downloadPublished(gh, repo, tag) + // Why strictly higher: apps refuse a version they already have, so republishing one would reach + // nobody, and a lower one would reach only apps that never took the higher. + const published = publishedText === null ? null : JSON.parse(publishedText).version + if (published !== null && candidate.version <= published) { + throw new Error( + `version ${candidate.version} is not higher than the published ${published}; bump agent-state-rules-release.json` + ) + } + const file = join(mkdtempSync(join(tmpdir(), 'agent-state-rules-')), AGENT_STATE_RULES_ASSET) + writeFileSync(file, text) + if (published === null) { + ghOrThrow(gh, [ + 'release', + 'create', + tag, + file, + '--repo', + repo, + '--target', + target, + '--prerelease', + '--latest=false', + '--title', + `Agent state rules (${channel})`, + '--notes', + 'Agent state rules for Orca. Running apps download this file; it is not an app release.' + ]) + } else { + // Why --clobber on the same tag: the app's fixed URL stays valid, and a fetch that lands + // mid-upload gets a 404 and keeps its last good copy. + ghOrThrow(gh, ['release', 'upload', tag, file, '--repo', repo, '--clobber']) + } + return { tag, version: candidate.version } +} + +/** + * Why no rebuild and no re-gate: stable gets exactly the bytes RC and dev builds soaked on next, + * which only the gated publish-next job writes. A bundledOnly next is promoted too: that is how + * stable rolls back to the rules it shipped. + */ +export function promoteAgentStateRules({ repo, engineVersion, target, gh = runGh }) { + const nextTag = agentStateRulesTag(engineVersion, 'next') + const text = downloadPublished(gh, repo, nextTag) + if (text === null) { + throw new Error(`${nextTag} has not been published`) + } + return publishAgentStateRules({ repo, channel: 'stable', text, target, gh }) +} + +function requireEnv(name) { + const value = process.env[name] + if (!value) { + throw new Error(`${name} is not set`) + } + return value +} + +function main([command, ...args]) { + const bundledOnly = args.includes('--bundled-only') + switch (command) { + case 'build': { + const out = args.find((arg) => !arg.startsWith('--')) + if (!out) { + throw new Error('usage: build [--bundled-only]') + } + writeFileSync(out, buildAgentStateRulesBundle({ bundledOnly })) + return + } + case 'publish-next': + case 'promote-stable': { + const repo = requireEnv('GITHUB_REPOSITORY') + const target = requireEnv('GITHUB_SHA') + const text = buildAgentStateRulesBundle({ bundledOnly }) + const { tag, version } = + command === 'publish-next' + ? publishAgentStateRules({ repo, channel: 'next', text, target }) + : promoteAgentStateRules({ repo, engineVersion: JSON.parse(text).engineVersion, target }) + console.log(`Published version ${version} to ${tag}.`) + return + } + default: + throw new Error(`unknown command ${command ?? '(none)'}`) + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + try { + main(process.argv.slice(2)) + } catch (error) { + console.error(`::error::${error instanceof Error ? error.message : String(error)}`) + process.exitCode = 1 + } +} diff --git a/config/scripts/agent-state-rules-bundle.test.mjs b/config/scripts/agent-state-rules-bundle.test.mjs new file mode 100644 index 00000000000..38fd0de6633 --- /dev/null +++ b/config/scripts/agent-state-rules-bundle.test.mjs @@ -0,0 +1,179 @@ +// The rules-release gate: the bundle a release would publish validates in the app's own loader, +// carries only agents whose transcripts the census replays, and only the protected workflow can +// publish it. +import { readdirSync, readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' +import { + BUNDLED_AGENT_STATE_RULES_VERSION, + LIVE_UPDATABLE_AGENT_STATE_RULE_IDS, + parseAgentStateRulesBundle +} from '../../src/main/runtime/agent-state-rules/agent-state-rules-bundle.ts' +import { BUNDLED_AGENT_STATE_RULE_FILES } from '../../src/main/runtime/agent-state-rules/agent-state-rules-catalog.ts' +import { agentStateRulesDownloadUrl } from '../../src/main/runtime/agent-state-rules/agent-state-rules-live-update.ts' +import { + AGENT_STATE_RULES_ENGINE_VERSION, + UNKNOWN_PANE_RULES_ID +} from '../../src/main/runtime/agent-state-rules/agent-state-rules-schema.ts' +import { CENSUS_TRANSCRIPTS } from '../../src/main/runtime/readiness-census-transcript-catalog.ts' +import { + AGENT_STATE_RULES_ASSET, + buildAgentStateRulesBundle, + promoteAgentStateRules, + publishAgentStateRules +} from './agent-state-rules-bundle.mjs' +import { agentStateRulesTag } from './release-tag-patterns.mjs' + +const REPO = 'stablyai/orca' +const NEXT = agentStateRulesTag(1, 'next') +const STABLE = agentStateRulesTag(1, 'stable') + +describe('agent state rules bundle build', () => { + it('builds a bundle the app accepts, under the bundled version and engine', () => { + const text = buildAgentStateRulesBundle() + const parsed = parseAgentStateRulesBundle(text, 'live-updatable') + expect(parsed.ok).toBe(true) + const bundle = JSON.parse(text) + expect(bundle.engineVersion).toBe(AGENT_STATE_RULES_ENGINE_VERSION) + expect(bundle.version).toBe(BUNDLED_AGENT_STATE_RULES_VERSION) + expect(bundle.files).toEqual( + BUNDLED_AGENT_STATE_RULE_FILES.filter((file) => + LIVE_UPDATABLE_AGENT_STATE_RULE_IDS.has(file.id) + ) + ) + expect(bundle.bundledOnly).toBeUndefined() + expect(JSON.parse(buildAgentStateRulesBundle({ bundledOnly: true })).bundledOnly).toBe(true) + }) + + it('lets a rules release change only agents the readiness census replays', () => { + const replayed = new Set(CENSUS_TRANSCRIPTS.flatMap((transcript) => transcript.agent ?? [])) + // Why unknown-pane: the census replays every recording on an agent-unknown pane too. + replayed.add(UNKNOWN_PANE_RULES_ID) + expect([...LIVE_UPDATABLE_AGENT_STATE_RULE_IDS].filter((id) => !replayed.has(id))).toEqual([]) + }) + + it('publishes to the exact URL the app fetches', () => { + for (const channel of ['next', 'stable']) { + expect(agentStateRulesDownloadUrl(channel)).toBe( + `https://github.com/${REPO}/releases/download/${agentStateRulesTag(AGENT_STATE_RULES_ENGINE_VERSION, channel)}/${AGENT_STATE_RULES_ASSET}` + ) + } + }) +}) + +/** A `gh` stand-in over an in-memory set of releases, recording each call. */ +function fakeGh(releases = {}) { + const calls = [] + const gh = (args) => { + calls.push(args) + const [, verb, tag] = args + if (verb === 'download') { + return tag in releases + ? { status: 0, stdout: releases[tag], stderr: '' } + : { status: 1, stdout: '', stderr: 'release not found' } + } + if (verb === 'upload' || verb === 'create') { + expect(args[3].endsWith(`/${AGENT_STATE_RULES_ASSET}`)).toBe(true) + releases[tag] = readFileSync(args[3], 'utf8') + } + return { status: 0, stdout: '', stderr: '' } + } + return { gh, calls, releases } +} + +const at = (version, engineVersion = 1) => + `${JSON.stringify({ version, engineVersion, files: [] })}\n` + +describe('publishAgentStateRules', () => { + it("creates the engine's channel release as a prerelease that can never be Latest", () => { + const fake = fakeGh() + expect( + publishAgentStateRules({ + repo: REPO, + channel: 'next', + text: at(2), + target: 'abc', + gh: fake.gh + }) + ).toEqual({ tag: NEXT, version: 2 }) + expect(fake.calls.find((args) => args[1] === 'create')).toEqual( + expect.arrayContaining([NEXT, '--prerelease', '--latest=false', '--target', 'abc']) + ) + expect(fake.releases[NEXT]).toBe(at(2)) + }) + + it('replaces the asset in place on an existing release, keeping the tag', () => { + const fake = fakeGh({ [NEXT]: at(1) }) + publishAgentStateRules({ repo: REPO, channel: 'next', text: at(2), target: 'abc', gh: fake.gh }) + expect(fake.calls.map((args) => args[1])).toEqual(['download', 'upload']) + expect(fake.calls[1]).toContain('--clobber') + expect(fake.releases[NEXT]).toBe(at(2)) + }) + + it.each([1, 2])('refuses version %s over a published 2, uploading nothing', (version) => { + const fake = fakeGh({ [NEXT]: at(2) }) + expect(() => + publishAgentStateRules({ + repo: REPO, + channel: 'next', + text: at(version), + target: 'abc', + gh: fake.gh + }) + ).toThrow('bump agent-state-rules-release.json') + expect(fake.calls.map((args) => args[1])).toEqual(['download']) + }) + + it('fails when the release exists but its download fails', () => { + const gh = () => ({ status: 1, stdout: '', stderr: 'no assets match the file pattern' }) + expect(() => + publishAgentStateRules({ repo: REPO, channel: 'next', text: at(2), target: 'abc', gh }) + ).toThrow('no assets match') + }) + + it('promotes the identical next bytes to stable', () => { + const nextText = JSON.stringify({ version: 3, engineVersion: 1, files: [] }, null, 2) + const fake = fakeGh({ [NEXT]: nextText, [STABLE]: at(2) }) + promoteAgentStateRules({ repo: REPO, engineVersion: 1, target: 'abc', gh: fake.gh }) + expect(fake.releases[STABLE]).toBe(nextText) + }) + + it('refuses to promote before next exists', () => { + const fake = fakeGh() + expect(() => + promoteAgentStateRules({ repo: REPO, engineVersion: 1, target: 'abc', gh: fake.gh }) + ).toThrow('has not been published') + }) +}) + +describe('agent state rules workflows', () => { + const read = (name) => parse(readFileSync(`.github/workflows/${name}`, 'utf8')) + const publish = read('agent-state-rules-publish.yml') + + it('publishes only on manual dispatch from main, in the protected environment', () => { + expect(Object.keys(publish.on)).toEqual(['workflow_dispatch']) + for (const name of ['publish-next', 'promote-stable']) { + const job = publish.jobs[name] + expect(job.environment).toBe('agent-state-rules') + expect(job.permissions).toEqual({ contents: 'write' }) + } + expect(publish.permissions).toEqual({ contents: 'read' }) + expect(publish.jobs.gate.if).toContain("github.ref == 'refs/heads/main'") + expect(publish.jobs['promote-stable'].if).toContain("github.ref == 'refs/heads/main'") + expect(publish.jobs['publish-next'].needs).toBe('gate') + // Why: every job must check out the dispatched commit, so publish runs what the gate tested. + const checkouts = Object.values(publish.jobs).flatMap((job) => + job.steps.filter((step) => step.uses?.startsWith('actions/checkout')) + ) + expect(checkouts.map((step) => step.with?.ref)).toEqual([undefined, undefined, undefined]) + }) + + it('is the only workflow that publishes rules releases', () => { + const publishers = readdirSync('.github/workflows').filter((name) => + /agent-state-rules-bundle\.mjs (?:publish|promote)|release create agent-state-rules/.test( + readFileSync(`.github/workflows/${name}`, 'utf8') + ) + ) + expect(publishers).toEqual(['agent-state-rules-publish.yml']) + }) +}) diff --git a/config/scripts/readme-downloads-badge.test.mjs b/config/scripts/readme-downloads-badge.test.mjs new file mode 100644 index 00000000000..736ccbbd9b2 --- /dev/null +++ b/config/scripts/readme-downloads-badge.test.mjs @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { countReleaseDownloads } from '../../.github/scripts/render-readme-downloads-badge.mjs' + +const asset = (downloads) => ({ download_count: downloads }) + +describe('README downloads badge', () => { + it('counts published app releases and skips drafts and agent state rules releases', () => { + expect( + countReleaseDownloads([ + { tag_name: 'v1.4.1', draft: false, assets: [asset(10), asset(5)] }, + { tag_name: 'v1.4.2-rc.0', draft: false, assets: [asset(3)] }, + { tag_name: 'mobile-v0.0.1', draft: false, assets: [asset(2)] }, + { tag_name: 'v1.4.3', draft: true, assets: [asset(100)] }, + { tag_name: 'agent-state-rules-engine-1-next', draft: false, assets: [asset(9000)] }, + { tag_name: 'agent-state-rules-engine-1-stable', draft: false, assets: [asset(9000)] } + ]) + ).toBe(20) + }) +}) diff --git a/config/scripts/release-policy.mjs b/config/scripts/release-policy.mjs index 4733e79f2b4..e9ef5d8264e 100644 --- a/config/scripts/release-policy.mjs +++ b/config/scripts/release-policy.mjs @@ -1,15 +1,19 @@ // Enforced by .github/workflows/release-policy.yml on release events. +import { + DESKTOP_RC_TAG as RC_TAG, + DESKTOP_STABLE_TAG as STABLE_TAG, + MOBILE_TAG, + isAgentStateRulesTag +} from './release-tag-patterns.mjs' + const BOT_LOGIN = 'github-actions[bot]' const BOT_EMAIL = '41898282+github-actions[bot]@users.noreply.github.com' -const NUMBER = '(?:0|[1-9][0-9]*)' -const VERSION = `${NUMBER}\\.${NUMBER}\\.${NUMBER}` -const STABLE_TAG = new RegExp(`^v${VERSION}$`) -const RC_TAG = new RegExp(`^v${VERSION}-rc\\.${NUMBER}(?:\\.[0-9A-Za-z]+)?$`) -const MOBILE_TAG = new RegExp(`^mobile(?:-android)?-v${VERSION}$`) +// Why agent state rules here: their publish workflow is a bot author, and as a prerelease the +// release can never become Latest, which the app updater follows. export function isPrereleaseTag(tag) { - return RC_TAG.test(tag) || MOBILE_TAG.test(tag) + return RC_TAG.test(tag) || MOBILE_TAG.test(tag) || isAgentStateRulesTag(tag) } export function compareStableTags(left, right) { diff --git a/config/scripts/release-policy.test.mjs b/config/scripts/release-policy.test.mjs index 6478f6bb0c4..6d36717d785 100644 --- a/config/scripts/release-policy.test.mjs +++ b/config/scripts/release-policy.test.mjs @@ -147,4 +147,41 @@ describe('release policy', () => { await expect(restoreLatestStable(github, repoRef)).resolves.toBe('v1.4.100') }) + + it('keeps a bot-published agent state rules release as a prerelease, off Latest', async () => { + const rules = release('agent-state-rules-engine-1-next', 'github-actions[bot]') + const stable = release('v1.4.214', 'github-actions[bot]') + const github = createGithub({ + releases: [rules, stable], + tags: { 'v1.4.214': {} } + }) + + await run(github, rules, 'published') + + expect(github.rest.repos.deleteRelease).not.toHaveBeenCalled() + expect(github.rest.git.deleteRef).not.toHaveBeenCalled() + expect(github.rest.repos.updateRelease).toHaveBeenCalledWith({ + ...repoRef, + release_id: rules.id, + prerelease: true, + make_latest: 'false' + }) + expect(github.rest.repos.updateRelease).toHaveBeenLastCalledWith({ + ...repoRef, + release_id: stable.id, + make_latest: 'true' + }) + }) + + it('deletes an agent state rules release a person published', async () => { + const rules = release('agent-state-rules-engine-1-stable', 'someone', { prerelease: true }) + const github = createGithub({ releases: [rules] }) + + await run(github, rules, 'published') + + expect(github.rest.repos.deleteRelease).toHaveBeenCalledWith({ + ...repoRef, + release_id: rules.id + }) + }) }) diff --git a/config/scripts/release-tag-pattern-census.test.mjs b/config/scripts/release-tag-pattern-census.test.mjs new file mode 100644 index 00000000000..b42a424a36f --- /dev/null +++ b/config/scripts/release-tag-pattern-census.test.mjs @@ -0,0 +1,184 @@ +// Census: every release-triggered workflow and every script that lists this repo's releases +// classifies tags through release-tag-patterns.mjs, so a new tag family such as the agent state +// rules cannot reach a desktop-only path through one that forgot it. +import { readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { parse } from 'yaml' +import { + DESKTOP_RC_TAG, + DESKTOP_STABLE_TAG, + DESKTOP_STABLE_TAG_SHELL_PATTERN, + agentStateRulesTag, + isAgentStateRulesTag +} from './release-tag-patterns.mjs' + +const WORKFLOWS_DIR = '.github/workflows' +const SCRIPT_DIRS = ['config/scripts', '.github/scripts'] +const SHARED_IMPORT = /from '[^']*release-tag-patterns\.mjs'/ +const LISTS_RELEASES = /\/releases\?|listReleases|releases\.atom|'release',\s*'list'/ + +const RULES_TAGS = [agentStateRulesTag(1, 'next'), agentStateRulesTag(1, 'stable')] +const rulesRelease = (tag, extra = {}) => ({ + tag_name: tag, + name: tag, + draft: false, + prerelease: true, + author: { login: 'github-actions[bot]' }, + assets: [{ name: 'agent-state-rules.json', download_count: 5 }], + ...extra +}) + +function read(path) { + return readFileSync(path, 'utf8') +} + +function importsSharedPatterns(path) { + return SHARED_IMPORT.test(read(path)) +} + +function isReleaseTriggered(workflow) { + const on = workflow.on + if (typeof on === 'string') { + return on === 'release' + } + if (Array.isArray(on)) { + return on.includes('release') + } + return typeof on === 'object' && on !== null && 'release' in on +} + +function releaseTriggeredWorkflows() { + return readdirSync(WORKFLOWS_DIR) + .filter((name) => /\.ya?ml$/.test(name)) + .map((name) => join(WORKFLOWS_DIR, name)) + .filter((path) => isReleaseTriggered(parse(read(path)))) +} + +function scriptsNamedIn(text) { + return [...text.matchAll(/((?:config|\.github)\/scripts\/[\w.-]+\.mjs)/g)].map( + (match) => match[1] + ) +} + +function releaseListingScripts() { + return SCRIPT_DIRS.flatMap((dir) => + readdirSync(dir) + .filter((name) => name.endsWith('.mjs') && !name.includes('.test.')) + .map((name) => join(dir, name)) + ).filter((path) => LISTS_RELEASES.test(read(path))) +} + +/** + * Scripts that list releases yet admit only desktop tags by their own shape. Each entry proves, + * by calling the script, that a rules release never passes; a new listing script must import the + * shared patterns or add a proof here. + */ +const DESKTOP_ONLY_PROOFS = { + 'config/scripts/create-draft-release.mjs': async () => { + const { latestPreviousPublishedDesktopReleaseTag } = await import('./create-draft-release.mjs') + const releases = [ + ...RULES_TAGS.map((tag) => rulesRelease(tag)), + { tag_name: 'v1.4.1', draft: false } + ] + expect(latestPreviousPublishedDesktopReleaseTag(releases, 'v1.4.2')).toBe('v1.4.1') + }, + 'config/scripts/publish-complete-draft-releases.mjs': async () => { + const { isReleaseCutDraft } = await import('./publish-complete-draft-releases.mjs') + for (const tag of RULES_TAGS) { + expect(isReleaseCutDraft(rulesRelease(tag, { draft: true }))).toBe(false) + } + }, + 'config/scripts/latest-stable-release.mjs': async () => { + const { latestStableDesktopReleaseTag } = await import('./latest-stable-release.mjs') + const releases = [ + ...RULES_TAGS.map((tag) => rulesRelease(tag, { prerelease: false })), + { tag_name: 'v1.4.1' } + ] + expect(latestStableDesktopReleaseTag(releases)).toBe('v1.4.1') + }, + 'config/scripts/assert-github-release-is-draft.mjs': async () => { + const { matchingDesktopReleases } = await import('./assert-github-release-is-draft.mjs') + expect( + matchingDesktopReleases( + RULES_TAGS.map((tag) => rulesRelease(tag)), + 'v1.4.1' + ) + ).toEqual([]) + }, + 'config/scripts/verify-release-required-assets.mjs': async () => { + const { verifyRequiredReleaseAssets } = await import('./verify-release-required-assets.mjs') + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify(RULES_TAGS.map((tag) => rulesRelease(tag))))) + ) + await expect( + verifyRequiredReleaseAssets({ repo: 'stablyai/orca', tag: 'v1.4.1', token: '' }) + ).rejects.toThrow('was not found') + } +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('release tag pattern census', () => { + it('finds the release-triggered workflows and release-listing scripts it guards', () => { + expect(releaseTriggeredWorkflows().length).toBeGreaterThan(0) + expect(releaseListingScripts().length).toBeGreaterThan(0) + }) + + it.each(releaseTriggeredWorkflows())('%s classifies tags through the shared patterns', (path) => { + const text = read(path) + const viaScript = scriptsNamedIn(text).some(importsSharedPatterns) + const viaShellPattern = text.includes(DESKTOP_STABLE_TAG_SHELL_PATTERN) + expect( + viaScript || viaShellPattern, + `${path} names no script importing release-tag-patterns.mjs and embeds no shared pattern` + ).toBe(true) + }) + + it.each(releaseTriggeredWorkflows())( + '%s checks out the shared patterns beside its script', + (path) => { + const workflow = parse(read(path)) + const sparse = Object.values(workflow.jobs) + .flatMap((job) => job.steps ?? []) + .map((step) => step.with?.['sparse-checkout']) + .filter((value) => typeof value === 'string') + for (const paths of sparse) { + if (scriptsNamedIn(paths).some(importsSharedPatterns)) { + expect(paths).toContain('config/scripts/release-tag-patterns.mjs') + } + } + } + ) + + it.each(releaseListingScripts())( + '%s imports the shared patterns or proves it admits only desktop tags', + async (path) => { + if (importsSharedPatterns(path)) { + return + } + const proof = DESKTOP_ONLY_PROOFS[path] + expect( + proof, + `${path} lists releases: import release-tag-patterns.mjs or add a proof` + ).toBeDefined() + await proof() + } + ) + + it('keeps no proof for a script that no longer lists releases', () => { + const listing = new Set(releaseListingScripts()) + expect(Object.keys(DESKTOP_ONLY_PROOFS).filter((path) => !listing.has(path))).toEqual([]) + }) + + it('never classifies an agent state rules tag as a desktop release', () => { + for (const tag of RULES_TAGS) { + expect(isAgentStateRulesTag(tag)).toBe(true) + expect(DESKTOP_STABLE_TAG.test(tag) || DESKTOP_RC_TAG.test(tag)).toBe(false) + } + expect(() => agentStateRulesTag(1, 'beta')).toThrow() + }) +}) diff --git a/config/scripts/release-tag-patterns.mjs b/config/scripts/release-tag-patterns.mjs new file mode 100644 index 00000000000..43bae852b5f --- /dev/null +++ b/config/scripts/release-tag-patterns.mjs @@ -0,0 +1,30 @@ +// The tag families published as GitHub releases of this repo. Every release-triggered workflow and +// every script that lists releases classifies tags through this file, so a new family (like the +// agent state rules) is excluded or admitted in one place; release-tag-pattern-census.test.mjs +// enforces that. + +const NUMBER = '(?:0|[1-9][0-9]*)' +const VERSION = `${NUMBER}\\.${NUMBER}\\.${NUMBER}` + +/** The stable desktop tag as a bash `[[ =~ ]]` pattern, for workflows that gate inline. */ +export const DESKTOP_STABLE_TAG_SHELL_PATTERN = + '^v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$' + +export const DESKTOP_STABLE_TAG = new RegExp(DESKTOP_STABLE_TAG_SHELL_PATTERN) +export const DESKTOP_RC_TAG = new RegExp(`^v${VERSION}-rc\\.${NUMBER}(?:\\.[0-9A-Za-z]+)?$`) +export const MOBILE_TAG = new RegExp(`^mobile(?:-android)?-v${VERSION}$`) + +/** The agent state rules bundle, one release per rules engine and channel, updated in place. */ +const AGENT_STATE_RULES_TAG = /^agent-state-rules-engine-[1-9][0-9]*-(?:next|stable)$/ + +export function isAgentStateRulesTag(tag) { + return AGENT_STATE_RULES_TAG.test(tag) +} + +export function agentStateRulesTag(engineVersion, channel) { + const tag = `agent-state-rules-engine-${engineVersion}-${channel}` + if (!isAgentStateRulesTag(tag)) { + throw new Error(`not an agent state rules tag: ${tag}`) + } + return tag +} diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 854b4dfb723..ad9f51e7ad6 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -266,6 +266,13 @@ async function startOrcadRuntime( }) getAppEnvironment().onWillQuit(() => sessionSearch?.dispose()) + // Why: this host evaluates its own panes, so it keeps its own rules current. + const { startAgentStateRulesLiveUpdates } = + await import('../runtime/agent-state-rules/agent-state-rules-live-update') + startAgentStateRulesLiveUpdates(profileStore, (rules) => + console.info(`[orcad] agent state rules ${rules.version} (${rules.source})`) + ) + // Why here too and not only on the desktop: nothing else republishes `session.tabs` when a // pane's status row changes, and orcad's whole job is serving paired clients. uninstallHookStatusRepublish = installHookStatusSessionTabsRepublish( diff --git a/src/main/orcad/orcad-push-startup.test.ts b/src/main/orcad/orcad-push-startup.test.ts index 21ad507c016..530d06f4ee8 100644 --- a/src/main/orcad/orcad-push-startup.test.ts +++ b/src/main/orcad/orcad-push-startup.test.ts @@ -30,6 +30,10 @@ vi.mock('./orcad-daemon-supervision', () => ({ stopOrcadDaemon: async () => {} })) vi.mock('./orcad-health', () => ({ collectOrcadHealth: async () => ({}) })) +// Why: the real updater would fetch rules from GitHub inside a unit test. +vi.mock('../runtime/agent-state-rules/agent-state-rules-live-update', () => ({ + startAgentStateRulesLiveUpdates: () => {} +})) vi.mock('../daemon/daemon-init', () => ({ daemonOwnsFreshPersistentPtys: () => false })) vi.mock('../ipc/pty', () => ({ registerHeadlessPtyRuntime: async () => {}, diff --git a/src/main/proxy-guarded-fetch-call-site-audit.test.ts b/src/main/proxy-guarded-fetch-call-site-audit.test.ts index 4427b11f55d..b92b266f427 100644 --- a/src/main/proxy-guarded-fetch-call-site-audit.test.ts +++ b/src/main/proxy-guarded-fetch-call-site-audit.test.ts @@ -20,7 +20,9 @@ const AUDITED_NON_NET_FETCH_CALLS = new Map([ ['main/rate-limits/minimax/minimax-request-context.ts', 2], // Injected HttpClient, not a session: resolves to net.fetch on defaultSession // (main/host/electron-http-client.ts) or to the global-fetch-audited Node fallback. - ['main/jira/authenticated-request.ts', 1] + ['main/jira/authenticated-request.ts', 1], + // The same injected HttpClient, and the updater's deps.fetch that it is passed as. + ['main/runtime/agent-state-rules/agent-state-rules-live-update.ts', 2] ]) // `globalThis.fetch` / `global.fetch` belong to global-fetch-call-site-audit.test.ts. diff --git a/src/main/runtime/agent-state-rules/active-agent-state-rules.ts b/src/main/runtime/agent-state-rules/active-agent-state-rules.ts new file mode 100644 index 00000000000..bc6c0e1eff8 --- /dev/null +++ b/src/main/runtime/agent-state-rules/active-agent-state-rules.ts @@ -0,0 +1,55 @@ +import { BUNDLED_AGENT_STATE_RULE_FILES } from './agent-state-rules-catalog' +import { BUNDLED_AGENT_STATE_RULES_VERSION } from './agent-state-rules-bundle' +import type { AgentStateRulesFile } from './agent-state-rules-schema' + +/** Where the rules in use came from: precedence is override, then downloaded, then bundled. */ +export type AgentStateRulesSource = 'bundled' | 'downloaded' | 'override' + +export type ActiveAgentStateRules = { + files: readonly AgentStateRulesFile[] + version: number + source: AgentStateRulesSource +} + +export const BUNDLED_AGENT_STATE_RULES: ActiveAgentStateRules = { + files: BUNDLED_AGENT_STATE_RULE_FILES, + version: BUNDLED_AGENT_STATE_RULES_VERSION, + source: 'bundled' +} + +let active = BUNDLED_AGENT_STATE_RULES + +/** Replaces the bundled files of the same id; an agent the bundle does not name keeps its own. */ +export function overlayOnBundledAgentStateRules( + files: readonly AgentStateRulesFile[] +): readonly AgentStateRulesFile[] { + const byId = new Map(files.map((file) => [file.id, file])) + const merged = BUNDLED_AGENT_STATE_RULES.files.map((file) => byId.get(file.id) ?? file) + const bundledIds = new Set(BUNDLED_AGENT_STATE_RULES.files.map((file) => file.id)) + return [...merged, ...files.filter((file) => !bundledIds.has(file.id))] +} + +export function getActiveAgentStateRules(): ActiveAgentStateRules { + return active +} + +/** Hot reload: every compiled view below recompiles from `next` on its next read. */ +export function activateAgentStateRules(next: ActiveAgentStateRules): void { + active = next +} + +/** + * A view compiled from the active files, rebuilt only when they change. Why keyed on the array: + * readers run on every poll, and one identity check is all a stable rule set costs them. + */ +export function compiledFromActiveAgentStateRules( + compile: (files: readonly AgentStateRulesFile[]) => T +): () => T { + let compiled: { files: readonly AgentStateRulesFile[]; value: T } | null = null + return () => { + if (compiled?.files !== active.files) { + compiled = { files: active.files, value: compile(active.files) } + } + return compiled.value + } +} diff --git a/src/main/runtime/agent-state-rules/agent-state-rules-bundle.test.ts b/src/main/runtime/agent-state-rules/agent-state-rules-bundle.test.ts new file mode 100644 index 00000000000..679c6f7a3d4 --- /dev/null +++ b/src/main/runtime/agent-state-rules/agent-state-rules-bundle.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_STATE_RULES_BUNDLE_MAX_BYTES, + LIVE_UPDATABLE_AGENT_STATE_RULE_IDS, + parseAgentStateRulesBundle +} from './agent-state-rules-bundle' +import { BUNDLED_AGENT_STATE_RULE_FILES } from './agent-state-rules-catalog' + +function file(id: string): Record { + const found = BUNDLED_AGENT_STATE_RULE_FILES.find((candidate) => candidate.id === id) + return structuredClone({ ...found }) +} + +function bundle(overrides: Record = {}): string { + return JSON.stringify({ + version: 2, + engineVersion: 1, + files: [file('claude')], + ...overrides + }) +} + +function rejection(text: string, scope: 'live-updatable' | 'any-agent' = 'live-updatable') { + const result = parseAgentStateRulesBundle(text, scope) + return result.ok ? null : result.error +} + +describe('agent state rules bundle', () => { + it('accepts a live-updatable file and carries the bundledOnly flag', () => { + const result = parseAgentStateRulesBundle(bundle({ bundledOnly: true }), 'live-updatable') + expect(result).toMatchObject({ + ok: true, + bundle: { version: 2, bundledOnly: true } + }) + }) + + it.each([ + ['an unknown top-level field', bundle({ signature: 'x' }), 'Unrecognized key'], + ['a version that is not a positive integer', bundle({ version: '2026.10.02' }), 'version'], + ['version zero', bundle({ version: 0 }), 'version'], + ['a missing version', bundle({ version: undefined }), 'version'], + ['another engine', bundle({ engineVersion: 2 }), 'engineVersion'], + [ + 'a file with a misspelled field', + bundle({ files: [{ ...file('claude'), rule: [] }] }), + 'agent state rules file 0' + ], + ['two files for one agent', bundle({ files: [file('claude'), file('claude')] }), 'two files'], + ['an agent with no transcript suite', bundle({ files: [file('gemini')] }), 'gemini'] + ])('rejects %s', (_label, text, error) => { + expect(rejection(text)).toContain(error) + }) + + it('rejects text over the size cap before parsing it', () => { + expect(rejection(`${bundle()}${' '.repeat(AGENT_STATE_RULES_BUNDLE_MAX_BYTES)}`)).toBe( + `larger than ${AGENT_STATE_RULES_BUNDLE_MAX_BYTES} bytes` + ) + }) + + it('lets a local override carry any agent', () => { + expect(rejection(bundle({ files: [file('gemini')] }), 'any-agent')).toBeNull() + }) + + it('names only agents that have a bundled rule file', () => { + const bundled = new Set(BUNDLED_AGENT_STATE_RULE_FILES.map((rules) => rules.id)) + expect([...LIVE_UPDATABLE_AGENT_STATE_RULE_IDS].filter((id) => !bundled.has(id))).toEqual([]) + }) +}) diff --git a/src/main/runtime/agent-state-rules/agent-state-rules-bundle.ts b/src/main/runtime/agent-state-rules/agent-state-rules-bundle.ts new file mode 100644 index 00000000000..b4b77cd1ffb --- /dev/null +++ b/src/main/runtime/agent-state-rules/agent-state-rules-bundle.ts @@ -0,0 +1,69 @@ +import { z } from 'zod' +import release from './agent-state-rules-release.json' +import { parseAgentStateRuleFiles } from './agent-state-rules-catalog' +import { + AGENT_STATE_RULES_ENGINE_VERSION, + type AgentStateRulesFile +} from './agent-state-rules-schema' + +/** + * The published bundle, `agent-state-rules.json`: the live-updatable agents' rule files, built by + * config/scripts/agent-state-rules-bundle.mjs from the per-agent files and + * agent-state-rules-release.json. `bundledOnly` tells apps to fall back to the rules they shipped. + */ + +// Why a cap: the file comes off the network and is parsed on the main thread. +export const AGENT_STATE_RULES_BUNDLE_MAX_BYTES = 256 * 1024 + +/** The version of the rules this build ships; a download must be higher to replace them. */ +export const BUNDLED_AGENT_STATE_RULES_VERSION: number = release.version + +/** The rule files a rules release may carry: those the publish gate can replay transcripts for. */ +export const LIVE_UPDATABLE_AGENT_STATE_RULE_IDS: ReadonlySet = new Set( + release.liveUpdatable +) + +// Why the engine is checked here, before the files: a newer engine's files fail the file schema +// for a reason that is not theirs. +const BundleEnvelopeSchema = z + .object({ + version: z.number().int().positive(), + engineVersion: z.literal(AGENT_STATE_RULES_ENGINE_VERSION), + bundledOnly: z.boolean().optional(), + files: z.array(z.unknown()) + }) + .strict() + +export type AgentStateRulesBundle = { + version: number + bundledOnly: boolean + files: readonly AgentStateRulesFile[] +} + +export type AgentStateRulesBundleParse = + | { ok: true; bundle: AgentStateRulesBundle } + | { ok: false; error: string } + +/** + * Validates a bundle whole: a file that fails any check is rejected, never partly applied. + * `any-agent` is for the local override, which the user chose, so it may carry any agent. + */ +export function parseAgentStateRulesBundle( + text: string, + scope: 'live-updatable' | 'any-agent' +): AgentStateRulesBundleParse { + if (Buffer.byteLength(text, 'utf8') > AGENT_STATE_RULES_BUNDLE_MAX_BYTES) { + return { ok: false, error: `larger than ${AGENT_STATE_RULES_BUNDLE_MAX_BYTES} bytes` } + } + try { + const { version, bundledOnly, files } = BundleEnvelopeSchema.parse(JSON.parse(text)) + const parsed = parseAgentStateRuleFiles(files) + const outside = parsed.find((file) => !LIVE_UPDATABLE_AGENT_STATE_RULE_IDS.has(file.id)) + if (scope === 'live-updatable' && outside) { + return { ok: false, error: `carries ${outside.id}, which has no transcript suite to gate it` } + } + return { ok: true, bundle: { version, bundledOnly: bundledOnly ?? false, files: parsed } } + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } +} diff --git a/src/main/runtime/agent-state-rules/agent-state-rules-engine.ts b/src/main/runtime/agent-state-rules/agent-state-rules-engine.ts index 5ba0b853b60..afae652ead9 100644 --- a/src/main/runtime/agent-state-rules/agent-state-rules-engine.ts +++ b/src/main/runtime/agent-state-rules/agent-state-rules-engine.ts @@ -1,7 +1,7 @@ import type { AgentStatus } from '../../../shared/agent-detection' import type { TuiAgent } from '../../../shared/tui-agent' import { compileScreenCondition } from './agent-state-rule-matchers' -import { BUNDLED_AGENT_STATE_RULE_FILES } from './agent-state-rules-catalog' +import { compiledFromActiveAgentStateRules } from './active-agent-state-rules' import { UNKNOWN_PANE_RULES_ID, type AgentStateRuleAnswer, @@ -105,20 +105,23 @@ type RulesKey = TuiAgent | typeof UNKNOWN_PANE_RULES_ID type CompiledFile = { rules: CompiledRule[]; readsTrustedScreen: boolean; hooks: HookAuthority } -const FILES_BY_KEY: ReadonlyMap = new Map( - BUNDLED_AGENT_STATE_RULE_FILES.map((file) => [ - file.id, - { - rules: compileAgentRules(file), - readsTrustedScreen: file.profile?.screenSource === 'trusted', - hooks: file.profile?.hooks ?? 'identity-only' - } - ]) +const filesByKey = compiledFromActiveAgentStateRules( + (files): ReadonlyMap => + new Map( + files.map((file) => [ + file.id, + { + rules: compileAgentRules(file), + readsTrustedScreen: file.profile?.screenSource === 'trusted', + hooks: file.profile?.hooks ?? 'identity-only' + } + ]) + ) ) // Why the unknown-pane file for a null agent: an adopted pane can still run a known agent. function compiledFileFor(agent: TuiAgent | null | undefined): CompiledFile | undefined { - return FILES_BY_KEY.get(agent ?? UNKNOWN_PANE_RULES_ID) + return filesByKey().get(agent ?? UNKNOWN_PANE_RULES_ID) } /** diff --git a/src/main/runtime/agent-state-rules/agent-state-rules-live-update.test.ts b/src/main/runtime/agent-state-rules/agent-state-rules-live-update.test.ts new file mode 100644 index 00000000000..8400f38d9fd --- /dev/null +++ b/src/main/runtime/agent-state-rules/agent-state-rules-live-update.test.ts @@ -0,0 +1,406 @@ +import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getTerminalTailSentinelMatches } from '../terminal-tail-sentinel-index' +import { + activateAgentStateRules, + BUNDLED_AGENT_STATE_RULES, + getActiveAgentStateRules +} from './active-agent-state-rules' +import { BUNDLED_AGENT_STATE_RULES_VERSION } from './agent-state-rules-bundle' +import { BUNDLED_AGENT_STATE_RULE_FILES } from './agent-state-rules-catalog' +import { idleTitleRequiresQuiet } from './agent-state-rules-engine' +import { + AgentStateRulesLiveUpdater, + agentStateRulesCacheFileName, + agentStateRulesChannelForAppVersion, + agentStateRulesDownloadUrl, + type AgentStateRulesLiveUpdateDeps +} from './agent-state-rules-live-update' +import { detectExplicitIdleStatusFromTitle } from '../terminal-wait-detection' +import { showsIdleTitleAnchor } from './agent-state-title-anchors' + +const NEWER = 9999 +const NEWEST = 10000 + +function bundledFile(id: string): Record { + const file = BUNDLED_AGENT_STATE_RULE_FILES.find((candidate) => candidate.id === id) + if (!file) { + throw new Error(`no bundled ${id}`) + } + return structuredClone(file) +} + +// A Claude file whose title rule settles without quiet: visible through idleTitleRequiresQuiet. +function claudeWithoutQuiet(): Record { + const claude = bundledFile('claude') + return { + ...claude, + rules: [ + { + id: 'idle_title', + why: 'test', + priority: 100, + when: { region: 'title', status: 'idle' }, + answer: { state: 'idle', strength: 'weak', requiresQuiet: false } + } + ] + } +} + +function bundleText( + version: number, + files: unknown[] = [claudeWithoutQuiet()], + extra: Record = {} +): string { + return JSON.stringify({ version, engineVersion: 1, ...extra, files }) +} + +type Harness = { + updater: AgentStateRulesLiveUpdater + userData: string + settings: { agentStateRulesPath?: string | null; agentStateRulesLiveUpdates?: boolean } + fetch: ReturnType + onActivated: ReturnType +} + +let userData: string +let warn: ReturnType + +function status(): { version: number; source: string } { + const { version, source } = getActiveAgentStateRules() + return { version, source } +} + +function warnings(): string[] { + return warn.mock.calls.map(([message]) => String(message)) +} + +function harness(overrides: Partial = {}): Harness { + const settings: Harness['settings'] = {} + const fetch = vi.fn(async () => new Response('Not Found', { status: 404 })) + const onActivated = vi.fn() + const updater = new AgentStateRulesLiveUpdater({ + userDataPath: userData, + appVersion: '1.4.0', + isPackaged: true, + fetch, + readSettings: () => settings, + onActivated, + ...overrides + }) + return { updater, userData, settings, fetch, onActivated } +} + +function serve(h: Harness, text: string): void { + h.fetch.mockImplementation(async () => new Response(text, { status: 200 })) +} + +function cachePath(channel: 'next' | 'stable' = 'stable'): string { + return join(userData, agentStateRulesCacheFileName(channel)) +} + +function writeCache(text: string, channel: 'next' | 'stable' = 'stable'): void { + writeFileSync(cachePath(channel), text) +} + +beforeEach(() => { + userData = mkdtempSync(join(tmpdir(), 'agent-state-rules-live-update-')) + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +afterEach(() => { + activateAgentStateRules(BUNDLED_AGENT_STATE_RULES) + warn.mockRestore() + rmSync(userData, { recursive: true, force: true }) +}) + +describe('agent state rules channel and URL', () => { + it.each([ + ['1.4.0', 'stable'], + ['1.4.1-rc.2', 'next'], + ['1.4.1-rc.2.perf', 'next'], + ['not-a-version', null] + ])('maps app version %s to %s', (version, channel) => { + expect(agentStateRulesChannelForAppVersion(version)).toBe(channel) + }) + + it('fetches the fixed release-download URL for the engine and channel', () => { + expect(agentStateRulesDownloadUrl('next')).toBe( + 'https://github.com/stablyai/orca/releases/download/agent-state-rules-engine-1-next/agent-state-rules.json' + ) + }) +}) + +describe('agent state rules live updates', () => { + it('starts on the bundled rules and fetches the stable tag for a stable app', async () => { + const h = harness() + await h.updater.start() + expect(h.fetch).toHaveBeenCalledWith(agentStateRulesDownloadUrl('stable'), expect.anything()) + expect(status()).toEqual({ version: BUNDLED_AGENT_STATE_RULES_VERSION, source: 'bundled' }) + expect(warnings()).toEqual(['[agent-state-rules] download failed: HTTP 404']) + h.updater.stop() + }) + + it('accepts a newer download, writes it atomically, and hot-reloads the engine', async () => { + const h = harness({ appVersion: '1.4.1-rc.0' }) + expect(idleTitleRequiresQuiet('claude')).toBe(true) + const text = bundleText(NEWER) + serve(h, text) + await h.updater.start() + expect(h.fetch).toHaveBeenCalledWith(agentStateRulesDownloadUrl('next'), expect.anything()) + expect(status()).toEqual({ version: NEWER, source: 'downloaded' }) + expect(warnings()).toEqual([]) + expect(idleTitleRequiresQuiet('claude')).toBe(false) + expect(readFileSync(cachePath('next'), 'utf8')).toBe(text) + expect(readdirSync(userData)).toEqual([agentStateRulesCacheFileName('next')]) + expect(h.onActivated).toHaveBeenCalledWith({ version: NEWER, source: 'downloaded' }) + h.updater.stop() + }) + + it('keeps every agent the download does not carry on its bundled file', async () => { + const h = harness() + serve(h, bundleText(NEWER)) + await h.updater.start() + const active = getActiveAgentStateRules() + expect(active.files.map((file) => file.id)).toEqual( + BUNDLED_AGENT_STATE_RULE_FILES.map((file) => file.id) + ) + expect(active.files.find((file) => file.id === 'gemini')).toBe( + BUNDLED_AGENT_STATE_RULE_FILES.find((file) => file.id === 'gemini') + ) + h.updater.stop() + }) + + it.each([ + ['not newer than the bundled copy', bundleText(BUNDLED_AGENT_STATE_RULES_VERSION), null], + [ + 'built for another engine', + JSON.stringify({ version: NEWER, engineVersion: 2, files: [] }), + 'download rejected: ' + ], + [ + 'carrying an agent with no transcript suite', + bundleText(NEWER, [bundledFile('gemini')]), + 'download rejected: carries gemini, which has no transcript suite to gate it' + ], + [ + 'an unsafe pattern', + bundleText(NEWER, [ + { + ...bundledFile('claude'), + anchors: [ + { + id: 'bad', + why: 'test', + when: { region: 'title', status: 'idle', match: { regex: '(a+)+$' } }, + answer: { state: 'idle' } + } + ] + } + ]), + 'repeats a group' + ], + ['not JSON', '{', 'download rejected: '], + [ + 'over the size cap', + ' '.repeat(256 * 1024 + 1), + 'download failed: Response body exceeds 262144 byte limit' + ] + ])('refuses a download %s and keeps the bundled rules', async (_label, text, error) => { + const h = harness() + serve(h, text) + await h.updater.start() + expect(status()).toEqual({ version: BUNDLED_AGENT_STATE_RULES_VERSION, source: 'bundled' }) + if (error === null) { + expect(warnings()).toEqual([]) + } else { + expect(warnings()).toEqual([expect.stringContaining(error)]) + } + expect(readdirSync(userData)).toEqual([]) + h.updater.stop() + }) + + it('keeps the last good copy through a 404, a network error and an older download', async () => { + const h = harness() + serve(h, bundleText(NEWEST)) + await h.updater.start() + h.fetch.mockImplementation(async () => new Response('Not Found', { status: 404 })) + await h.updater.refresh() + expect(status()).toMatchObject({ version: NEWEST, source: 'downloaded' }) + h.fetch.mockImplementation(async () => { + throw new Error('offline') + }) + await h.updater.refresh() + expect(status()).toEqual({ version: NEWEST, source: 'downloaded' }) + expect(warnings().at(-1)).toBe('[agent-state-rules] download failed: offline') + serve(h, bundleText(NEWER)) + await h.updater.refresh() + expect(status()).toEqual({ version: NEWEST, source: 'downloaded' }) + expect(JSON.parse(readFileSync(cachePath(), 'utf8'))).toMatchObject({ version: NEWEST }) + h.updater.stop() + }) + + it('activates a cached download newer than the bundled rules before any fetch lands', async () => { + writeCache(bundleText(NEWER)) + const h = harness() + h.fetch.mockImplementation(() => new Promise(() => {})) + void h.updater.start() + await vi.waitFor(() => expect(status().source).toBe('downloaded')) + expect(status().version).toBe(NEWER) + h.updater.stop() + }) + + it('ignores a cached download that is not newer than the bundled rules', async () => { + writeCache(bundleText(BUNDLED_AGENT_STATE_RULES_VERSION)) + const h = harness() + await h.updater.start() + expect(status().source).toBe('bundled') + h.updater.stop() + }) + + it("never reads the other channel's cache", async () => { + // Why: an RC build left next rules in the userData a stable build now shares. + writeCache(bundleText(NEWEST), 'next') + const h = harness() + serve(h, bundleText(NEWER)) + await h.updater.start() + expect(status()).toMatchObject({ version: NEWER, source: 'downloaded' }) + expect(JSON.parse(readFileSync(cachePath('next'), 'utf8'))).toMatchObject({ version: NEWEST }) + h.updater.stop() + }) + + it('lets no superseded start or fetch change the rules after a restart', async () => { + const overridePath = join(userData, 'override.json') + writeFileSync(overridePath, bundleText(1, [bundledFile('gemini')])) + const h = harness() + let land: (response: Response) => void = () => {} + h.fetch.mockImplementation(() => new Promise((resolve) => (land = resolve))) + h.settings.agentStateRulesPath = overridePath + const superseded = h.updater.start() + await vi.waitFor(() => expect(h.fetch).toHaveBeenCalledTimes(1)) + h.settings.agentStateRulesPath = null + h.settings.agentStateRulesLiveUpdates = false + await h.updater.start() + land(new Response(bundleText(NEWER), { status: 200 })) + await superseded + expect(status()).toEqual({ version: BUNDLED_AGENT_STATE_RULES_VERSION, source: 'bundled' }) + expect(readdirSync(userData)).toEqual(['override.json']) + }) + + it('refuses a download no newer than the cached one', async () => { + writeCache(bundleText(NEWEST)) + const h = harness() + serve(h, bundleText(NEWER, [bundledFile('claude')])) + await h.updater.start() + expect(status()).toMatchObject({ version: NEWEST, source: 'downloaded' }) + expect(idleTitleRequiresQuiet('claude')).toBe(false) + h.updater.stop() + }) + + it('falls back to the bundled rules when a published bundle says bundledOnly', async () => { + const h = harness() + serve(h, bundleText(NEWER)) + await h.updater.start() + serve(h, bundleText(NEWEST, [claudeWithoutQuiet()], { bundledOnly: true })) + await h.updater.refresh() + expect(status()).toMatchObject({ source: 'bundled' }) + expect(idleTitleRequiresQuiet('claude')).toBe(true) + h.updater.stop() + }) + + it('uses only the bundled rules when the setting turns live updates off', async () => { + writeCache(bundleText(NEWER)) + const h = harness() + h.settings.agentStateRulesLiveUpdates = false + await h.updater.start() + expect(h.fetch).not.toHaveBeenCalled() + expect(status().source).toBe('bundled') + h.updater.stop() + }) + + it('never fetches from an unpackaged build', async () => { + const h = harness({ isPackaged: false }) + await h.updater.start() + expect(h.fetch).not.toHaveBeenCalled() + h.updater.stop() + }) + + it('ranks a local override over a download over the bundled rules', async () => { + const overridePath = join(userData, 'override.json') + // Why gemini: the override may carry an agent a rules release cannot. + writeFileSync(overridePath, bundleText(1, [bundledFile('gemini')])) + writeCache(bundleText(NEWER)) + const h = harness() + h.settings.agentStateRulesPath = overridePath + await h.updater.start() + expect(status()).toMatchObject({ version: 1, source: 'override' }) + + h.settings.agentStateRulesPath = null + await h.updater.start() + expect(status()).toMatchObject({ version: NEWER, source: 'downloaded' }) + h.updater.stop() + }) + + it('reports a rejected override and keeps the next source in line', async () => { + const overridePath = join(userData, 'override.json') + writeFileSync(overridePath, '{"version":1,"engineVersion":1,"files":[{"id":"claude"}]}') + const h = harness() + h.settings.agentStateRulesPath = overridePath + await h.updater.start() + expect(status().source).toBe('bundled') + expect(warnings()[0]).toContain(`override ${overridePath} rejected`) + expect(h.onActivated).not.toHaveBeenCalled() + h.updater.stop() + }) + + it('still activates an accepted download when the cache cannot be written', async () => { + const h = harness({ userDataPath: join(userData, 'missing', 'dir') }) + serve(h, bundleText(NEWER)) + await h.updater.start() + expect(status().source).toBe('downloaded') + expect(warnings()).toEqual([expect.stringContaining('downloaded rules not cached')]) + h.updater.stop() + }) +}) + +describe('agent state rules hot reload', () => { + it('recompiles the title anchors every pane reads, past the title memo', () => { + expect(showsIdleTitleAnchor('✳ Claude Code')).toBe(true) + expect(detectExplicitIdleStatusFromTitle('✳ Claude Code')).toBe('idle') + activateAgentStateRules({ + ...BUNDLED_AGENT_STATE_RULES, + files: BUNDLED_AGENT_STATE_RULES.files.map((file) => + file.id === 'claude' ? { ...file, anchors: [] } : file + ) + }) + expect(showsIdleTitleAnchor('✳ Claude Code')).toBe(false) + expect(detectExplicitIdleStatusFromTitle('✳ Claude Code')).toBeNull() + }) + + it('rescans a tail the sentinel index already covered when a blocked anchor arrives', () => { + const lines = ['zebra crossing prompt'] + expect(getTerminalTailSentinelMatches(lines)).toEqual([]) + activateAgentStateRules({ + ...BUNDLED_AGENT_STATE_RULES, + files: BUNDLED_AGENT_STATE_RULES.files.map((file) => + file.id === 'claude' + ? { + ...file, + anchors: [ + ...file.anchors, + { + id: 'zebra', + why: 'test', + when: { region: 'text', find: { lastOf: 'zebra crossing' } }, + answer: { state: 'blocked', reason: 'agent-approval-prompt' } + } + ] + } + : file + ) + }) + expect(getTerminalTailSentinelMatches(lines)).toEqual([0]) + }) +}) diff --git a/src/main/runtime/agent-state-rules/agent-state-rules-live-update.ts b/src/main/runtime/agent-state-rules/agent-state-rules-live-update.ts new file mode 100644 index 00000000000..ee4e7440967 --- /dev/null +++ b/src/main/runtime/agent-state-rules/agent-state-rules-live-update.ts @@ -0,0 +1,259 @@ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { getAppEnvironment } from '../../../shared/app-environment' +import { readFetchResponseTextWithinLimit } from '../../../shared/fetch-response-body' +import type { GlobalSettings } from '../../../shared/global-settings-types' +import { getVersionChannel, MAIN_RELEASE_REPO } from '../../../shared/release-channel' +import { getMainHttpClient } from '../../network/http-client' +import { writePluginFileAtomically } from '../../plugins/plugin-atomic-file-write' +import { + activateAgentStateRules, + BUNDLED_AGENT_STATE_RULES, + getActiveAgentStateRules, + overlayOnBundledAgentStateRules, + type ActiveAgentStateRules, + type AgentStateRulesSource +} from './active-agent-state-rules' +import { + AGENT_STATE_RULES_BUNDLE_MAX_BYTES, + BUNDLED_AGENT_STATE_RULES_VERSION, + parseAgentStateRulesBundle, + type AgentStateRulesBundle +} from './agent-state-rules-bundle' +import { AGENT_STATE_RULES_ENGINE_VERSION } from './agent-state-rules-schema' + +const REFRESH_INTERVAL_MS = 4 * 60 * 60 * 1000 +const FETCH_TIMEOUT_MS = 30_000 + +export type AgentStateRulesChannel = 'next' | 'stable' + +/** Stable apps read the stable tag; RC, hourly, daily and adhoc builds soak the next one first. */ +export function agentStateRulesChannelForAppVersion( + appVersion: string +): AgentStateRulesChannel | null { + const channel = getVersionChannel(appVersion) + if (!channel) { + return null + } + return channel === 'stable' ? 'stable' : 'next' +} + +// Why a fixed release-download URL: no API call or rate limit, and no "latest" lookup to steer. +export function agentStateRulesDownloadUrl(channel: AgentStateRulesChannel): string { + const tag = `agent-state-rules-engine-${AGENT_STATE_RULES_ENGINE_VERSION}-${channel}` + return `https://github.com/${MAIN_RELEASE_REPO}/releases/download/${tag}/agent-state-rules.json` +} + +// Why per channel: stable and RC builds share userData, and a stable app must not run, or be +// held below, rules that only next has published. +export function agentStateRulesCacheFileName(channel: AgentStateRulesChannel): string { + return `agent-state-rules-${channel}.json` +} + +type LiveUpdateSettings = Pick + +export type AgentStateRulesLiveUpdateDeps = { + userDataPath: string + appVersion: string + /** Unpackaged dev and test runs never download. */ + isPackaged: boolean + fetch: (url: string, init?: RequestInit) => Promise + readSettings: () => LiveUpdateSettings + /** Called when the active version or source changes, for diagnostics and crash reports. */ + onActivated: (rules: { version: number; source: AgentStateRulesSource }) => void +} + +function warn(message: string): void { + console.warn(`[agent-state-rules] ${message}`) +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function parseOrWarn( + label: string, + text: string, + scope: 'live-updatable' | 'any-agent' +): AgentStateRulesBundle | null { + const parsed = parseAgentStateRulesBundle(text, scope) + if (!parsed.ok) { + warn(`${label} rejected: ${parsed.error}`) + return null + } + return parsed.bundle +} + +async function readBundleFile( + label: string, + path: string, + scope: 'live-updatable' | 'any-agent', + warnIfMissing: boolean +): Promise { + try { + return parseOrWarn(label, await readFile(path, 'utf8'), scope) + } catch (error) { + const missing = error instanceof Error && 'code' in error && error.code === 'ENOENT' + if (!missing || warnIfMissing) { + warn(`${label} unreadable: ${describeError(error)}`) + } + return null + } +} + +/** + * Keeps the active agent state rules current: a local override, else a downloaded copy newer than + * the bundled one, else the bundled rules. Any failure keeps the last good copy active. + */ +export class AgentStateRulesLiveUpdater { + private override: AgentStateRulesBundle | null = null + /** Always newer than the bundled rules, so it is also the floor a download must clear. */ + private downloaded: AgentStateRulesBundle | null = null + // Why an identity per start: a settings change restarts while an earlier read or fetch may + // still be pending, and a superseded one must not change the active rules. + private run: { channel: AgentStateRulesChannel | null } | null = null + private timer: ReturnType | null = null + + constructor(private readonly deps: AgentStateRulesLiveUpdateDeps) {} + + /** Loads the override and the cached download, then fetches now and on an interval. Re-run it + * when the settings it reads change. */ + async start(): Promise { + this.stop() + const settings = this.deps.readSettings() + const live = this.deps.isPackaged && settings.agentStateRulesLiveUpdates !== false + const run = { channel: live ? agentStateRulesChannelForAppVersion(this.deps.appVersion) : null } + this.run = run + const overridePath = settings.agentStateRulesPath + const [override, cached] = await Promise.all([ + // Why any agent: the user chose this file, so the transcript gate on releases does not apply. + overridePath + ? readBundleFile(`override ${overridePath}`, overridePath, 'any-agent', true) + : null, + run.channel + ? readBundleFile('cached rules', this.cachePath(run.channel), 'live-updatable', false) + : null + ]) + if (this.run !== run) { + return + } + this.override = override + // Why re-check the version: an app update may have bundled rules newer than the cache. + this.downloaded = cached && cached.version > BUNDLED_AGENT_STATE_RULES_VERSION ? cached : null + this.activate() + if (run.channel) { + this.timer = setInterval(() => void this.refresh(), REFRESH_INTERVAL_MS) + this.timer.unref?.() + await this.refresh() + } + } + + stop(): void { + this.run = null + if (this.timer) { + clearInterval(this.timer) + this.timer = null + } + } + + /** Fetches the channel's file now; does nothing unless started with live updates on. */ + async refresh(): Promise { + const run = this.run + if (!run?.channel) { + return + } + const text = await this.fetchText(run.channel) + if (this.run !== run || text === null) { + return + } + const bundle = parseOrWarn('download', text, 'live-updatable') + // Why higher than both: an app whose bundled rules already hold a fix must not be shadowed by + // an older download, and a cached copy must never be replaced by an older one. + const floor = this.downloaded?.version ?? BUNDLED_AGENT_STATE_RULES_VERSION + if (!bundle || bundle.version <= floor) { + return + } + this.downloaded = bundle + this.activate() + try { + await writePluginFileAtomically(this.cachePath(run.channel), text) + } catch (error) { + warn(`downloaded rules not cached: ${describeError(error)}`) + } + } + + private cachePath(channel: AgentStateRulesChannel): string { + return join(this.deps.userDataPath, agentStateRulesCacheFileName(channel)) + } + + /** The published file's text, or null after warning why there is none. */ + private async fetchText(channel: AgentStateRulesChannel): Promise { + try { + const response = await this.deps.fetch(agentStateRulesDownloadUrl(channel), { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) + }) + if (!response.ok) { + await response.body?.cancel().catch(() => undefined) + // Why not fatal: a 404 is also what a re-upload in progress looks like. + warn(`download failed: HTTP ${response.status}`) + return null + } + return await readFetchResponseTextWithinLimit(response, AGENT_STATE_RULES_BUNDLE_MAX_BYTES) + } catch (error) { + warn(`download failed: ${describeError(error)}`) + return null + } + } + + private resolveActive(): ActiveAgentStateRules { + const chosen = this.override ?? (this.downloaded?.bundledOnly ? null : this.downloaded) + if (!chosen) { + return BUNDLED_AGENT_STATE_RULES + } + return { + files: overlayOnBundledAgentStateRules(chosen.files), + version: chosen.version, + source: chosen === this.override ? 'override' : 'downloaded' + } + } + + private activate(): void { + const previous = getActiveAgentStateRules() + const next = this.resolveActive() + activateAgentStateRules(next) + if (previous.version !== next.version || previous.source !== next.source) { + this.deps.onActivated({ version: next.version, source: next.source }) + } + } +} + +type SettingsStore = { + getSettings: () => LiveUpdateSettings + onSettingsChanged: (listener: (updates: Partial) => void) => unknown +} + +/** + * Starts live updates on this host: the desktop, `orca serve` and orcad each fetch their own copy, + * so a paired client never supplies the rules a host evaluates with. + */ +export function startAgentStateRulesLiveUpdates( + store: SettingsStore, + onActivated: AgentStateRulesLiveUpdateDeps['onActivated'] +): void { + const environment = getAppEnvironment() + const updater = new AgentStateRulesLiveUpdater({ + userDataPath: environment.getPath('userData'), + appVersion: environment.getVersion(), + isPackaged: environment.isPackaged(), + fetch: (url, init) => getMainHttpClient().fetch(url, init), + readSettings: () => store.getSettings(), + onActivated + }) + void updater.start() + store.onSettingsChanged((updates) => { + if ('agentStateRulesPath' in updates || 'agentStateRulesLiveUpdates' in updates) { + void updater.start() + } + }) + environment.onWillQuit(() => updater.stop()) +} diff --git a/src/main/runtime/agent-state-rules/agent-state-rules-release.json b/src/main/runtime/agent-state-rules/agent-state-rules-release.json new file mode 100644 index 00000000000..6876a5a2170 --- /dev/null +++ b/src/main/runtime/agent-state-rules/agent-state-rules-release.json @@ -0,0 +1,14 @@ +{ + "version": 1, + "liveUpdatable": [ + "antigravity", + "claude", + "cline", + "codex", + "cursor", + "omp", + "opencode", + "prime-agent", + "unknown-pane" + ] +} diff --git a/src/main/runtime/agent-state-rules/agent-state-rules-schema.ts b/src/main/runtime/agent-state-rules/agent-state-rules-schema.ts index f5aafc2db3a..c3735d2ef9e 100644 --- a/src/main/runtime/agent-state-rules/agent-state-rules-schema.ts +++ b/src/main/runtime/agent-state-rules/agent-state-rules-schema.ts @@ -11,7 +11,7 @@ import { findUnsafePatternReason } from './agent-state-rule-pattern-safety' * show) plus `answer`. Once a version ships, adding a region, predicate or answer bumps * `engineVersion`; until then version 1 is still being defined. */ -const AGENT_STATE_RULES_ENGINE_VERSION = 1 +export const AGENT_STATE_RULES_ENGINE_VERSION = 1 const MAX_PATTERN_LENGTH = 200 const MAX_RULES = 32 diff --git a/src/main/runtime/agent-state-rules/agent-state-text-anchors.test.ts b/src/main/runtime/agent-state-rules/agent-state-text-anchors.test.ts index 97fda216dc6..2dbdea7c3b1 100644 --- a/src/main/runtime/agent-state-rules/agent-state-text-anchors.test.ts +++ b/src/main/runtime/agent-state-rules/agent-state-text-anchors.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { parseAgentStateRuleFiles } from './agent-state-rules-catalog' import { compileTextAnchors, findPromptAnchorIndexes } from './agent-state-text-anchors' -import { TERMINAL_WAIT_BLOCKED_SENTINEL_RE } from './blocked-text-layer' +import { terminalWaitBlockedSentinelRe } from './blocked-text-layer' import { detectTerminalWaitBlockedReason } from '../terminal-wait-detection' function anchorsOf(anchors: unknown[]) { @@ -79,6 +79,6 @@ describe('the bundled Cursor approval menu', () => { describe('the blocked layer prefilter', () => { it('includes every bundled blocked anchor', () => { - expect(TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test('Run this command?')).toBe(true) + expect(terminalWaitBlockedSentinelRe().test('Run this command?')).toBe(true) }) }) diff --git a/src/main/runtime/agent-state-rules/agent-state-text-anchors.ts b/src/main/runtime/agent-state-rules/agent-state-text-anchors.ts index 5de562de05a..4c59b5a39e5 100644 --- a/src/main/runtime/agent-state-rules/agent-state-text-anchors.ts +++ b/src/main/runtime/agent-state-rules/agent-state-text-anchors.ts @@ -1,7 +1,7 @@ import type { RuntimeTerminalWaitBlockedReason } from '../../../shared/runtime-types' import { startOfLastLines } from '../terminal-wait-tail-window' import { compileTextTest, type TextMatcher } from './agent-state-rule-matchers' -import { BUNDLED_AGENT_STATE_RULE_FILES } from './agent-state-rules-catalog' +import { compiledFromActiveAgentStateRules } from './active-agent-state-rules' import type { Anchor, AgentStateRulesFile, @@ -96,14 +96,16 @@ export function compileTextAnchors(files: readonly AgentStateRulesFile[]): { } } -const TEXT_ANCHORS = compileTextAnchors(BUNDLED_AGENT_STATE_RULE_FILES) +const textAnchors = compiledFromActiveAgentStateRules(compileTextAnchors) /** The literal every blocked anchor needs, for the blocked layer's one-pass prefilter. */ -export const BLOCKED_ANCHOR_LITERALS: readonly string[] = TEXT_ANCHORS.blockedLiterals +export function blockedAnchorLiterals(): readonly string[] { + return textAnchors().blockedLiterals +} /** Every rule file's blocked anchor found in the blocked layer's live window. */ export function findBlockedAnchorSignals(window: string): BlockedTextSignal[] { - return TEXT_ANCHORS.blocked.flatMap((find) => { + return textAnchors().blocked.flatMap((find) => { const hit = find(window) return hit?.answer.state === 'blocked' ? [{ reason: hit.answer.reason, index: hit.index }] : [] }) @@ -119,7 +121,7 @@ export function findPromptAnchorIndexes(normalized: string): { } { let live: number | null = null let ready: number | null = null - for (const find of TEXT_ANCHORS.prompts) { + for (const find of textAnchors().prompts) { const hit = find(normalized) if (hit === null) { continue @@ -134,10 +136,10 @@ export function findPromptAnchorIndexes(normalized: string): { /** Whether any rule file's hold anchor shows: an agent is up but not yet taking input. */ export function showsHoldAnchor(normalized: string): boolean { - return TEXT_ANCHORS.holds.some((find) => find(normalized) !== null) + return textAnchors().holds.some((find) => find(normalized) !== null) } export function showsScreenProbeBanner(text: string): boolean { const normalized = text.toLowerCase() - return TEXT_ANCHORS.screenProbeBanners.some((banner) => normalized.includes(banner)) + return textAnchors().screenProbeBanners.some((banner) => normalized.includes(banner)) } diff --git a/src/main/runtime/agent-state-rules/agent-state-title-anchors.ts b/src/main/runtime/agent-state-rules/agent-state-title-anchors.ts index 67fc6063408..0f41f5e7d00 100644 --- a/src/main/runtime/agent-state-rules/agent-state-title-anchors.ts +++ b/src/main/runtime/agent-state-rules/agent-state-title-anchors.ts @@ -1,6 +1,6 @@ import { isOpenCodeNativeTitle } from '../../../shared/opencode-terminal-title' import { compileTextTest } from './agent-state-rule-matchers' -import { BUNDLED_AGENT_STATE_RULE_FILES } from './agent-state-rules-catalog' +import { compiledFromActiveAgentStateRules } from './active-agent-state-rules' import type { AgentStateRulesFile, NamedTitlePredicate, @@ -26,9 +26,9 @@ function compileTitleAnchors(files: readonly AgentStateRulesFile[]): TitleAnchor ) } -const TITLE_ANCHORS = compileTitleAnchors(BUNDLED_AGENT_STATE_RULE_FILES) +const titleAnchors = compiledFromActiveAgentStateRules(compileTitleAnchors) /** Whether any rule file's title anchor marks an idle-classified `title` as an agent's own rest title. */ export function showsIdleTitleAnchor(title: string): boolean { - return TITLE_ANCHORS.some((matches) => matches(title)) + return titleAnchors().some((matches) => matches(title)) } diff --git a/src/main/runtime/agent-state-rules/blocked-text-layer.ts b/src/main/runtime/agent-state-rules/blocked-text-layer.ts index 9e8d486bb35..9787f6966f9 100644 --- a/src/main/runtime/agent-state-rules/blocked-text-layer.ts +++ b/src/main/runtime/agent-state-rules/blocked-text-layer.ts @@ -1,8 +1,9 @@ import { escapeRegex } from '../../../shared/string-utils' import { findStartupDialogBlockedSignals } from '../startup-dialog-blocked-signals' import { startOfLastNonBlankLines } from '../terminal-wait-tail-window' +import { compiledFromActiveAgentStateRules } from './active-agent-state-rules' import { - BLOCKED_ANCHOR_LITERALS, + blockedAnchorLiterals, findBlockedAnchorSignals, showsHoldAnchor, type BlockedTextSignal @@ -17,10 +18,16 @@ import { const BUILT_IN_SENTINEL_RE = /update available|choose working directory to|codex just got an upgrade|available\s*·|esc\s*skip|enter\s*confirm\s*·|enter\/esc\s*(?:continue|confirm)|hooks need review|do you trust|trust this|trusted workspace|press enter to (?:confirm|continue|view|insert)|press t to trust|permission required|requires permission|allow once|allow always/ -/** Matches any line that may carry a blocker; a cheap negative test before the full scan. */ -export const TERMINAL_WAIT_BLOCKED_SENTINEL_RE = new RegExp( - [BUILT_IN_SENTINEL_RE.source, ...BLOCKED_ANCHOR_LITERALS.map(escapeRegex)].join('|'), - 'i' +/** + * Matches any line that may carry a blocker; a cheap negative test before the full scan. The same + * object until the active rules change, so a caller may key cached matches on it. + */ +export const terminalWaitBlockedSentinelRe = compiledFromActiveAgentStateRules( + () => + new RegExp( + [BUILT_IN_SENTINEL_RE.source, ...blockedAnchorLiterals().map(escapeRegex)].join('|'), + 'i' + ) ) // Why bounded: answered dialogs and quoted prompt wording (agents grep this file and its specs) stay in the @@ -32,7 +39,7 @@ export function findTerminalWaitBlockedSignal(fullTail: string): BlockedTextSign const windowStart = startOfLastNonBlankLines(fullTail, LIVE_PROMPT_TAIL_LINES) const normalized = windowStart === 0 ? fullTail : fullTail.slice(windowStart) // Why: one combined negative scan avoids a dozen searches when no prompt can match. - if (!TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(normalized)) { + if (!terminalWaitBlockedSentinelRe().test(normalized)) { return null } const signal = findBlockedSignalInLiveWindow(normalized) diff --git a/src/main/runtime/terminal-tail-sentinel-index.test.ts b/src/main/runtime/terminal-tail-sentinel-index.test.ts index b4c1c888012..273feb94311 100644 --- a/src/main/runtime/terminal-tail-sentinel-index.test.ts +++ b/src/main/runtime/terminal-tail-sentinel-index.test.ts @@ -8,22 +8,22 @@ import { tailMayContainBlockedSignal } from './terminal-tail-sentinel-index' import { computeTerminalTailWaitState } from './terminal-wait-tail-state' -import { TERMINAL_WAIT_BLOCKED_SENTINEL_RE } from './agent-state-rules/blocked-text-layer' +import { terminalWaitBlockedSentinelRe } from './agent-state-rules/blocked-text-layer' import type { RetainedTailRedrawCursor } from './terminal-tail-redraw-buffer' // The definition the incremental index must reproduce: does ANY retained line (or the // partial line) match the sentinel? Written out independently of the implementation. function referenceMayContainBlockedSignal(lines: string[], partialLine: string): boolean { for (const line of lines) { - if (TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(line)) { + if (terminalWaitBlockedSentinelRe().test(line)) { return true } } - return TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(partialLine) + return terminalWaitBlockedSentinelRe().test(partialLine) } function indexedMayContainBlockedSignal(lines: string[], partialLine: string): boolean { - return tailMayContainBlockedSignal(lines) || TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(partialLine) + return tailMayContainBlockedSignal(lines) || terminalWaitBlockedSentinelRe().test(partialLine) } type TailSim = { @@ -66,7 +66,7 @@ const ESC = String.fromCharCode(27) function referenceSentinelMatches(lines: readonly string[]): number[] { const matches: number[] = [] for (let index = 0; index < lines.length; index += 1) { - if (TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(lines[index]!)) { + if (terminalWaitBlockedSentinelRe().test(lines[index]!)) { matches.push(index) } } @@ -88,7 +88,7 @@ function assertIndexedPositionsAreExact(lines: readonly string[]): void { } function countSentinelTests(run: () => void): number { - const spy = vi.spyOn(TERMINAL_WAIT_BLOCKED_SENTINEL_RE, 'test') + const spy = vi.spyOn(terminalWaitBlockedSentinelRe(), 'test') try { run() return spy.mock.calls.length diff --git a/src/main/runtime/terminal-tail-sentinel-index.ts b/src/main/runtime/terminal-tail-sentinel-index.ts index 799bf759696..df9b358a847 100644 --- a/src/main/runtime/terminal-tail-sentinel-index.ts +++ b/src/main/runtime/terminal-tail-sentinel-index.ts @@ -1,4 +1,4 @@ -import { TERMINAL_WAIT_BLOCKED_SENTINEL_RE } from './agent-state-rules/blocked-text-layer' +import { terminalWaitBlockedSentinelRe } from './agent-state-rules/blocked-text-layer' /** * Which retained tail lines match the wait-blocked sentinel, memoized per @@ -9,17 +9,22 @@ import { TERMINAL_WAIT_BLOCKED_SENTINEL_RE } from './agent-state-rules/blocked-t * per streaming PTY) even though only ~20 lines were new. Keyed weakly by the * array so an entry dies with the tail it describes; the tail array is replaced * on every append and never mutated in place, so at most one entry per PTY - * stays live. + * stays live. Each entry keeps the sentinel it was scanned with: a rules hot reload swaps it, and + * matches found by the old one would hide a blocker only the new one knows. */ -const sentinelMatchesByTailLines = new WeakMap() +const sentinelMatchesByTailLines = new WeakMap< + readonly string[], + { sentinel: RegExp; matches: number[] } +>() function collectSentinelMatches( + sentinel: RegExp, lines: readonly string[], startIndex: number, into: number[] ): void { for (let index = startIndex; index < lines.length; index += 1) { - if (TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(lines[index]!)) { + if (sentinel.test(lines[index]!)) { into.push(index) } } @@ -40,14 +45,15 @@ export function getTerminalTailSentinelFullScanCount(): number { /** Ascending indices of sentinel-matching lines; full-scans an unseen array. */ export function getTerminalTailSentinelMatches(lines: readonly string[]): readonly number[] { + const sentinel = terminalWaitBlockedSentinelRe() const cached = sentinelMatchesByTailLines.get(lines) - if (cached) { - return cached + if (cached?.sentinel === sentinel) { + return cached.matches } sentinelFullScanCount += 1 const matches: number[] = [] - collectSentinelMatches(lines, 0, matches) - sentinelMatchesByTailLines.set(lines, matches) + collectSentinelMatches(sentinel, lines, 0, matches) + sentinelMatchesByTailLines.set(lines, { sentinel, matches }) return matches } @@ -89,6 +95,7 @@ export function carryTerminalTailSentinelMatches( } } } - collectSentinelMatches(nextLines, carriedCount, matches) - sentinelMatchesByTailLines.set(nextLines, matches) + const sentinel = terminalWaitBlockedSentinelRe() + collectSentinelMatches(sentinel, nextLines, carriedCount, matches) + sentinelMatchesByTailLines.set(nextLines, { sentinel, matches }) } diff --git a/src/main/runtime/terminal-wait-detection.ts b/src/main/runtime/terminal-wait-detection.ts index fb24d21c124..a2e6ee3f073 100644 --- a/src/main/runtime/terminal-wait-detection.ts +++ b/src/main/runtime/terminal-wait-detection.ts @@ -11,6 +11,7 @@ import { } from './agent-state-rules/agent-state-rules-engine' import { findPromptAnchorIndexes } from './agent-state-rules/agent-state-text-anchors' import { showsIdleTitleAnchor } from './agent-state-rules/agent-state-title-anchors' +import { compiledFromActiveAgentStateRules } from './agent-state-rules/active-agent-state-rules' import { findTerminalWaitBlockedSignal, isSettledAfter, @@ -29,12 +30,18 @@ function computeExplicitIdleStatusFromTitle(title: string): AgentStatus | null { } /** - * Pure in `title`, so it is memoized on the title string like the status classifier it - * wraps: the wait path re-asks for the same unchanged title on every poll tick and every - * repaint frame, and the marker scan below is a regex sweep each time (~72ns vs ~7ns). + * Pure in `title` for one rule set, so it is memoized on the title string like the status + * classifier it wraps: the wait path re-asks for the same unchanged title on every poll tick and + * every repaint frame, and the marker scan below is a regex sweep each time (~72ns vs ~7ns). Why a + * fresh memo per rule set: a rules reload can change which titles read as idle. */ -export const detectExplicitIdleStatusFromTitle: (title: string) => AgentStatus | null = +const explicitIdleTitleMemo = compiledFromActiveAgentStateRules(() => memoizeTitleClassification(computeExplicitIdleStatusFromTitle) +) + +export function detectExplicitIdleStatusFromTitle(title: string): AgentStatus | null { + return explicitIdleTitleMemo()(title) +} export function isKnownReadyPromptPreview(preview: string): boolean { const normalized = preview.toLowerCase() diff --git a/src/main/runtime/terminal-wait-tail-state.ts b/src/main/runtime/terminal-wait-tail-state.ts index 485451abf0c..28bb094b8e3 100644 --- a/src/main/runtime/terminal-wait-tail-state.ts +++ b/src/main/runtime/terminal-wait-tail-state.ts @@ -2,7 +2,7 @@ import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-type import { buildTailLines } from './terminal-tail-state' import { tailMayContainBlockedSignal } from './terminal-tail-sentinel-index' import { findActionableTerminalWaitBlockedSignal } from './terminal-wait-detection' -import { TERMINAL_WAIT_BLOCKED_SENTINEL_RE } from './agent-state-rules/blocked-text-layer' +import { terminalWaitBlockedSentinelRe } from './agent-state-rules/blocked-text-layer' export function buildTerminalWaitText( lines: string[], @@ -64,7 +64,7 @@ function inspectTerminalWaitTail( // Why the index: proving a signal is ABSENT can't early-exit, so a full re-test of the // 2000-line tail ran per scan; the index tests only the lines each append produced. mayContainBlockedSignal: - tailMayContainBlockedSignal(lines) || TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(partialLine) + tailMayContainBlockedSignal(lines) || terminalWaitBlockedSentinelRe().test(partialLine) } } diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index cfd0fa29402..9da3d7212ef 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -32,6 +32,8 @@ import { AgentStatusObservedPaneIdentities, recordObservedAgentStatusPaneIdentity } from '../runtime/agent-status-observed-pane-identity' +import { startAgentStateRulesLiveUpdates } from '../runtime/agent-state-rules/agent-state-rules-live-update' +import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' export function getDesktopWindowStatus(): RuntimeDesktopWindowStatus { const activation = state.desktopActivationGate @@ -162,6 +164,10 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { getScopeCatalog: () => sessionSearchScopeCatalogFromStore(store, LOCAL_EXECUTION_HOST_ID) }) app.once('will-quit', () => sessionSearch?.dispose()) + // Why here: this runs for the desktop and headless `orca serve`, and each evaluates its own panes. + startAgentStateRulesLiveUpdates(store, (rules) => + recordDurableCrashBreadcrumb('agent_state_rules_active', rules) + ) state.runtime = runtime agentHookServer.subscribeEnrichedStatus((enriched) => recordObservedAgentStatusPaneIdentity(observedPaneIdentities, enriched.paneKey, runtime) diff --git a/src/main/updater-agent-state-rules-release.test.ts b/src/main/updater-agent-state-rules-release.test.ts new file mode 100644 index 00000000000..880f46c1087 --- /dev/null +++ b/src/main/updater-agent-state-rules-release.test.ts @@ -0,0 +1,119 @@ +// Regression: agent state rules releases live in the app's own release feed, and must never change +// what the updater resolves on any path: the stable Latest feed, the RC prerelease feed, or the +// build picker. +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getVersionChannel } from '../shared/release-channel' +import { installNetRequestFetchAdapter } from './updater-net-request.fixture' + +const { netFetchMock, netRequestMock } = vi.hoisted(() => ({ + netFetchMock: vi.fn(), + netRequestMock: vi.fn() +})) + +vi.mock('electron', () => ({ net: { fetch: netFetchMock, request: netRequestMock } })) +vi.mock('./updater-release-api-token', () => ({ + resolveReleaseApiToken: async () => null, + rejectReleaseApiToken: () => {} +})) +vi.mock('./git/gh-rate-limit-breaker', () => ({ + getGhRateLimitBlockedUntilMs: () => null, + recordGhPrimaryRateLimit: () => {} +})) + +const { fetchNewerReleaseTag } = await import('./updater-prerelease-feed') +const { listReleaseBuilds, resolveTargetBuild } = await import('./updater-release-builds') + +const RULES_TAGS = ['agent-state-rules-engine-1-next', 'agent-state-rules-engine-1-stable'] +const APP_TAGS = ['v1.4.3-rc.1', 'v1.4.2', 'v1.4.2-rc.4', 'v1.4.1'] + +function atomFeed(tags: readonly string[]): string { + const entries = tags + .map( + (tag) => + `${tag}` + ) + .join('') + return `${entries}` +} + +function serveFeed(tags: readonly string[]): void { + netFetchMock.mockImplementation((url: string) => { + if (url === 'https://github.com/stablyai/orca/releases.atom') { + return Promise.resolve({ ok: true, text: () => Promise.resolve(atomFeed(tags)) }) + } + const manifest = url.match(/\/releases\/download\/v([^/]+)\/latest(?:-[a-z]+)?\.yml$/) + if (manifest) { + const version = manifest[1] + return Promise.resolve({ + ok: true, + status: 200, + text: () => + Promise.resolve( + `version: ${version}\npath: Orca-${version}.zip\nfiles:\n - url: Orca-${version}.zip\n` + ) + }) + } + return Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve('') }) + }) +} + +function apiRelease(tag: string, assets: readonly string[]) { + return { + tag_name: tag, + draft: false, + published_at: '2026-10-01T00:00:00Z', + html_url: `https://github.com/stablyai/orca/releases/tag/${tag}`, + assets: assets.map((name) => ({ name })) + } +} + +const APP_ASSETS = [ + 'latest-mac.yml', + 'orca-macos-arm64.dmg', + 'latest.yml', + 'orca-windows-setup.exe' +] + +beforeEach(() => { + netFetchMock.mockReset() + netRequestMock.mockReset() + installNetRequestFetchAdapter(netRequestMock, netFetchMock) +}) + +describe('agent state rules releases and the app updater', () => { + it('leave the RC prerelease feed resolving to the same app tag', async () => { + serveFeed(APP_TAGS) + const withoutRules = await fetchNewerReleaseTag('1.4.2-rc.4') + serveFeed([...RULES_TAGS, ...APP_TAGS]) + expect(await fetchNewerReleaseTag('1.4.2-rc.4')).toBe(withoutRules) + expect(withoutRules).toBe('v1.4.3-rc.1') + }) + + it('leave the stable check resolving to the same app tag', async () => { + serveFeed([...RULES_TAGS, ...APP_TAGS]) + expect(await fetchNewerReleaseTag('1.4.1', { includePrerelease: false })).toBe('v1.4.2') + }) + + it.each(['stable', 'rc'] as const)('never appear in the %s build picker', async (channel) => { + netFetchMock.mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers(), + json: () => + Promise.resolve([ + ...RULES_TAGS.map((tag) => apiRelease(tag, ['agent-state-rules.json'])), + ...APP_TAGS.map((tag) => apiRelease(tag, APP_ASSETS)) + ]) + }) + const builds = await listReleaseBuilds(channel, 'darwin') + expect(builds.map((build) => build.tag).filter((tag) => RULES_TAGS.includes(tag))).toEqual([]) + expect(builds.length).toBeGreaterThan(0) + }) + + it('cannot be pinned as a build or read as any app channel', () => { + for (const tag of RULES_TAGS) { + expect(getVersionChannel(tag)).toBeNull() + expect(() => resolveTargetBuild('stable', tag)).toThrow('not a valid release tag') + } + }) +}) diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index c1a9b7d2df1..2a3fac5d545 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -420,6 +420,12 @@ export type GlobalSettings = { agentYoloDefaultsMigrated?: boolean /** Why: disabling must persist so startup doesn't reinstall global agent hook entries the user just removed. */ agentStatusHooksEnabled: boolean + /** A local agent-state-rules.json that replaces downloaded and bundled rules, for testing a rule + * change. */ + agentStateRulesPath?: string | null + /** Off: rules are never downloaded and a cached download is ignored, so the bundled rules (or a + * local override) apply. Absent reads as on. */ + agentStateRulesLiveUpdates?: boolean /** Pre-trust the worktree or folder Orca starts an agent in, so its "trust this folder?" prompt is skipped. Defaults on. */ agentWorkspaceTrustEnabled: boolean /** Why: Codex's shared server runs every tab's hooks with the first tab's env; off opts new terminals back into it. Absent reads as on. */ From 9503f9de346d39b0deef941e230d31104c25d214 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:19:19 -0400 Subject: [PATCH 04/21] fix(runtime): settle Codex tui-idle waits on its hook done, leaving working rows to the rules (#24541) A headless orca serve has no window to write the Codex ready title, so a Codex tui-idle wait settled only after three quiet seconds. Rule files gain profile.hooks: "turn-end": a fresh hook done settles the wait, while a working or permission row leaves the decision to the rules, so a Codex whose Esc posts no event (before its Interrupt hook) cannot hang the wait. Codex moves from identity-only to turn-end. --- docs/reference/agent-status-store.md | 17 +++-- .../agent-state-rules-engine.ts | 6 +- .../agent-state-rules-schema.ts | 8 ++- src/main/runtime/agent-state-rules/codex.json | 2 +- .../tui-idle-hook-lane-runtime.test.ts | 13 ++-- src/main/runtime/tui-idle-hook-lane.test.ts | 69 +++++++++++++++++-- src/main/runtime/tui-idle-hook-lane.ts | 19 +++-- 7 files changed, 100 insertions(+), 34 deletions(-) diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index e60e683c738..5dae5fafafb 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -446,8 +446,8 @@ call it. `terminal wait --for tui-idle` is a reader too. Before STA-9100 hook state reached it only through the ` ready` titles the window writes, so a headless `orca serve` never saw it (#16095). Now an agent whose rule file says -`profile.hooks: "authoritative"` (OpenCode, OpenCode 2, Pi, OMP) has its -fresh row read straight from the store, through the same +`profile.hooks: "authoritative"` (OpenCode, OpenCode 2, Pi, OMP) or +`"turn-end"` (Codex) has its fresh row read straight from the store, through the same `selectFreshExplicitAgentStatusRow` join prompt-receipt verification uses (`src/main/runtime/tui-idle-hook-lane.ts`): @@ -469,11 +469,14 @@ fresh row read straight from the store, through the same OSC 133 zones itself; - every other agent stays `identity-only`: Claude sends no event when an approval is denied or Esc stops a tool, so its row can sit at `waiting` or - `working` forever, and the rules keep deciding. Codex is identity-only too: - before its `Interrupt` hook an Esc mid-turn leaves the row `working`, and an - older TUI can hand its hooks to a newer shared app server, so no version - check tells which Codex posts it. Current Codex settles fast anyway, since - `Interrupt` drives its `Codex ready` title. + `working` forever, and the rules keep deciding; +- Codex is `turn-end`: only a `done` decides (it settles the wait), and a + `working` or permission row leaves the decision to the rules. Before its + `Interrupt` hook an Esc mid-turn can leave the row `working`, and an older + TUI can hand its hooks to a newer shared app server, so no version check + tells which Codex posts it. A `done` is a real turn end on every version, so + trusting only that one keeps the headless gain (no quiet window after the + turn) without letting a missing cancel hang the wait. The titles stay for display; remote clients read them. diff --git a/src/main/runtime/agent-state-rules/agent-state-rules-engine.ts b/src/main/runtime/agent-state-rules/agent-state-rules-engine.ts index afae652ead9..0d0e8beae68 100644 --- a/src/main/runtime/agent-state-rules/agent-state-rules-engine.ts +++ b/src/main/runtime/agent-state-rules/agent-state-rules-engine.ts @@ -133,9 +133,9 @@ export function readsTrustedScreen(agent: TuiAgent | null | undefined): boolean return compiledFileFor(agent)?.readsTrustedScreen ?? false } -/** Whether a fresh hook row for the agent's main turn decides readiness ahead of its rules. */ -export function hooksAreAuthoritative(agent: TuiAgent | null | undefined): boolean { - return compiledFileFor(agent)?.hooks === 'authoritative' +/** Which fresh hook rows for the agent's main turn decide readiness ahead of its rules. */ +export function hookAuthority(agent: TuiAgent | null | undefined): HookAuthority { + return compiledFileFor(agent)?.hooks ?? 'identity-only' } function someRule( diff --git a/src/main/runtime/agent-state-rules/agent-state-rules-schema.ts b/src/main/runtime/agent-state-rules/agent-state-rules-schema.ts index c3735d2ef9e..77faf6ad35e 100644 --- a/src/main/runtime/agent-state-rules/agent-state-rules-schema.ts +++ b/src/main/runtime/agent-state-rules/agent-state-rules-schema.ts @@ -221,10 +221,12 @@ const AnchorSchema = z /** * How far readiness may trust the agent's hooks. `authoritative`: they report every way the main * agent's turn ends (done, cancelled, an approval granted or denied), so a fresh hook row decides - * ahead of the rules. `identity-only` (the default): some end sends no event (Claude's denied - * approval and Esc mid-tool), so hooks only name the agent and the rules decide. + * ahead of the rules. `turn-end`: a hook `done` is always a real turn end, but some ends may send + * nothing (Codex before its `Interrupt` hook), so only a `done` decides and a `working` or + * permission row leaves the rules to decide. `identity-only` (the default): hooks only name the + * agent and the rules decide (Claude sends no event when an approval is denied or Esc stops a tool). */ -const HOOK_AUTHORITIES = ['authoritative', 'identity-only'] as const +const HOOK_AUTHORITIES = ['authoritative', 'turn-end', 'identity-only'] as const /** Facts about the agent that are not detection rules. */ const ProfileSchema = z diff --git a/src/main/runtime/agent-state-rules/codex.json b/src/main/runtime/agent-state-rules/codex.json index 7167cf75626..db023c72af9 100644 --- a/src/main/runtime/agent-state-rules/codex.json +++ b/src/main/runtime/agent-state-rules/codex.json @@ -1,7 +1,7 @@ { "id": "codex", "engineVersion": 1, - "profile": { "screenSource": "live" }, + "profile": { "screenSource": "live", "hooks": "turn-end" }, "anchors": [ { "id": "plan_implement_menu", diff --git a/src/main/runtime/tui-idle-hook-lane-runtime.test.ts b/src/main/runtime/tui-idle-hook-lane-runtime.test.ts index 1e18c9096a1..fd9f35e8065 100644 --- a/src/main/runtime/tui-idle-hook-lane-runtime.test.ts +++ b/src/main/runtime/tui-idle-hook-lane-runtime.test.ts @@ -176,13 +176,14 @@ describe('tui-idle hook lane through the runtime', () => { ).toBe('ready') }) - // Why: Codex before its Interrupt hook sends nothing for an Esc mid-turn, so its row can stay - // working; its title and screen rules decide instead. - it('leaves Codex to its screen rules, whatever its hook row says', async () => { + // Why: Codex before its Interrupt hook sends nothing for an Esc mid-turn, so only its done + // decides; a working row leaves its title and screen rules to decide. + it('settles Codex on its done, and leaves a working row to its screen rules', async () => { const codex = { launchAgent: 'codex' as const, data: CODEX_BUSY_SCREEN } - expect(await waitOutcome({ ...codex, rows: () => [row({ agentType: 'codex' })] })).toBe( - 'timeout' - ) + expect(await waitOutcome({ ...codex, rows: () => [row({ agentType: 'codex' })] })).toBe('ready') + expect( + await waitOutcome({ ...codex, rows: () => [row({ agentType: 'codex', state: 'working' })] }) + ).toBe('timeout') }) it("settles past a denied prompt's dialog text once the hook says the turn ended", async () => { diff --git a/src/main/runtime/tui-idle-hook-lane.test.ts b/src/main/runtime/tui-idle-hook-lane.test.ts index e9fe3a88587..f79af4e2c4d 100644 --- a/src/main/runtime/tui-idle-hook-lane.test.ts +++ b/src/main/runtime/tui-idle-hook-lane.test.ts @@ -3,7 +3,7 @@ import { AGENT_STATUS_STALE_AFTER_MS, type AgentStatusIpcPayload } from '../../shared/agent-status-types' -import { hooksAreAuthoritative } from './agent-state-rules/agent-state-rules-engine' +import { hookAuthority } from './agent-state-rules/agent-state-rules-engine' import { parseAgentStateRuleFiles } from './agent-state-rules/agent-state-rules-catalog' import { hookLeadTurnState, readTuiIdleHookTurn, type TuiIdleHookTurn } from './tui-idle-hook-lane' import { @@ -208,10 +208,12 @@ describe('readTuiIdleHookTurn', () => { describe('profile.hooks', () => { it('marks the agents whose hooks report every turn end as authoritative', () => { for (const agent of ['opencode', 'opencode2', 'pi', 'omp'] as const) { - expect(hooksAreAuthoritative(agent)).toBe(true) + expect(hookAuthority(agent)).toBe('authoritative') } - for (const agent of ['codex', 'claude', 'cursor', 'gemini', 'grok', null] as const) { - expect(hooksAreAuthoritative(agent)).toBe(false) + // Codex before its Interrupt hook posts nothing for an Esc, so only its done is trusted. + expect(hookAuthority('codex')).toBe('turn-end') + for (const agent of ['claude', 'cursor', 'gemini', 'grok', null] as const) { + expect(hookAuthority(agent)).toBe('identity-only') } }) @@ -311,12 +313,65 @@ describe('evaluateTuiIdle hook lane', () => { ).toEqual({ kind: 'pending', quietForeground: 'closed' }) }) - it.each(['claude', 'codex'] as const)('never reads hooks for identity-only %s', (agent) => { - const readHookTurn = vi.fn(() => ({ state: 'done' as const, blockedReason: null })) - expect(evaluateTuiIdle(input({ agent, readHookTurn }))).toEqual({ + it('never reads hooks for identity-only claude', () => { + const readHookTurn = vi.fn(() => DONE) + expect(evaluateTuiIdle(input({ agent: 'claude', readHookTurn }))).toEqual({ kind: 'pending', quietForeground: 'closed' }) expect(readHookTurn).not.toHaveBeenCalled() }) }) + +describe('evaluateTuiIdle turn-end hook lane (codex)', () => { + it('settles at once on a done turn, with no title and a streaming pane (headless)', () => { + expect(evaluateTuiIdle(input({ agent: 'codex', readHookTurn: () => DONE }))).toEqual({ + kind: 'ready-strong' + }) + }) + + it('still blocks on a done turn whose arbiter confirms the blocked text', () => { + expect( + evaluateTuiIdle( + input({ + agent: 'codex', + readHookTurn: () => ({ state: 'done', blockedReason: 'agent-approval-prompt' }) + }) + ) + ).toEqual({ kind: 'blocked', reason: 'agent-approval-prompt' }) + }) + + it('lets the rules settle over a working row an Esc left behind (Codex before Interrupt)', () => { + expect( + evaluateTuiIdle( + input({ + agent: 'codex', + readQuietReadyBodyEvidence: () => true, + record: record({ lastOutputAt: Date.now() - QUIESCENCE_MS }), + readHookTurn: () => WORKING + }) + ) + ).toEqual({ kind: 'ready-strong' }) + }) + + it('leaves a working or permission row to the rules, which hold a busy pane', () => { + for (const turn of [ + WORKING, + { state: 'permission', blockedReason: 'agent-approval-prompt' } as const + ]) { + expect(evaluateTuiIdle(input({ agent: 'codex', readHookTurn: () => turn }))).toEqual({ + kind: 'pending', + quietForeground: 'closed' + }) + expect( + evaluateTuiIdle( + input({ + agent: 'codex', + readTailBlockedReason: () => 'agent-trust-workspace', + readHookTurn: () => turn + }) + ) + ).toEqual({ kind: 'blocked', reason: 'agent-trust-workspace' }) + } + }) +}) diff --git a/src/main/runtime/tui-idle-hook-lane.ts b/src/main/runtime/tui-idle-hook-lane.ts index 492419afb82..e8ef4c1e724 100644 --- a/src/main/runtime/tui-idle-hook-lane.ts +++ b/src/main/runtime/tui-idle-hook-lane.ts @@ -1,7 +1,7 @@ import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types' import type { TuiAgent } from '../../shared/tui-agent' -import { hooksAreAuthoritative } from './agent-state-rules/agent-state-rules-engine' +import { hookAuthority } from './agent-state-rules/agent-state-rules-engine' import { selectFreshExplicitAgentStatusRow } from './runtime-hook-agent-row-selection' type HookTurnState = 'done' | 'working' | 'permission' @@ -89,20 +89,25 @@ export type TuiIdleHookVerdict = | { kind: 'pending'; quietForeground: 'closed' } /** - * Tier 0 of tui-idle-evidence.ts for an agent whose hooks are authoritative. Why ahead of every - * rule: its hooks report each way a turn ends, and they reach a headless host, where the - * ` ready` titles the window writes never appear (#16095). Why the arbiter judges the blocked - * text: a denied prompt's dialog lingers in the line tail after the hook says the turn moved on. - * No fresh row (startup, before the first prompt, or an unjoinable pane) leaves the other tiers. + * Tier 0 of tui-idle-evidence.ts for an agent whose hooks are trusted (`profile.hooks`). Why ahead + * of every rule: they reach a headless host, where the ` ready` titles the window writes + * never appear (#16095). Why the arbiter judges the blocked text: a denied prompt's dialog lingers + * in the line tail after the hook says the turn moved on. No fresh row (startup, before the first + * prompt, or an unjoinable pane) leaves the other tiers. */ export function evaluateHookTurn( agent: TuiAgent | null | undefined, readHookTurn: () => TuiIdleHookTurn | null ): TuiIdleHookVerdict | null { - if (!hooksAreAuthoritative(agent)) { + const authority = hookAuthority(agent) + if (authority === 'identity-only') { return null } const turn = readHookTurn() + // Why only a done for `turn-end`: an end that posts nothing leaves the row working forever. + if (authority === 'turn-end' && turn?.state !== 'done') { + return null + } if (turn?.blockedReason) { return { kind: 'blocked', reason: turn.blockedReason } } From b3f39ee178cae7e17e74a6ad9e0550eb72d0179a Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:25:25 -0400 Subject: [PATCH 05/21] fix(release): accept the build identity as a later declarator in the minified telemetry check (#24219) --- .../scripts/telemetry-bundle-constant-patterns.mjs | 3 ++- .../telemetry-bundle-constant-patterns.test.mjs | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/config/scripts/telemetry-bundle-constant-patterns.mjs b/config/scripts/telemetry-bundle-constant-patterns.mjs index 87c2ae43cf7..f0e9d53ed1c 100644 --- a/config/scripts/telemetry-bundle-constant-patterns.mjs +++ b/config/scripts/telemetry-bundle-constant-patterns.mjs @@ -2,5 +2,6 @@ // them, but the injected identity and key remain adjacent in the declaration. export const BUILD_IDENTITY_RE = /\b(?:const|let|var)\s+BUILD_IDENTITY\s*=\s*["`](rc|stable)["`]/ export const WRITE_KEY_RE = /\b(?:const|let|var)\s+WRITE_KEY\s*=\s*["`](phc_[A-Za-z0-9_-]+)["`]/ +// Why the `,` alternative: the identity can be a later declarator in a shared `var` (`var a=!0,b=\`stable\``). export const MINIFIED_TELEMETRY_RE = - /\b(?:const|let|var)\s+[$\w]+\s*=\s*["'`](rc|stable)["'`][\s\S]{0,200}?[,$]\s*[$\w]+\s*=\s*["'`](phc_[A-Za-z0-9_-]+)["'`]/ + /(?:\b(?:const|let|var)\s+|,\s*)[$\w]+\s*=\s*["'`](rc|stable)["'`][\s\S]{0,200}?[,$]\s*[$\w]+\s*=\s*["'`](phc_[A-Za-z0-9_-]+)["'`]/ diff --git a/config/scripts/telemetry-bundle-constant-patterns.test.mjs b/config/scripts/telemetry-bundle-constant-patterns.test.mjs index ca87e3ee971..57f8e0c7217 100644 --- a/config/scripts/telemetry-bundle-constant-patterns.test.mjs +++ b/config/scripts/telemetry-bundle-constant-patterns.test.mjs @@ -22,4 +22,16 @@ describe('telemetry bundle constant patterns', () => { const bundle = 'var dde=`stable`,fde=`phc_example-key_123`,pde=(dde===`stable`)' expect(bundle).toMatch(MINIFIED_TELEMETRY_RE) }) + + it('accepts the identity as a later declarator in a shared declaration', () => { + const bundle = 'var wpe=!0,Tpe=`stable`,Epe=`phc_example-key_123`,Dpe=(Tpe===`stable`)' + expect(MINIFIED_TELEMETRY_RE.exec(bundle)?.slice(1, 3)).toEqual([ + 'stable', + 'phc_example-key_123' + ]) + }) + + it('rejects a minified identity with no adjacent write key', () => { + expect('var a=!0,b=`stable`,c=null').not.toMatch(MINIFIED_TELEMETRY_RE) + }) }) From bdab91e53100e45301dedce9aad969e96f38fbc6 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:25:30 -0400 Subject: [PATCH 06/21] fix(windows): link the CLI launcher's C runtime statically so orca.exe runs without the VC++ Redistributable (#24484) --- config/scripts/build-windows-cli-launcher.mjs | 14 +++- .../build-windows-cli-launcher.test.mjs | 4 ++ config/scripts/windows-pe-imports.mjs | 54 +++++++++++++++ config/scripts/windows-pe-imports.test.mjs | 66 +++++++++++++++++++ .../windows-cli-launcher/.cargo/config.toml | 4 ++ 5 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 config/scripts/windows-pe-imports.mjs create mode 100644 config/scripts/windows-pe-imports.test.mjs create mode 100644 native/windows-cli-launcher/.cargo/config.toml diff --git a/config/scripts/build-windows-cli-launcher.mjs b/config/scripts/build-windows-cli-launcher.mjs index b93005600e9..3d9d93aaed2 100644 --- a/config/scripts/build-windows-cli-launcher.mjs +++ b/config/scripts/build-windows-cli-launcher.mjs @@ -5,6 +5,7 @@ import { createHash } from 'node:crypto' import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' +import { findDynamicVcRuntimeImports, readPeImportedDllNames } from './windows-pe-imports.mjs' export function windowsCliLauncherFingerprint(inputPaths, version) { const hash = createHash('sha256').update(version) @@ -66,6 +67,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) join(crateRoot, 'build.rs'), manifestPath, join(crateRoot, 'app.manifest'), + join(crateRoot, '.cargo', 'config.toml'), iconPath, join(repoRoot, 'config/scripts/build-windows-cli-launcher.mjs') ], @@ -117,6 +119,16 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) process.exit(result.status ?? 1) } - copyFileSync(join(targetDirectory, 'release', 'orca.exe'), outputPath) + const builtPath = join(targetDirectory, 'release', 'orca.exe') + const vcRuntimeImports = findDynamicVcRuntimeImports( + readPeImportedDllNames(readFileSync(builtPath)) + ) + if (vcRuntimeImports.length > 0) { + // Why fatal: those DLLs ship with the Visual C++ Redistributable, so the CLI would fail to start on a clean Windows install. + throw new Error( + `orca.exe imports ${vcRuntimeImports.join(', ')}; the C runtime must be linked statically (native/windows-cli-launcher/.cargo/config.toml).` + ) + } + copyFileSync(builtPath, outputPath) writeFileSync(`${outputPath}.sha256`, fingerprint) } diff --git a/config/scripts/build-windows-cli-launcher.test.mjs b/config/scripts/build-windows-cli-launcher.test.mjs index e83b57f65e7..0e06cafd22e 100644 --- a/config/scripts/build-windows-cli-launcher.test.mjs +++ b/config/scripts/build-windows-cli-launcher.test.mjs @@ -17,6 +17,7 @@ import { windowsCliLauncherFileVersion, windowsCliLauncherFingerprint } from './build-windows-cli-launcher.mjs' +import { findDynamicVcRuntimeImports, readPeImportedDllNames } from './windows-pe-imports.mjs' const itCrossHost = process.platform === 'win32' ? it.skip : it const projectRoot = resolve(import.meta.dirname, '../..') @@ -131,6 +132,9 @@ describe('Windows CLI launcher', () => { expect(info.ProductVersion).toBe(version) const binary = readFileSync(launcherPath) expect(binary.includes(Buffer.from('requestedExecutionLevel level="asInvoker"'))).toBe(true) + const imports = readPeImportedDllNames(binary) + expect(imports.map((name) => name.toLowerCase())).toContain('kernel32.dll') + expect(findDynamicVcRuntimeImports(imports)).toEqual([]) const icon = readFileSync(join(projectRoot, 'resources', 'build', 'icon.ico')) const imageSize = icon.readUInt32LE(14) const imageOffset = icon.readUInt32LE(18) diff --git a/config/scripts/windows-pe-imports.mjs b/config/scripts/windows-pe-imports.mjs new file mode 100644 index 00000000000..340bc08a2ae --- /dev/null +++ b/config/scripts/windows-pe-imports.mjs @@ -0,0 +1,54 @@ +// Why a hand-rolled reader: the release guard only needs the import table's DLL +// names, and a parser dependency would be a new supply-chain input for one check. +const IMPORT_DIRECTORY_INDEX = 1 + +export function readPeImportedDllNames(buffer) { + const peOffset = buffer.readUInt32LE(0x3c) + if (buffer.toString('latin1', peOffset, peOffset + 4) !== 'PE\0\0') { + throw new Error('Not a PE image') + } + const sectionCount = buffer.readUInt16LE(peOffset + 6) + const optionalHeaderSize = buffer.readUInt16LE(peOffset + 20) + const optionalHeader = peOffset + 24 + const isPe32Plus = buffer.readUInt16LE(optionalHeader) === 0x20b + const dataDirectories = optionalHeader + (isPe32Plus ? 112 : 96) + const importRva = buffer.readUInt32LE(dataDirectories + IMPORT_DIRECTORY_INDEX * 8) + if (importRva === 0) { + return [] + } + const sections = [] + for (let index = 0; index < sectionCount; index += 1) { + const header = optionalHeader + optionalHeaderSize + index * 40 + sections.push({ + virtualAddress: buffer.readUInt32LE(header + 12), + size: Math.max(buffer.readUInt32LE(header + 8), buffer.readUInt32LE(header + 16)), + rawOffset: buffer.readUInt32LE(header + 20) + }) + } + const fileOffset = (rva) => { + const section = sections.find( + (candidate) => + rva >= candidate.virtualAddress && rva < candidate.virtualAddress + candidate.size + ) + if (!section) { + throw new Error(`RVA 0x${rva.toString(16)} is outside every section`) + } + return rva - section.virtualAddress + section.rawOffset + } + const names = [] + for (let descriptor = fileOffset(importRva); ; descriptor += 20) { + const nameRva = buffer.readUInt32LE(descriptor + 12) + if (nameRva === 0) { + return names + } + const start = fileOffset(nameRva) + names.push(buffer.toString('latin1', start, buffer.indexOf(0, start))) + } +} + +// The Visual C++ runtime DLLs ship with the Redistributable, not with Windows. +const DYNAMIC_VC_RUNTIME_RE = /^(?:vcruntime|msvcp|msvcr)\d+(?:_\d+)?\.dll$/i + +export function findDynamicVcRuntimeImports(dllNames) { + return dllNames.filter((name) => DYNAMIC_VC_RUNTIME_RE.test(name)) +} diff --git a/config/scripts/windows-pe-imports.test.mjs b/config/scripts/windows-pe-imports.test.mjs new file mode 100644 index 00000000000..11cfbbfc78e --- /dev/null +++ b/config/scripts/windows-pe-imports.test.mjs @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { findDynamicVcRuntimeImports, readPeImportedDllNames } from './windows-pe-imports.mjs' + +// Smallest PE32+ image with one section holding an import directory for `dllNames`. +function peWithImports(dllNames) { + const peOffset = 0x40 + const optionalHeaderSize = 240 + const sectionHeader = peOffset + 24 + optionalHeaderSize + const rawOffset = 0x200 + const virtualAddress = 0x1000 + const descriptorsSize = (dllNames.length + 1) * 20 + const strings = dllNames.map((name) => Buffer.from(`${name}\0`, 'latin1')) + const sectionSize = descriptorsSize + strings.reduce((sum, item) => sum + item.length, 0) + const buffer = Buffer.alloc(rawOffset + sectionSize) + buffer.write('MZ', 0, 'latin1') + buffer.writeUInt32LE(peOffset, 0x3c) + buffer.write('PE\0\0', peOffset, 'latin1') + buffer.writeUInt16LE(0x8664, peOffset + 4) + buffer.writeUInt16LE(1, peOffset + 6) + buffer.writeUInt16LE(optionalHeaderSize, peOffset + 20) + buffer.writeUInt16LE(0x20b, peOffset + 24) + buffer.writeUInt32LE(virtualAddress, peOffset + 24 + 112 + 8) + buffer.writeUInt32LE(sectionSize, sectionHeader + 8) + buffer.writeUInt32LE(virtualAddress, sectionHeader + 12) + buffer.writeUInt32LE(sectionSize, sectionHeader + 16) + buffer.writeUInt32LE(rawOffset, sectionHeader + 20) + let stringRva = virtualAddress + descriptorsSize + strings.forEach((item, index) => { + buffer.writeUInt32LE(stringRva, rawOffset + index * 20 + 12) + item.copy(buffer, rawOffset + (stringRva - virtualAddress)) + stringRva += item.length + }) + return buffer +} + +describe('windows PE imports', () => { + it('reads every imported DLL name in order', () => { + const names = ['KERNEL32.dll', 'VCRUNTIME140.dll', 'api-ms-win-crt-runtime-l1-1-0.dll'] + expect(readPeImportedDllNames(peWithImports(names))).toEqual(names) + }) + + it('reads an image with no imports', () => { + expect(readPeImportedDllNames(peWithImports([]))).toEqual([]) + }) + + it('rejects a file that is not a PE image', () => { + const buffer = Buffer.alloc(0x80) + buffer.writeUInt32LE(0x40, 0x3c) + expect(() => readPeImportedDllNames(buffer)).toThrow('Not a PE image') + }) + + it('flags only the Visual C++ Redistributable DLLs', () => { + expect( + findDynamicVcRuntimeImports([ + 'KERNEL32.dll', + 'ntdll.dll', + 'VCRUNTIME140.dll', + 'vcruntime140_1.dll', + 'MSVCP140.dll', + 'msvcr120.dll', + 'api-ms-win-crt-heap-l1-1-0.dll', + 'ucrtbase.dll' + ]) + ).toEqual(['VCRUNTIME140.dll', 'vcruntime140_1.dll', 'MSVCP140.dll', 'msvcr120.dll']) + }) +}) diff --git a/native/windows-cli-launcher/.cargo/config.toml b/native/windows-cli-launcher/.cargo/config.toml new file mode 100644 index 00000000000..7a86328d513 --- /dev/null +++ b/native/windows-cli-launcher/.cargo/config.toml @@ -0,0 +1,4 @@ +# Why: link the C runtime statically so orca.exe runs on Windows hosts without the +# Visual C++ Redistributable (VCRUNTIME140.dll is not part of Windows). +[target.'cfg(all(windows, target_env = "msvc"))'] +rustflags = ["-C", "target-feature=+crt-static"] From 1e600a8f655a5b3acc51fef1ed299d319b1bcdad Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:25:34 -0400 Subject: [PATCH 07/21] feat(terminal): point an old terminal's Codex shared-server banner at a new terminal (STA-9051) (#24501) * feat(terminal): point an old terminal's shared-server banner at a new terminal A terminal opened before the update that added Orca's codex wrapper is still served by an older terminal daemon, so a typed codex there joins Codex's shared server. The banner now says why and offers a new terminal instead of the global Fix, which changes Codex settings and stops a server other sessions use. Detection reads the owning daemon's protocol from the router's in-memory session map, only after a pane is already found on the shared server. Refs #24217, STA-9051 * refactor(terminal): simplify the old-terminal banner after review - Open new terminal now works from Activity, which shows panes from worktrees that are not active: it activates the tab's worktree first. - Inline the legacy-daemon check in the IPC handler over the existing getLegacyDaemonAdapters instead of a new routing export. - One banner frame with the variant chosen inline; the Fix dialog is a sibling rather than a children slot. - Rename CodexSharedServerJoin to CodexSharedServerStatus. * fix(terminal): open the new terminal in the pane's own workspace, and only promise it where it helps Open new terminal now always goes through the folder-aware workspace activation (returning early when that fails) and reveals the floating panel for floating panes, so folder workspaces and panes viewed from Activity open in the right place. The old-terminal variant now shows only when the shell Codex was typed into gets Orca's codex function from this build: zsh, bash and PowerShell from protocol 37, fish from 39, cmd.exe never. The shell is the parent of the Codex process in the process table the shared-server check already reads. Co-Authored-By: Claude Opus 5.5 (1M context) * feat(terminal): explain an old terminal's shared server in Learn more Old-tab banner says Orca now gives each Codex its own server, and gains a Learn more dialog: why it matters, why this terminal still shares, Open new terminal, and a quieter way into the existing Turn off / Stop server steps. * fix(terminal): shorten the old-terminal Learn more copy to one line * fix(terminal): drop the global fix from the old-terminal dialog A new terminal already runs Codex on its own server there, so turning off sharing everywhere only changes settings outside Orca and can end other sessions. * fix(terminal): treat fish without config as a shell Orca does not wrap * fix(terminal): return focus when a Codex shared-server dialog closes Both banner dialogs are controlled with no Radix trigger, so Esc or X left focus on document.body. Capture the active surface when the banner opens a dialog and restore it on close via useModalReturnFocus; Open new terminal skips the restore so the new terminal keeps focus. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(terminal): return focus when no new terminal opens, and keep an open banner dialog when Codex ends --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../codex/codex-shared-server-pane.test.ts | 55 ++- src/main/codex/codex-shared-server-pane.ts | 62 +++- src/main/daemon/daemon-protocol-version.ts | 1 + .../daemon/daemon-provider-routing.test.ts | 9 + src/main/daemon/daemon-provider-routing.ts | 3 +- src/main/fish-xdg-data-dirs-handoff.ts | 2 +- .../ipc/pty/ipc/codex-shared-server.test.ts | 85 +++-- src/main/ipc/pty/ipc/codex-shared-server.ts | 53 ++- src/preload/api/pty-api.ts | 5 +- .../pty-bridge-stream-and-serialization.ts | 3 +- .../activity/activity-thread-actions.ts | 22 +- .../terminal-pane/CodexOldTerminalDialog.tsx | 54 +++ .../terminal-pane/CodexSharedServerBanner.tsx | 221 ++++++++---- .../TerminalPaneRuntimePortals.tsx | 4 +- .../codex-shared-server-banner.test.tsx | 333 +++++++++++++++++- src/renderer/src/i18n/locales/en.json | 5 + .../lib/floating-workspace-panel-reveal.ts | 24 ++ .../src/web/preload-api/web-terminal-api.ts | 2 +- src/shared/codex-shared-server-command.ts | 9 + 19 files changed, 788 insertions(+), 164 deletions(-) create mode 100644 src/main/daemon/daemon-provider-routing.test.ts create mode 100644 src/renderer/src/components/terminal-pane/CodexOldTerminalDialog.tsx create mode 100644 src/renderer/src/lib/floating-workspace-panel-reveal.ts diff --git a/src/main/codex/codex-shared-server-pane.test.ts b/src/main/codex/codex-shared-server-pane.test.ts index ef05bf5f006..14d76f7a425 100644 --- a/src/main/codex/codex-shared-server-pane.test.ts +++ b/src/main/codex/codex-shared-server-pane.test.ts @@ -29,9 +29,9 @@ vi.mock('../pty/shell-startup-env', () => ({ })) import { - findPaneCodexCommandLine, + findPaneCodex, + findPaneCodexOnSharedServer, isCodexPaneOnOrcaMirrorHome, - isPaneCodexOnSharedServer, resolveCodexPaneHome } from './codex-shared-server-pane' @@ -46,16 +46,17 @@ beforeEach(() => { mocks.isShellStartupEnvProbeSupported.mockReturnValue(true) }) -describe('findPaneCodexCommandLine', () => { +describe('findPaneCodex', () => { it('takes the launcher line, which carries the argv, over its native child', () => { const rows = [ row(SHELL, 1, '-bash'), row(101, SHELL, 'node /usr/lib/node_modules/@openai/codex/bin/codex.js --no-daemon'), row(102, 101, '/usr/lib/node_modules/@openai/codex/vendor/codex --no-daemon') ] - expect(findPaneCodexCommandLine(rows, SHELL)).toBe( - 'node /usr/lib/node_modules/@openai/codex/bin/codex.js --no-daemon' - ) + expect(findPaneCodex(rows, SHELL)).toEqual({ + command: 'node /usr/lib/node_modules/@openai/codex/bin/codex.js --no-daemon', + shell: 'bash' + }) }) it('ignores the shared server a Windows Codex spawns as its own child', () => { @@ -65,12 +66,29 @@ describe('findPaneCodexCommandLine', () => { row(102, 101, '"C:\\h\\codex.exe" app-server --listen unix:// --managed-daemon'), row(103, 101, '"C:\\h\\codex.exe" app-server daemon pid-update-loop') ] - expect(findPaneCodexCommandLine(rows, SHELL)).toBe('C:\\npm\\codex.exe') + expect(findPaneCodex(rows, SHELL)).toEqual({ command: 'C:\\npm\\codex.exe', shell: 'cmd' }) + }) + + it.each([ + ['a login shell', row(SHELL, 1, '-zsh'), 'zsh'], + ['a shell path', row(SHELL, 1, '/opt/homebrew/bin/fish -l'), 'fish'], + [ + 'a Windows image name over its command line', + { ...row(SHELL, 1, ''), name: 'pwsh.exe' }, + 'pwsh' + ] + ])('names the shell Codex was typed into from %s', (_label, shell, name) => { + expect(findPaneCodex([shell, row(101, SHELL, 'codex')], SHELL)?.shell).toBe(name) + }) + + it('leaves fish without config unnamed, since it never loads the codex function', () => { + const fish = row(SHELL, 1, '/opt/homebrew/bin/fish -l -N') + expect(findPaneCodex([fish, row(101, SHELL, 'codex')], SHELL)?.shell).toBeNull() }) it('ignores Codex outside this pane and non-Codex children', () => { const rows = [row(SHELL, 1, '-zsh'), row(101, SHELL, 'vim'), row(201, 1, 'codex')] - expect(findPaneCodexCommandLine(rows, SHELL)).toBeNull() + expect(findPaneCodex(rows, SHELL)).toBeNull() }) }) @@ -148,7 +166,7 @@ describe('isCodexPaneOnOrcaMirrorHome', () => { }) }) -describe('isPaneCodexOnSharedServer', () => { +describe('findPaneCodexOnSharedServer', () => { beforeEach(() => { mocks.getCodexPaneAccount.mockReturnValue({ selectionKey: 'host', @@ -161,20 +179,23 @@ describe('isPaneCodexOnSharedServer', () => { mocks.readWindowsProcessTable.mockResolvedValue(rows) }) - it('is true for a typed codex while its home has a live server', async () => { - await expect(isPaneCodexOnSharedServer('pty', SHELL)).resolves.toBe(true) + it('finds a typed codex while its home has a live server', async () => { + await expect(findPaneCodexOnSharedServer('pty', SHELL)).resolves.toEqual({ + command: 'codex', + shell: 'bash' + }) expect(mocks.probeCodexSharedServer).toHaveBeenCalledWith('/home/me/.codex') }) it.each(['absent', 'unknown'] as const)( - 'is false when the pane home server is %s', + 'finds none when the pane home server is %s', async (state) => { mocks.probeCodexSharedServer.mockResolvedValue(state) - await expect(isPaneCodexOnSharedServer('pty', SHELL)).resolves.toBe(false) + await expect(findPaneCodexOnSharedServer('pty', SHELL)).resolves.toBeNull() } ) - it('is false when Codex runs with --no-daemon, without probing', async () => { + it('finds none when Codex runs with --no-daemon, without probing', async () => { mocks.getProcessTableSnapshot.mockResolvedValue([ row(SHELL, 1, '-bash'), row(101, SHELL, 'codex --no-daemon') @@ -183,13 +204,13 @@ describe('isPaneCodexOnSharedServer', () => { row(SHELL, 1, 'cmd.exe'), row(101, SHELL, 'codex --no-daemon') ]) - await expect(isPaneCodexOnSharedServer('pty', SHELL)).resolves.toBe(false) + await expect(findPaneCodexOnSharedServer('pty', SHELL)).resolves.toBeNull() expect(mocks.probeCodexSharedServer).not.toHaveBeenCalled() }) - it('is false when the pane home cannot be named, without reading processes', async () => { + it('finds none when the pane home cannot be named, without reading processes', async () => { mocks.getCodexPaneAccount.mockReturnValue(null) - await expect(isPaneCodexOnSharedServer('pty', SHELL)).resolves.toBe(false) + await expect(findPaneCodexOnSharedServer('pty', SHELL)).resolves.toBeNull() expect(mocks.getProcessTableSnapshot).not.toHaveBeenCalled() expect(mocks.readWindowsProcessTable).not.toHaveBeenCalled() }) diff --git a/src/main/codex/codex-shared-server-pane.ts b/src/main/codex/codex-shared-server-pane.ts index 3f010995dbb..9cfd9ac18fb 100644 --- a/src/main/codex/codex-shared-server-pane.ts +++ b/src/main/codex/codex-shared-server-pane.ts @@ -1,3 +1,4 @@ +import { win32 as pathWin32 } from 'node:path' import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' import { codexCommandLineJoinsSharedServer } from '../../shared/codex-shared-server-command' import { @@ -8,23 +9,41 @@ import { import { getProcessTableSnapshot } from '../../shared/process-table-snapshot-reader' import { readWindowsProcessTable } from '../windows/windows-process-table' import { isShellStartupEnvProbeSupported } from '../pty/shell-startup-env' +import { fishArgsSkipConfig } from '../fish-xdg-data-dirs-handoff' import { getSystemCodexHomePath, resolveOrcaManagedCodexHomePath } from './codex-home-paths' import { getCodexPaneAccount } from './codex-pane-account-registry' import { probeCodexSharedServer } from './codex-shared-server-probe' -type CommandRow = ProcessIdentityRow & { command: string } +type CommandRow = ProcessIdentityRow & { command: string; name?: string } + +export type PaneCodexProcess = { + command: string + /** Lowercase name of the process that launched Codex, e.g. `zsh` or `pwsh`; null when absent. */ + shell: string | null +} + +// Why name first: Windows rows carry the image name, and their command line may be empty. +function executableName(row: CommandRow): string { + const executable = row.name || row.command.trim().split(/\s+/)[0] || '' + return pathWin32 + .basename(executable) + .replace(/^-/, '') + .replace(/\.exe$/i, '') + .toLowerCase() +} /** - * The outermost Codex under the pane's shell. Outermost because a launcher - * (`node …/codex.js`) carries the argv, and on Windows the shared server it - * starts is its own child. + * The outermost Codex under the pane's shell, and the shell it was typed into. + * Outermost because a launcher (`node …/codex.js`) carries the argv, and on + * Windows the shared server it starts is its own child. */ -export function findPaneCodexCommandLine( +export function findPaneCodex( rows: readonly CommandRow[], rootPid: number -): string | null { +): PaneCodexProcess | null { + const index = getProcessTableIndex(rows) let outermost: (CommandRow & { depth: number }) | null = null - for (const row of collectDescendantsFromIndex(getProcessTableIndex(rows), rootPid)) { + for (const row of collectDescendantsFromIndex(index, rootPid)) { if ( (!outermost || row.depth < outermost.depth) && recognizeAgentProcessFromCommandLine(row.command, { includeHeadlessOneShot: true })?.agent === @@ -33,7 +52,15 @@ export function findPaneCodexCommandLine( outermost = row } } - return outermost?.command ?? null + if (!outermost) { + return null + } + const parent = index.byPid.get(outermost.ppid) + const shell = parent ? executableName(parent) : null + // Why: fish without config never loads Orca's codex function, so it counts as unwrapped. + const unwrappedFish = + shell === 'fish' && fishArgsSkipConfig(parent?.command.trim().split(/\s+/).slice(1) ?? []) + return { command: outermost.command, shell: unwrappedFish ? null : shell } } /** @@ -70,18 +97,21 @@ export function isCodexPaneOnOrcaMirrorHome(ptyId: string): boolean { ) } -/** Whether the Codex running in this local pane is a client of Codex's shared server. */ -export async function isPaneCodexOnSharedServer(ptyId: string, rootPid: number): Promise { +/** This local pane's Codex when it is a client of Codex's shared server; otherwise null. */ +export async function findPaneCodexOnSharedServer( + ptyId: string, + rootPid: number +): Promise { const codexHome = resolveCodexPaneHome(ptyId) if (!codexHome) { - return false + return null } const rows: readonly CommandRow[] = process.platform === 'win32' ? await readWindowsProcessTable() : await getProcessTableSnapshot() - const commandLine = findPaneCodexCommandLine(rows, rootPid) - return ( - commandLine !== null && - codexCommandLineJoinsSharedServer(commandLine) && + const codex = findPaneCodex(rows, rootPid) + return codex && + codexCommandLineJoinsSharedServer(codex.command) && (await probeCodexSharedServer(codexHome)) === 'live' - ) + ? codex + : null } diff --git a/src/main/daemon/daemon-protocol-version.ts b/src/main/daemon/daemon-protocol-version.ts index 42f87382d62..7f9f911c647 100644 --- a/src/main/daemon/daemon-protocol-version.ts +++ b/src/main/daemon/daemon-protocol-version.ts @@ -1,6 +1,7 @@ // Why: daemons survive app updates, so wire behavior must be version-gated. // v39 gives plain fish panes Orca's codex function through XDG_DATA_DIRS; older owners stay attachable. export const PROTOCOL_VERSION = 39 +export const CODEX_FISH_SHELL_FUNCTION_DAEMON_PROTOCOL_VERSION = 39 // Why: older daemons reject `setColorQueryReplyColors` as an unknown request type. export const COLOR_QUERY_REPLY_COLORS_DAEMON_PROTOCOL_VERSION = 38 export const CODEX_NO_DAEMON_SHELL_LAUNCH_DAEMON_PROTOCOL_VERSION = 37 diff --git a/src/main/daemon/daemon-provider-routing.test.ts b/src/main/daemon/daemon-provider-routing.test.ts new file mode 100644 index 00000000000..721f1e52c06 --- /dev/null +++ b/src/main/daemon/daemon-provider-routing.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest' +import { getLegacyDaemonAdapters } from './daemon-provider-routing' +import { LocalPtyProvider } from '../providers/local-pty-provider' + +describe('getLegacyDaemonAdapters', () => { + it('lists none for the in-process fallback provider', () => { + expect(getLegacyDaemonAdapters(new LocalPtyProvider())).toEqual([]) + }) +}) diff --git a/src/main/daemon/daemon-provider-routing.ts b/src/main/daemon/daemon-provider-routing.ts index 0198bf2893c..4a2e648c37e 100644 --- a/src/main/daemon/daemon-provider-routing.ts +++ b/src/main/daemon/daemon-provider-routing.ts @@ -1,6 +1,7 @@ import { DegradedDaemonPtyProvider } from './degraded-daemon-pty-provider' import type { DaemonPtyAdapter } from './daemon-pty-adapter' import { DaemonPtyRouter } from './daemon-pty-router' +import type { IPtyProvider } from '../providers/types' export type DaemonProvider = DaemonPtyRouter | DaemonPtyAdapter | DegradedDaemonPtyProvider @@ -11,7 +12,7 @@ export function getCurrentDaemonAdapter(provider: DaemonProvider): DaemonPtyAdap return provider } -export function getLegacyDaemonAdapters(provider: DaemonProvider): DaemonPtyAdapter[] { +export function getLegacyDaemonAdapters(provider: IPtyProvider): DaemonPtyAdapter[] { if (provider instanceof DaemonPtyRouter || provider instanceof DegradedDaemonPtyProvider) { return [...provider.getLegacyAdapters()] } diff --git a/src/main/fish-xdg-data-dirs-handoff.ts b/src/main/fish-xdg-data-dirs-handoff.ts index bec47294044..e5be3ce6ab8 100644 --- a/src/main/fish-xdg-data-dirs-handoff.ts +++ b/src/main/fish-xdg-data-dirs-handoff.ts @@ -22,7 +22,7 @@ export function getFishVendorConfSnippetPath(wrapperRoot: string): string { // Why: -N/--no-config (also abbreviated or in a flag cluster) skips vendor_conf.d, // so the snippet could never undo the env. Over-matching only costs the codex hook. -function fishArgsSkipConfig(fishArgs: readonly string[]): boolean { +export function fishArgsSkipConfig(fishArgs: readonly string[]): boolean { return fishArgs.some( (arg) => /^-[^-]*N/.test(arg) || (arg.length > 5 && '--no-config'.startsWith(arg)) ) diff --git a/src/main/ipc/pty/ipc/codex-shared-server.test.ts b/src/main/ipc/pty/ipc/codex-shared-server.test.ts index 94715870d3c..2c457df160b 100644 --- a/src/main/ipc/pty/ipc/codex-shared-server.test.ts +++ b/src/main/ipc/pty/ipc/codex-shared-server.test.ts @@ -1,18 +1,22 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -type Handler = (event: unknown, args: { id: string }) => Promise +type Handler = (event: unknown, args: { id: string }) => Promise type Session = { id: string; rootProcessId?: number; wslDistro?: string } const mocks = vi.hoisted(() => ({ handlers: new Map(), sessions: new Array(), hasProvider: vi.fn<(id: string) => boolean>(), - isPaneCodexOnSharedServer: vi.fn<(id: string, rootPid: number) => Promise>(), + findPaneCodexOnSharedServer: + vi.fn< + (id: string, rootPid: number) => Promise<{ command: string; shell: string | null } | null> + >(), resolveCodexPaneHome: vi.fn<(id: string) => string | null>(), isOnOrcaMirror: vi.fn<(id: string) => boolean>(), disable: vi.fn<(home: string) => Promise>(), disableOnMirror: vi.fn<(home: string) => Promise>(), - stop: vi.fn<(home: string) => Promise>() + stop: vi.fn<(home: string) => Promise>(), + legacyAdapters: new Array<{ protocolVersion: number; hasPty: (id: string) => boolean }>() })) vi.mock('../../pty-host-bindings', () => ({ getPtyIpc: () => ({ @@ -21,7 +25,7 @@ vi.mock('../../pty-host-bindings', () => ({ })) vi.mock('../../../codex/codex-shared-server-pane', () => ({ isCodexPaneOnOrcaMirrorHome: mocks.isOnOrcaMirror, - isPaneCodexOnSharedServer: mocks.isPaneCodexOnSharedServer, + findPaneCodexOnSharedServer: mocks.findPaneCodexOnSharedServer, resolveCodexPaneHome: mocks.resolveCodexPaneHome })) vi.mock('../../../codex/codex-shared-server-fix', () => ({ @@ -29,6 +33,9 @@ vi.mock('../../../codex/codex-shared-server-fix', () => ({ disableCodexSharedServerAutoStartOnOrcaMirror: mocks.disableOnMirror, stopCodexSharedServer: mocks.stop })) +vi.mock('../../../daemon/daemon-provider-routing', () => ({ + getLegacyDaemonAdapters: () => mocks.legacyAdapters +})) vi.mock('../provider/registry', () => ({ hasPtyProviderForInspection: mocks.hasProvider, getProviderForPty: () => ({ listProcesses: () => Promise.resolve(mocks.sessions) }) @@ -38,13 +45,14 @@ import { toAppSshPtyId } from '../../../providers/ssh-pty-id' import { ptyOwnership } from '../provider/ownership-state' import { installPtyCodexSharedServerIpcHandler } from './codex-shared-server' +// Each channel with its answer for a local pane and its answer when it refuses one. const CHANNELS = [ - 'pty:isCodexOnSharedServer', - 'pty:disableCodexSharedServerAutoStart', - 'pty:stopCodexSharedServer' + ['pty:isCodexOnSharedServer', { joined: true, openedBeforeWrapper: false }, { joined: false }], + ['pty:disableCodexSharedServerAutoStart', true, false], + ['pty:stopCodexSharedServer', true, false] ] as const -function invoke(channel: string, id: string): Promise { +function invoke(channel: string, id: string): Promise { const handler = mocks.handlers.get(channel) if (!handler) { throw new Error(`missing ${channel}`) @@ -58,18 +66,50 @@ beforeEach(() => { ptyOwnership.clear() mocks.sessions = [{ id: 'local-1', rootProcessId: 100 }] mocks.hasProvider.mockReturnValue(true) - mocks.isPaneCodexOnSharedServer.mockResolvedValue(true) + mocks.findPaneCodexOnSharedServer.mockResolvedValue({ command: 'codex', shell: 'zsh' }) mocks.resolveCodexPaneHome.mockReturnValue('/home/me/.codex') mocks.isOnOrcaMirror.mockReturnValue(false) mocks.disable.mockResolvedValue(true) mocks.disableOnMirror.mockResolvedValue(true) mocks.stop.mockResolvedValue(true) + mocks.legacyAdapters = [] installPtyCodexSharedServerIpcHandler({ getLocalPtyProviderStartupPromise: () => undefined }) }) describe('Codex shared-server IPC', () => { - it.each(CHANNELS)('%s answers for a local pane', async (channel) => { - expect(await invoke(channel, 'local-1')).toBe(true) + it.each(CHANNELS)('%s answers for a local pane', async (channel, answer) => { + expect(await invoke(channel, 'local-1')).toEqual(answer) + }) + + const ownedBy = (protocolVersion: number, ptyId = 'local-1') => ({ + protocolVersion, + hasPty: (id: string) => id === ptyId + }) + + it.each([ + ['zsh on a v36 daemon', 'zsh', [ownedBy(36)], true], + ['zsh on a v37 daemon', 'zsh', [ownedBy(37)], false], + ['fish on a v37 daemon', 'fish', [ownedBy(37)], true], + ['fish on a v38 daemon', 'fish', [ownedBy(38)], true], + ['fish on a v39 daemon', 'fish', [ownedBy(39)], false], + ['cmd.exe, which never gets the codex function', 'cmd', [ownedBy(36)], false], + ['an unknown shell', null, [ownedBy(36)], false], + ['zsh where the older daemon does not hold this pane', 'zsh', [ownedBy(36, 'other')], false] + ])('tells whether a new terminal would fix %s', async (_label, shell, adapters, old) => { + mocks.findPaneCodexOnSharedServer.mockResolvedValue({ command: 'codex', shell }) + mocks.legacyAdapters = adapters + expect(await invoke('pty:isCodexOnSharedServer', 'local-1')).toEqual({ + joined: true, + openedBeforeWrapper: old + }) + }) + + it('reads no daemon owner for a pane that has not joined', async () => { + const hasPty = vi.fn(() => true) + mocks.legacyAdapters = [{ protocolVersion: 36, hasPty }] + mocks.findPaneCodexOnSharedServer.mockResolvedValue(null) + expect(await invoke('pty:isCodexOnSharedServer', 'local-1')).toEqual({ joined: false }) + expect(hasPty).not.toHaveBeenCalled() }) it('runs the fix against the pane home', async () => { @@ -104,17 +144,18 @@ describe('Codex shared-server IPC', () => { ['a pane no provider holds', 'local-1', () => mocks.hasProvider.mockReturnValue(false)] ] - it.each(CHANNELS.flatMap((channel) => refusals.map((refusal) => [channel, ...refusal] as const)))( - '%s refuses %s', - async (channel, _label, id, arrange) => { - arrange() - expect(await invoke(channel, id)).toBe(false) - expect(mocks.isPaneCodexOnSharedServer).not.toHaveBeenCalled() - expect(mocks.disable).not.toHaveBeenCalled() - expect(mocks.disableOnMirror).not.toHaveBeenCalled() - expect(mocks.stop).not.toHaveBeenCalled() - } - ) + it.each( + CHANNELS.flatMap(([channel, , refused]) => + refusals.map((refusal) => [channel, ...refusal, refused] as const) + ) + )('%s refuses %s', async (channel, _label, id, arrange, refused) => { + arrange() + expect(await invoke(channel, id)).toEqual(refused) + expect(mocks.findPaneCodexOnSharedServer).not.toHaveBeenCalled() + expect(mocks.disable).not.toHaveBeenCalled() + expect(mocks.disableOnMirror).not.toHaveBeenCalled() + expect(mocks.stop).not.toHaveBeenCalled() + }) it.each(CHANNELS.slice(1))('%s runs nothing when the pane has no Codex home', async (channel) => { mocks.resolveCodexPaneHome.mockReturnValue(null) diff --git a/src/main/ipc/pty/ipc/codex-shared-server.ts b/src/main/ipc/pty/ipc/codex-shared-server.ts index 33d30d314a9..57221284fa1 100644 --- a/src/main/ipc/pty/ipc/codex-shared-server.ts +++ b/src/main/ipc/pty/ipc/codex-shared-server.ts @@ -1,8 +1,8 @@ import { getPtyIpc } from '../../pty-host-bindings' import { parseAppSshPtyId } from '../../../providers/ssh-pty-id' import { + findPaneCodexOnSharedServer, isCodexPaneOnOrcaMirrorHome, - isPaneCodexOnSharedServer, resolveCodexPaneHome } from '../../../codex/codex-shared-server-pane' import { @@ -10,9 +10,25 @@ import { disableCodexSharedServerAutoStartOnOrcaMirror, stopCodexSharedServer } from '../../../codex/codex-shared-server-fix' +import { getLegacyDaemonAdapters } from '../../../daemon/daemon-provider-routing' +import { + CODEX_FISH_SHELL_FUNCTION_DAEMON_PROTOCOL_VERSION, + CODEX_NO_DAEMON_SHELL_LAUNCH_DAEMON_PROTOCOL_VERSION +} from '../../../daemon/daemon-protocol-version' +import type { CodexSharedServerStatus } from '../../../../shared/codex-shared-server-command' import { ptyOwnership } from '../provider/ownership-state' import { getProviderForPty, hasPtyProviderForInspection } from '../provider/registry' +// Why per shell: a new terminal fixes Codex only where this build's daemon gives that shell Orca's +// codex function; cmd.exe and unrecognized shells never get one. +const CODEX_SHELL_FUNCTION_PROTOCOL_BY_SHELL: ReadonlyMap = new Map([ + ['zsh', CODEX_NO_DAEMON_SHELL_LAUNCH_DAEMON_PROTOCOL_VERSION], + ['bash', CODEX_NO_DAEMON_SHELL_LAUNCH_DAEMON_PROTOCOL_VERSION], + ['powershell', CODEX_NO_DAEMON_SHELL_LAUNCH_DAEMON_PROTOCOL_VERSION], + ['pwsh', CODEX_NO_DAEMON_SHELL_LAUNCH_DAEMON_PROTOCOL_VERSION], + ['fish', CODEX_FISH_SHELL_FUNCTION_DAEMON_PROTOCOL_VERSION] +]) + type Deps = { getLocalPtyProviderStartupPromise: () => Promise | undefined } /** The pane's root pid when it is a local, non-WSL pane; otherwise null. */ @@ -37,21 +53,40 @@ async function findLocalPaneRootPid(deps: Deps, id: unknown): Promise( deps: Deps, channel: string, - run: (id: string, rootPid: number) => Promise + run: (id: string, rootPid: number) => Promise, + refused: T ): void { - getPtyIpc().handle(channel, async (_event, args: { id: string }): Promise => { + getPtyIpc().handle(channel, async (_event, args: { id: string }): Promise => { try { const rootPid = await findLocalPaneRootPid(deps, args?.id) - return rootPid === null ? false : await run(args.id, rootPid) + return rootPid === null ? refused : await run(args.id, rootPid) } catch { - return false + return refused } }) } +async function readPaneSharedServerStatus( + id: string, + rootPid: number +): Promise { + const codex = await findPaneCodexOnSharedServer(id, rootPid) + if (!codex) { + return { joined: false } + } + // Why: the pane's own daemon predates its shell's codex function, which a new terminal has. + const shellFunctionProtocol = CODEX_SHELL_FUNCTION_PROTOCOL_BY_SHELL.get(codex.shell ?? '') + const openedBeforeWrapper = + shellFunctionProtocol !== undefined && + getLegacyDaemonAdapters(getProviderForPty(id)).some( + (adapter) => adapter.hasPty(id) && adapter.protocolVersion < shellFunctionProtocol + ) + return { joined: true, openedBeforeWrapper } +} + /** Runs a fix command against the pane's own CODEX_HOME, never a guessed one. */ function runForPaneHome(fix: (codexHome: string) => Promise) { return async (id: string): Promise => { @@ -71,7 +106,7 @@ function disableForPane(id: string): Promise { // Why its own read: only a pane already showing Codex asks, so no cadence poll pays for argv. export function installPtyCodexSharedServerIpcHandler(deps: Deps): void { - handleLocalPane(deps, 'pty:isCodexOnSharedServer', isPaneCodexOnSharedServer) - handleLocalPane(deps, 'pty:disableCodexSharedServerAutoStart', disableForPane) - handleLocalPane(deps, 'pty:stopCodexSharedServer', runForPaneHome(stopCodexSharedServer)) + handleLocalPane(deps, 'pty:isCodexOnSharedServer', readPaneSharedServerStatus, { joined: false }) + handleLocalPane(deps, 'pty:disableCodexSharedServerAutoStart', disableForPane, false) + handleLocalPane(deps, 'pty:stopCodexSharedServer', runForPaneHome(stopCodexSharedServer), false) } diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index c7c46c7b004..4cfb5572776 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -18,6 +18,7 @@ import type { TerminalViewAttributes } from '../../shared/terminal-view-attribut import type { TuiAgent } from '../../shared/tui-agent' import type { PtyManagementApi } from './pty-management-api' import type { TerminalProcessInspection } from '../../shared/terminal-process-inspection' +import type { CodexSharedServerStatus } from '../../shared/codex-shared-server-command' export type PtyApi = { spawn: (opts: { @@ -126,8 +127,8 @@ export type PtyApi = { } ) => Promise confirmForegroundProcess: (id: string) => Promise - /** Local panes only; false for any other pane. */ - isCodexOnSharedServer: (id: string) => Promise + /** Local panes only; never joined for any other pane. */ + isCodexOnSharedServer: (id: string) => Promise /** Runs the fix with the pane's own Codex; true only once verified. Local panes only. */ disableCodexSharedServerAutoStart: (id: string) => Promise stopCodexSharedServer: (id: string) => Promise diff --git a/src/preload/api/pty-bridge-stream-and-serialization.ts b/src/preload/api/pty-bridge-stream-and-serialization.ts index ec5b232acb5..e0b2e7f2bf6 100644 --- a/src/preload/api/pty-bridge-stream-and-serialization.ts +++ b/src/preload/api/pty-bridge-stream-and-serialization.ts @@ -3,6 +3,7 @@ import type { PtyModelRestoreNeededEvent } from '../../shared/pty-model-restore- import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' import type { PreloadApi } from '../api-types' import type { TerminalProcessInspection } from '../../shared/terminal-process-inspection' +import type { CodexSharedServerStatus } from '../../shared/codex-shared-server-command' export const ptyStreamAndSerializationApi = { inspectProcess: ( @@ -16,7 +17,7 @@ export const ptyStreamAndSerializationApi = { ipcRenderer.invoke('pty:inspectProcess', { id, ...options }), confirmForegroundProcess: (id: string): Promise => ipcRenderer.invoke('pty:confirmForegroundProcess', { id }), - isCodexOnSharedServer: (id: string): Promise => + isCodexOnSharedServer: (id: string): Promise => ipcRenderer.invoke('pty:isCodexOnSharedServer', { id }), disableCodexSharedServerAutoStart: (id: string): Promise => ipcRenderer.invoke('pty:disableCodexSharedServerAutoStart', { id }), diff --git a/src/renderer/src/components/activity/activity-thread-actions.ts b/src/renderer/src/components/activity/activity-thread-actions.ts index c2d99c23fc2..84d9d55ea21 100644 --- a/src/renderer/src/components/activity/activity-thread-actions.ts +++ b/src/renderer/src/components/activity/activity-thread-actions.ts @@ -1,6 +1,5 @@ import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' -import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal' -import { isFloatingWorkspacePanelVisible } from '@/lib/floating-workspace-terminal-actions' +import { revealFloatingWorkspacePanel } from '@/lib/floating-workspace-panel-reveal' import { activateStructuredAgentSessionTab } from '@/lib/structured-agent-session-tab-activation' import { activateAndRevealWorkspace } from '@/lib/worktree-activation' import { jumpToWorktreeFromSidebar } from '@/lib/worktree-jump-navigation' @@ -48,25 +47,6 @@ export function hasActivityThreadWorkspace( ) } -function toggleFloatingWorkspacePanelIfHidden(): void { - if (!isFloatingWorkspacePanelVisible()) { - window.dispatchEvent(new Event(TOGGLE_FLOATING_TERMINAL_EVENT)) - } -} - -// Why enable first: floating tabs outlive a feature disable, and the panel ignores the toggle -// while disabled, so a live floating agent row would otherwise be a silent no-op. -function revealFloatingWorkspacePanel(state: AppState): void { - if (state.settings?.floatingTerminalEnabled === true) { - toggleFloatingWorkspacePanelIfHidden() - return - } - void state.updateSettings({ floatingTerminalEnabled: true }).then(() => { - // Why deferred a frame: the panel only honors the toggle once the enabled flag has reached React. - requestAnimationFrame(toggleFloatingWorkspacePanelIfHidden) - }) -} - export function createActivityThreadActions({ getMarkAllReadThreads, acknowledgeAgents, diff --git a/src/renderer/src/components/terminal-pane/CodexOldTerminalDialog.tsx b/src/renderer/src/components/terminal-pane/CodexOldTerminalDialog.tsx new file mode 100644 index 00000000000..60238ca3832 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/CodexOldTerminalDialog.tsx @@ -0,0 +1,54 @@ +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { translate } from '@/i18n/i18n' + +/** Learn more for a terminal opened before Orca gave each Codex its own server. */ +export function CodexOldTerminalDialog({ + open, + onOpenChange, + onOpenNewTerminal +}: { + open: boolean + onOpenChange: (open: boolean) => void + onOpenNewTerminal: () => void +}): React.JSX.Element { + return ( + + + + + {translate( + 'terminal.codexSharedServerBanner.oldTerminalDialogTitle', + 'Why this Codex shares a server' + )} + + + {translate( + 'terminal.codexSharedServerBanner.oldTerminalDialogRisk', + 'Codex sessions that share a server can end together, and agent status can be wrong.' + )} + + +

+ {translate( + 'terminal.codexSharedServerBanner.oldTerminalDialogCause', + 'Older terminals still share a Codex server. Open a new terminal to run Codex on a separate server.' + )} +

+ {/* Why no global fix: a new terminal already runs Codex on its own server here. */} + + + +
+
+ ) +} diff --git a/src/renderer/src/components/terminal-pane/CodexSharedServerBanner.tsx b/src/renderer/src/components/terminal-pane/CodexSharedServerBanner.tsx index 434c05b0e85..94264705465 100644 --- a/src/renderer/src/components/terminal-pane/CodexSharedServerBanner.tsx +++ b/src/renderer/src/components/terminal-pane/CodexSharedServerBanner.tsx @@ -4,6 +4,14 @@ import { Button } from '@/components/ui/button' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' import { isCodexSharedServerWarningEnabled } from '../../../../shared/codex-terminal-server-isolation' +import type { CodexSharedServerStatus } from '../../../../shared/codex-shared-server-command' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { createFloatingWorkspaceTerminalTab } from '@/lib/floating-workspace-tab-creation' +import { revealFloatingWorkspacePanel } from '@/lib/floating-workspace-panel-reveal' +import { activateAndRevealWorkspace } from '@/lib/worktree-activation' +import { useModalReturnFocus } from '@/hooks/useModalReturnFocus' +import { CodexOldTerminalDialog } from './CodexOldTerminalDialog' import { CodexSharedServerFixDialog } from './CodexSharedServerFixDialog' import { retireCodexTerminalServerIsolationNotice } from './codex-terminal-server-isolation-notice' @@ -14,22 +22,22 @@ const dismissedPtyIds = new Set() /** Asks on the ladder until an answer is yes; returns a cancel that drops any later answer. */ function askUntilOnSharedServer( - ask: (ptyId: string) => Promise, + ask: (ptyId: string) => Promise, ptyId: string, - onJoined: () => void + onJoined: (status: CodexSharedServerStatus) => void ): () => void { let cancelled = false let timer: ReturnType | undefined const schedule = (attempt: number): void => { timer = setTimeout(() => { void ask(ptyId) - .catch(() => false) - .then((joined) => { + .catch((): CodexSharedServerStatus => ({ joined: false })) + .then((status) => { if (cancelled) { return } - if (joined) { - onJoined() + if (status.joined) { + onJoined(status) } else if (attempt + 1 < CHECK_DELAYS_MS.length) { schedule(attempt + 1) } @@ -43,21 +51,47 @@ function askUntilOnSharedServer( } } -function usePaneCodexOnSharedServer(ptyId: string, enabled: boolean, recheck: number): boolean { - const [joined, setJoined] = useState(false) +function usePaneCodexSharedServerStatus( + ptyId: string, + enabled: boolean, + recheck: number +): CodexSharedServerStatus | null { + const [status, setStatus] = useState(null) useEffect(() => { if (!enabled) { return } - const cancel = askUntilOnSharedServer(window.api.pty.isCodexOnSharedServer, ptyId, () => - setJoined(true) - ) + const cancel = askUntilOnSharedServer(window.api.pty.isCodexOnSharedServer, ptyId, setStatus) return () => { cancel() - setJoined(false) + setStatus(null) } }, [enabled, ptyId, recheck]) - return joined + return status +} + +/** Opens a new terminal where this tab lives; a new terminal's shell has Orca's codex wrapper. */ +function openTerminalBesideTab(terminalTabId: string): boolean { + const state = useAppStore.getState() + const tab = Object.values(state.unifiedTabsByWorktree) + .flat() + .find( + (candidate) => candidate.contentType === 'terminal' && candidate.entityId === terminalTabId + ) + if (!tab) { + return false + } + if (tab.worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { + revealFloatingWorkspacePanel(state) + void createFloatingWorkspaceTerminalTab(state) + return true + } + // Why always: Activity can show this pane behind its own view even when its workspace is active. + if (activateAndRevealWorkspace(tab.worktreeId) === false) { + return false + } + void useAppStore.getState().openNewTerminalTabInActiveWorkspace(tab.groupId) + return true } /** Reserves the banner's height at the top of its pane so the terminal refits below it. */ @@ -85,11 +119,14 @@ function useReservePaneTopSpace(): React.RefObject { export function CodexSharedServerBanner({ ptyId, - paneKey + tabId, + leafId }: { ptyId: string - paneKey: string + tabId: string + leafId: string }): React.JSX.Element | null { + const paneKey = makePaneKey(tabId, leafId) const [dismissed, setDismissed] = useState(() => dismissedPtyIds.has(ptyId)) const warningEnabled = useAppStore( (state) => state.settings !== null && isCodexSharedServerWarningEnabled(state.settings) @@ -102,45 +139,123 @@ export function CodexSharedServerBanner({ ) // Why a recheck: after the fix stops the server, the banner hides unless a new one is joined. const [recheck, setRecheck] = useState(0) - const joined = usePaneCodexOnSharedServer( + const status = usePaneCodexSharedServerStatus( ptyId, warningEnabled && codexInPane && !dismissed, recheck ) - const [fixOpen, setFixOpen] = useState(false) - // Why fixOpen keeps it: stopping the server ends this pane's Codex, which must not close the dialog. - if (!joined && !fixOpen) { + const [openDialog, setOpenDialog] = useState<'fix' | 'oldTerminal' | null>(null) + // Why: neither dialog has a Radix trigger, so closing it would leave focus on document.body. + const { captureReturnFocus, skipReturnFocus } = useModalReturnFocus(openDialog !== null) + const showDialog = (dialog: 'fix' | 'oldTerminal'): void => { + captureReturnFocus() + setOpenDialog(dialog) + } + // Why an open dialog keeps it: this pane's Codex can end mid-dialog (Stop server does it), and + // the dialog must still close normally so focus returns. + if (!status && openDialog === null) { return null } + const { body, primaryAction } = + status?.joined && status.openedBeforeWrapper + ? { + body: ( + <> + {translate( + 'terminal.codexSharedServerBanner.openedBeforeUpdateBody', + 'This terminal was opened before Orca started giving each Codex its own server.' + )}{' '} + showDialog('oldTerminal')} /> + + ), + primaryAction: ( + + ) + } + : { + body: ( + <> + {translate( + 'terminal.codexSharedServerBanner.body', + 'Sessions may end unexpectedly, and agent status may be wrong.' + )}{' '} + showDialog('fix')} /> + + ), + primaryAction: ( + + ) + } + const closeDialog = (open: boolean): void => { + if (!open) { + setOpenDialog(null) + } + } return ( - setRecheck((count) => count + 1)} - onDismiss={() => { - dismissedPtyIds.add(ptyId) - setDismissed(true) - }} - onDontShowAgain={() => - void useAppStore.getState().updateSettings({ codexSharedServerWarning: false }) - } - /> + <> + {status ? ( + { + dismissedPtyIds.add(ptyId) + setDismissed(true) + }} + onDontShowAgain={() => + void useAppStore.getState().updateSettings({ codexSharedServerWarning: false }) + } + /> + ) : null} + { + // Why: a new terminal takes focus; only when none opens does focus return to this pane. + if (openTerminalBesideTab(tabId)) { + skipReturnFocus() + } + setOpenDialog(null) + }} + /> + setRecheck((count) => count + 1)} + /> + ) } -function CodexSharedServerBannerContent({ - ptyId, - fixOpen, - onFixOpenChange, - onServerStopped, +function LearnMoreLink({ onClick }: { onClick: () => void }): React.JSX.Element { + return ( + + ) +} + +function CodexSharedServerBannerFrame({ + body, + primaryAction, onDismiss, onDontShowAgain }: { - ptyId: string - fixOpen: boolean - onFixOpenChange: (open: boolean) => void - onServerStopped: () => void + body: React.ReactNode + primaryAction: React.ReactNode onDismiss: () => void onDontShowAgain: () => void }): React.JSX.Element { @@ -169,25 +284,11 @@ function CodexSharedServerBannerContent({ 'This Codex is sharing a server with your other Codex tabs' )}

-

- {translate( - 'terminal.codexSharedServerBanner.body', - 'Sessions may end unexpectedly, and agent status may be wrong.' - )}{' '} - -

+

{body}

- + {primaryAction} @@ -196,12 +297,6 @@ function CodexSharedServerBannerContent({
- ) } diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneRuntimePortals.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneRuntimePortals.tsx index ff587ab8b95..0d6d2d53056 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneRuntimePortals.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneRuntimePortals.tsx @@ -1,7 +1,6 @@ import { createPortal } from 'react-dom' import CodexRestartChip from '../CodexRestartChip' import { CodexSharedServerBanner } from './CodexSharedServerBanner' -import { makePaneKey } from '../../../../shared/stable-pane-id' import { TerminalSshReconnectOverlay } from './TerminalSshReconnectOverlay' import { TerminalRemoteRuntimeReconnectBanner } from './TerminalRemoteRuntimeReconnectBanner' import { TerminalProcessExitOverlay } from './TerminalProcessExitOverlay' @@ -40,7 +39,8 @@ export function TerminalPaneCodexRestartPortals({ , pane.container, diff --git a/src/renderer/src/components/terminal-pane/codex-shared-server-banner.test.tsx b/src/renderer/src/components/terminal-pane/codex-shared-server-banner.test.tsx index 2f81ae371e0..1855062a1df 100644 --- a/src/renderer/src/components/terminal-pane/codex-shared-server-banner.test.tsx +++ b/src/renderer/src/components/terminal-pane/codex-shared-server-banner.test.tsx @@ -6,20 +6,47 @@ import { toast } from 'sonner' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' import type { GlobalSettings } from '../../../../shared/global-settings-types' -import { getDefaultSettings } from '../../../../shared/constants' +import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultSettings } from '../../../../shared/constants' import { CodexSharedServerBanner } from './CodexSharedServerBanner' import { CODEX_DISABLE_AUTO_START_COMMAND, - CODEX_STOP_SHARED_SERVER_COMMAND + CODEX_STOP_SHARED_SERVER_COMMAND, + type CodexSharedServerStatus } from '../../../../shared/codex-shared-server-command' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import type { Tab } from '../../../../shared/tab-types' + +const routing = vi.hoisted(() => ({ + activateAndRevealWorkspace: vi.fn<(id: string) => unknown>(), + revealFloatingWorkspacePanel: vi.fn(), + createFloatingWorkspaceTerminalTab: vi.fn() +})) +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorkspace: routing.activateAndRevealWorkspace +})) +vi.mock('@/lib/floating-workspace-panel-reveal', () => ({ + revealFloatingWorkspacePanel: routing.revealFloatingWorkspacePanel +})) +vi.mock('@/lib/floating-workspace-tab-creation', () => ({ + createFloatingWorkspaceTerminalTab: routing.createFloatingWorkspaceTerminalTab +})) globalThis.IS_REACT_ACT_ENVIRONMENT = true -const PANE_KEY = 'tab-1:leaf-1' +const TAB_ID = 'tab-1' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID) +const JOINED: CodexSharedServerStatus = { joined: true, openedBeforeWrapper: false } +const NOT_JOINED: CodexSharedServerStatus = { joined: false } +const OLD_TAB_JOINED: CodexSharedServerStatus = { joined: true, openedBeforeWrapper: true } const TITLE = 'This Codex is sharing a server' +const OLD_TAB_BODY = + 'This terminal was opened before Orca started giving each Codex its own server.' let paneElement: HTMLDivElement let root: Root -let isCodexOnSharedServer: ReturnType Promise>> +let isCodexOnSharedServer: ReturnType< + typeof vi.fn<(id: string) => Promise> +> let disableCodexSharedServerAutoStart: ReturnType Promise>> let stopCodexSharedServer: ReturnType Promise>> let writeClipboardText: ReturnType Promise>> @@ -42,7 +69,7 @@ function setState(settings: Partial, agent: 'codex' | null = 'co async function renderBanner(): Promise { await act(async () => { - root.render() + root.render() }) } @@ -66,6 +93,8 @@ function button(label: string): HTMLButtonElement { beforeEach(() => { vi.useFakeTimers() + vi.clearAllMocks() + routing.activateAndRevealWorkspace.mockReturnValue({ primaryTabId: null }) vi.stubGlobal('ResizeObserver', ResizeObserverStub) useAppStore.setState(useAppStore.getInitialState(), true) ptyId = `pty-${(nextPtyId += 1)}` @@ -73,7 +102,7 @@ beforeEach(() => { paneElement.className = 'pane' document.body.appendChild(paneElement) root = createRoot(paneElement) - isCodexOnSharedServer = vi.fn(() => Promise.resolve(true)) + isCodexOnSharedServer = vi.fn(() => Promise.resolve(JOINED)) disableCodexSharedServerAutoStart = vi.fn(() => Promise.resolve(true)) stopCodexSharedServer = vi.fn(() => Promise.resolve(true)) writeClipboardText = vi.fn(() => Promise.resolve()) @@ -113,6 +142,19 @@ describe('CodexSharedServerBanner', () => { expect(paneElement.style.getPropertyValue('--orca-pane-top-banner-height')).toMatch(/px$/) }) + it('keeps the default Learn more on the Fix steps, with no old-terminal copy', async () => { + setState({}) + await renderBanner() + await advance(1_000) + expect(paneElement.textContent).not.toContain(OLD_TAB_BODY) + expect(() => button('Open new terminal')).toThrow() + button('Fix') + + await act(async () => button('Learn more').click()) + expect(document.body.textContent).toContain('Give each Codex tab its own server') + expect(document.body.textContent).not.toContain('Turn off sharing everywhere') + }) + it("retires the one-time 'runs Codex without its shared server' toast, which it contradicts", async () => { setState({}) useAppStore.setState({ codexTerminalServerIsolationNoticeSeen: false }) @@ -127,7 +169,7 @@ describe('CodexSharedServerBanner', () => { it('keeps asking while Codex starts, then stops once it has an answer', async () => { setState({}) - isCodexOnSharedServer.mockResolvedValueOnce(false) + isCodexOnSharedServer.mockResolvedValueOnce(NOT_JOINED) await renderBanner() await advance(1_000) expect(paneElement.textContent).toBe('') @@ -183,7 +225,7 @@ describe('CodexSharedServerBanner', () => { const confirm = Array.from(document.querySelectorAll('button')).filter( (candidate) => candidate.textContent?.trim() === 'Stop server' ) - isCodexOnSharedServer.mockResolvedValue(false) + isCodexOnSharedServer.mockResolvedValue(NOT_JOINED) await act(async () => confirm.at(-1)?.click()) expect(stopCodexSharedServer).toHaveBeenCalledWith(ptyId) expect(document.body.textContent).toContain('Stopped') @@ -252,4 +294,279 @@ describe('CodexSharedServerBanner', () => { await act(async () => setState({}, null)) expect(paneElement.textContent).toBe('') }) + + describe('in a terminal opened before the codex wrapper', () => { + function terminalTab(worktreeId: string, groupId: string): Tab { + return { + id: 'unified-1', + entityId: TAB_ID, + groupId, + worktreeId, + contentType: 'terminal', + label: 'Terminal', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + } + + it('says why this terminal shares the server and offers a new one instead of Fix', async () => { + setState({}) + isCodexOnSharedServer.mockResolvedValue(OLD_TAB_JOINED) + await renderBanner() + await advance(1_000) + + expect(paneElement.textContent).toContain(TITLE) + expect(paneElement.textContent).toContain(OLD_TAB_BODY) + expect(paneElement.textContent).not.toContain('agent status may be wrong') + expect(() => button('Fix')).toThrow() + button('Open new terminal') + button("Don't show again") + button('Dismiss') + }) + + async function renderOldTab(worktreeId: string): Promise<() => Promise> { + const openNewTerminalTabInActiveWorkspace = vi.fn(() => Promise.resolve()) + setState({}) + useAppStore.setState({ + activeWorktreeId: 'wt-1', + activeView: 'activity', + unifiedTabsByWorktree: { [worktreeId]: [terminalTab(worktreeId, 'group-2')] }, + openNewTerminalTabInActiveWorkspace + }) + isCodexOnSharedServer.mockResolvedValue(OLD_TAB_JOINED) + await renderBanner() + await advance(1_000) + return openNewTerminalTabInActiveWorkspace + } + + function dialog(): Element | null { + return document.querySelector('[role="dialog"]') + } + + it('explains the old terminal in Learn more, with no global fix', async () => { + await renderOldTab('wt-1') + expect(dialog()).toBeNull() + await act(async () => button('Learn more').click()) + + expect(dialog()?.textContent).toContain('Why this Codex shares a server') + expect(dialog()?.textContent).toContain( + 'Open a new terminal to run Codex on a separate server.' + ) + expect(dialog()?.textContent).not.toContain('Turn off sharing everywhere') + expect(document.body.textContent).not.toContain(CODEX_DISABLE_AUTO_START_COMMAND) + expect(document.body.textContent).not.toContain(CODEX_STOP_SHARED_SERVER_COMMAND) + }) + + it('opens a new terminal from Learn more and closes the dialog', async () => { + const openNewTerminal = await renderOldTab('wt-1') + await act(async () => button('Learn more').click()) + const inDialog = Array.from(dialog()?.querySelectorAll('button') ?? []).find( + (candidate) => candidate.textContent?.trim() === 'Open new terminal' + ) + await act(async () => inDialog?.click()) + + expect(openNewTerminal).toHaveBeenCalledWith('group-2') + expect(dialog()).toBeNull() + expect(paneElement.textContent).toContain(OLD_TAB_BODY) + }) + + async function clickOpenNewTerminal(worktreeId: string): Promise<() => Promise> { + const openNewTerminalTabInActiveWorkspace = await renderOldTab(worktreeId) + await act(async () => button('Open new terminal').click()) + expect(disableCodexSharedServerAutoStart).not.toHaveBeenCalled() + expect(stopCodexSharedServer).not.toHaveBeenCalled() + return openNewTerminalTabInActiveWorkspace + } + + it.each([ + ['an active worktree viewed from Activity', 'wt-1'], + ['another worktree', 'wt-other'], + ['a folder workspace', 'folder:notes'] + ])('activates %s, then opens a terminal in its group', async (_label, worktreeId) => { + const openNewTerminal = await clickOpenNewTerminal(worktreeId) + expect(routing.activateAndRevealWorkspace).toHaveBeenCalledWith(worktreeId) + expect(openNewTerminal).toHaveBeenCalledWith('group-2') + }) + + it('opens nothing when its workspace cannot be activated', async () => { + routing.activateAndRevealWorkspace.mockReturnValue(false) + const openNewTerminal = await clickOpenNewTerminal('folder:unmounted') + expect(openNewTerminal).not.toHaveBeenCalled() + }) + + it('reveals the floating panel and opens a floating terminal', async () => { + const openNewTerminal = await clickOpenNewTerminal(FLOATING_TERMINAL_WORKTREE_ID) + expect(routing.revealFloatingWorkspacePanel).toHaveBeenCalledTimes(1) + expect(routing.createFloatingWorkspaceTerminalTab).toHaveBeenCalledTimes(1) + expect(routing.activateAndRevealWorkspace).not.toHaveBeenCalled() + expect(openNewTerminal).not.toHaveBeenCalled() + }) + }) + + describe('returning focus when a dialog closes', () => { + // Why a real xterm textarea: the dialogs have no trigger, so only the hook can restore focus. + function mountActiveTerminal(): HTMLTextAreaElement { + useAppStore.setState({ + activeWorktreeId: 'wt-1', + activeTabType: 'terminal', + activeTabIdByWorktree: { 'wt-1': TAB_ID }, + terminalLayoutsByTabId: { + [TAB_ID]: { root: null, activeLeafId: LEAF_ID, expandedLeafId: null } + } + }) + const tab = document.createElement('div') + tab.dataset.terminalTabId = TAB_ID + const leaf = document.createElement('div') + leaf.dataset.leafId = LEAF_ID + const xterm = document.createElement('textarea') + xterm.className = 'xterm-helper-textarea' + leaf.append(xterm) + tab.append(leaf) + document.body.append(tab) + xterm.focus() + return xterm + } + + async function pressEscape(): Promise { + await act(async () => { + ;(document.activeElement ?? document.body).dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }) + ) + }) + await advance(100) + } + + afterEach(() => { + document.querySelector('[data-terminal-tab-id]')?.remove() + }) + + it.each([ + ['Fix', JOINED, 'Fix'], + ['old-terminal Learn more', OLD_TAB_JOINED, 'Learn more'] + ])( + 'returns focus to the terminal after Esc closes the %s dialog', + async (_l, status, label) => { + setState({}) + isCodexOnSharedServer.mockResolvedValue(status) + const xterm = mountActiveTerminal() + await renderBanner() + await advance(1_000) + await act(async () => button(label).click()) + expect(document.querySelector('[role="dialog"]')).not.toBeNull() + expect(document.activeElement).not.toBe(xterm) + + await pressEscape() + + expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(document.activeElement).toBe(xterm) + } + ) + + it('leaves focus in the new terminal after Open new terminal', async () => { + setState({}) + const xterm = mountActiveTerminal() + const newTerminal = document.createElement('textarea') + document.body.append(newTerminal) + useAppStore.setState({ + unifiedTabsByWorktree: { + 'wt-1': [ + { + id: 'unified-1', + entityId: TAB_ID, + groupId: 'group-1', + worktreeId: 'wt-1', + contentType: 'terminal', + label: 'Terminal', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + ] + }, + openNewTerminalTabInActiveWorkspace: vi.fn(() => { + newTerminal.focus() + return Promise.resolve() + }) + }) + isCodexOnSharedServer.mockResolvedValue(OLD_TAB_JOINED) + await renderBanner() + await advance(1_000) + await act(async () => button('Learn more').click()) + await clickDialogOpenNewTerminal() + + expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(document.activeElement).toBe(newTerminal) + expect(document.activeElement).not.toBe(xterm) + newTerminal.remove() + }) + + async function clickDialogOpenNewTerminal(): Promise { + const inDialog = Array.from( + document.querySelector('[role="dialog"]')?.querySelectorAll('button') ?? [] + ).find((candidate) => candidate.textContent?.trim() === 'Open new terminal') + await act(async () => inDialog?.click()) + await advance(100) + } + + it('returns focus to this terminal when Open new terminal cannot open one', async () => { + setState({}) + useAppStore.setState({ unifiedTabsByWorktree: {} }) + const xterm = mountActiveTerminal() + isCodexOnSharedServer.mockResolvedValue(OLD_TAB_JOINED) + await renderBanner() + await advance(1_000) + await act(async () => button('Learn more').click()) + + await clickDialogOpenNewTerminal() + + expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(document.activeElement).toBe(xterm) + }) + + it('keeps the Learn more dialog open when this Codex ends, then returns focus', async () => { + setState({}) + const xterm = mountActiveTerminal() + isCodexOnSharedServer.mockResolvedValue(OLD_TAB_JOINED) + await renderBanner() + await advance(1_000) + await act(async () => button('Learn more').click()) + + setState({}, null) + await advance(100) + expect(document.querySelector('[role="dialog"]')).not.toBeNull() + expect(paneElement.textContent).toBe('') + + await pressEscape() + + expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(document.activeElement).toBe(xterm) + }) + + it('returns focus to the pane terminal after Stop server ends its Codex', async () => { + setState({}) + const xterm = mountActiveTerminal() + await renderBanner() + await advance(1_000) + await act(async () => button('Fix').click()) + await act(async () => button('Turn off').click()) + await act(async () => button('Stop server').click()) + isCodexOnSharedServer.mockResolvedValue(NOT_JOINED) + const confirm = Array.from(document.querySelectorAll('button')).filter( + (candidate) => candidate.textContent?.trim() === 'Stop server' + ) + await act(async () => confirm.at(-1)?.click()) + await advance(20_000) + expect(stopCodexSharedServer).toHaveBeenCalledWith(ptyId) + expect(document.body.textContent).toContain('Stopped') + + await pressEscape() + + expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(paneElement.textContent).toBe('') + expect(document.activeElement).toBe(xterm) + }) + }) }) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2f29e560803..523e7d7dbe2 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18657,7 +18657,12 @@ "codexSharedServerBanner": { "title": "This Codex is sharing a server with your other Codex tabs", "body": "Sessions may end unexpectedly, and agent status may be wrong.", + "openedBeforeUpdateBody": "This terminal was opened before Orca started giving each Codex its own server.", + "oldTerminalDialogTitle": "Why this Codex shares a server", + "oldTerminalDialogRisk": "Codex sessions that share a server can end together, and agent status can be wrong.", + "oldTerminalDialogCause": "Older terminals still share a Codex server. Open a new terminal to run Codex on a separate server.", "fix": "Fix", + "openNewTerminal": "Open new terminal", "dontShowAgain": "Don't show again", "dismiss": "Dismiss", "dialogTitle": "Give each Codex tab its own server", diff --git a/src/renderer/src/lib/floating-workspace-panel-reveal.ts b/src/renderer/src/lib/floating-workspace-panel-reveal.ts new file mode 100644 index 00000000000..af3a6e82314 --- /dev/null +++ b/src/renderer/src/lib/floating-workspace-panel-reveal.ts @@ -0,0 +1,24 @@ +import type { AppState } from '@/store/types' +import { TOGGLE_FLOATING_TERMINAL_EVENT } from './floating-terminal' +import { isFloatingWorkspacePanelVisible } from './floating-workspace-terminal-actions' + +function toggleFloatingWorkspacePanelIfHidden(): void { + if (!isFloatingWorkspacePanelVisible()) { + window.dispatchEvent(new Event(TOGGLE_FLOATING_TERMINAL_EVENT)) + } +} + +// Why enable first: floating tabs outlive a feature disable, and the panel ignores the toggle +// while disabled, so a live floating agent row would otherwise be a silent no-op. +export function revealFloatingWorkspacePanel( + state: Pick +): void { + if (state.settings?.floatingTerminalEnabled === true) { + toggleFloatingWorkspacePanelIfHidden() + return + } + void state.updateSettings({ floatingTerminalEnabled: true }).then(() => { + // Why deferred a frame: the panel only honors the toggle once the enabled flag has reached React. + requestAnimationFrame(toggleFloatingWorkspacePanelIfHidden) + }) +} diff --git a/src/renderer/src/web/preload-api/web-terminal-api.ts b/src/renderer/src/web/preload-api/web-terminal-api.ts index c4bf707ea3c..55c0ec364eb 100644 --- a/src/renderer/src/web/preload-api/web-terminal-api.ts +++ b/src/renderer/src/web/preload-api/web-terminal-api.ts @@ -41,7 +41,7 @@ export function createPtyApi(): NonNullable['pty']> { // Why: paired web panes cannot provide a local post-boundary process scan. confirmForegroundProcess: () => Promise.resolve(null), // Why: a paired client's terminals belong to the host, whose Codex settings this client does not own. - isCodexOnSharedServer: () => Promise.resolve(false), + isCodexOnSharedServer: () => Promise.resolve({ joined: false }), disableCodexSharedServerAutoStart: () => Promise.resolve(false), stopCodexSharedServer: () => Promise.resolve(false), getCwd: () => Promise.resolve('~'), diff --git a/src/shared/codex-shared-server-command.ts b/src/shared/codex-shared-server-command.ts index 74c242b4e3b..51343d3d75a 100644 --- a/src/shared/codex-shared-server-command.ts +++ b/src/shared/codex-shared-server-command.ts @@ -120,3 +120,12 @@ export const CODEX_STOP_SHARED_SERVER_ARGS = ['app-server', 'daemon', 'stop'] as /** What the fix dialog shows the user it runs. */ export const CODEX_DISABLE_AUTO_START_COMMAND = `codex ${CODEX_DISABLE_SHARED_SERVER_ARGS.join(' ')}` export const CODEX_STOP_SHARED_SERVER_COMMAND = `codex ${CODEX_STOP_SHARED_SERVER_ARGS.join(' ')}` + +/** Whether a local pane's Codex is a client of Codex's shared server. */ +export type CodexSharedServerStatus = + | { joined: false } + | { + joined: true + /** The pane's shell lacks the codex function a new terminal would give it. */ + openedBeforeWrapper: boolean + } From 0f9154015e2bd2372f8fbdbfb83cc95ccf65e513 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:36:13 -0700 Subject: [PATCH 08/21] fix(filesystem): deleting a workspace no longer fails as 'outside allowed directories' in WSL-runtime C:\ projects (#24242) * fix(filesystem): list a WSL-runtime repo's worktrees through its distro when authorizing paths * fix(filesystem): record the Git that listed each repo's roots and keep WSL roots under repair - Registration now takes the distro the caller listed through (create and catalog scan pass theirs) instead of re-resolving the runtime, so a stale-routed listing is relisted on the next miss. - A runtime awaiting repair no longer counts as a routing change, so its last WSL listing stays authorized instead of being replaced by a host-Git listing. - The routing check runs after each awaited refresh, so a runtime switch during an in-flight rebuild is corrected in the same request. - Reuse the shared distro helper for listing; move the drift check into the relist policy and refresh the root set once per batch. * test(worktrees): fail the host-Git scan registration test when nothing registers --- ...lesystem-source-control-ai-targets.test.ts | 57 +++++ .../filesystem-source-control-ai-targets.ts | 11 +- .../ipc/registered-worktree-root-owner.ts | 23 ++ .../ipc/registered-worktree-root-probes.ts | 21 +- .../registered-worktree-root-relist-policy.ts | 29 +++ ...gistered-worktree-root-wsl-runtime.test.ts | 219 ++++++++++++++++++ .../ipc/registered-worktree-roots-cache.ts | 73 +++--- src/main/ipc/worktree-remote.ts | 10 +- .../listing/detected-worktree-scan-cache.ts | 19 +- ...ected-worktree-scan-listing-distro.test.ts | 74 ++++++ 10 files changed, 486 insertions(+), 50 deletions(-) create mode 100644 src/main/ipc/filesystem/filesystem-source-control-ai-targets.test.ts create mode 100644 src/main/ipc/registered-worktree-root-wsl-runtime.test.ts create mode 100644 src/main/ipc/worktrees/listing/detected-worktree-scan-listing-distro.test.ts diff --git a/src/main/ipc/filesystem/filesystem-source-control-ai-targets.test.ts b/src/main/ipc/filesystem/filesystem-source-control-ai-targets.test.ts new file mode 100644 index 00000000000..f0fad64322f --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-source-control-ai-targets.test.ts @@ -0,0 +1,57 @@ +import { resolve } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Store } from '../../persistence' +import type { Repo } from '../../../shared/repo-types' + +const mocks = vi.hoisted(() => ({ graph: vi.fn(), gitOptions: vi.fn() })) +vi.mock('../../repo-worktrees', () => ({ listRepoWorktreeGraph: mocks.graph })) +vi.mock('../../project-runtime-git-options', () => ({ + getLocalProjectWorktreeGitOptions: mocks.gitOptions +})) +vi.mock('../registered-worktree-roots-cache', () => ({ + resolveRegisteredWorktreePath: async (path: string) => path +})) +vi.mock('../filesystem-auth', () => ({ resolveAuthorizedPath: vi.fn() })) +vi.mock('../../providers/ssh-git-dispatch', () => ({ getSshGitProvider: vi.fn() })) +import { getRepoForSourceControlAi } from './filesystem-source-control-ai-targets' + +const repo: Repo = { + id: 'wsl-drive-repo', + path: resolve('/wsl-drive/repo'), + displayName: 'repo', + badgeColor: '#000', + addedAt: 0 +} +const worktree = resolve('/wsl-drive/workspaces/feature-extra') + +function fixture(): Store { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the ownership check reads only these store methods. + return { getRepo: () => repo, getAllWorktreeMeta: () => ({}) } as unknown as Store +} + +beforeEach(() => { + vi.resetAllMocks() + mocks.gitOptions.mockReturnValue({ wslDistro: 'Ubuntu-24.04' }) + // Only WSL Git names the checkout the way the renderer does; host Git reads its `/mnt/c` metadata. + mocks.graph.mockImplementation(async (_repo: Repo, options?: { wslDistro?: string }) => [ + { path: options?.wslDistro ? worktree : resolve('/mnt/c/wsl-drive/workspaces/feature-extra') } + ]) +}) + +describe('source control AI repo ownership for a detected worktree', () => { + it('lists through the project runtime, so a WSL-runtime drive worktree keeps its repo', async () => { + await expect( + getRepoForSourceControlAi(fixture(), { repoId: repo.id, worktreePath: worktree }) + ).resolves.toBe(repo) + expect(mocks.graph).toHaveBeenCalledWith(repo, { wslDistro: 'Ubuntu-24.04' }) + }) + + it('does not claim a worktree the repo listing does not name', async () => { + await expect( + getRepoForSourceControlAi(fixture(), { + repoId: repo.id, + worktreePath: resolve('/wsl-drive/elsewhere') + }) + ).resolves.toBeNull() + }) +}) diff --git a/src/main/ipc/filesystem/filesystem-source-control-ai-targets.ts b/src/main/ipc/filesystem/filesystem-source-control-ai-targets.ts index 2f9e5e92c09..eaa3f245dd9 100644 --- a/src/main/ipc/filesystem/filesystem-source-control-ai-targets.ts +++ b/src/main/ipc/filesystem/filesystem-source-control-ai-targets.ts @@ -1,6 +1,9 @@ import type { Repo } from '../../../shared/repo-types' import type { Store } from '../../persistence' -import type { LocalProjectWorktreeGitOptions } from '../../project-runtime-git-options' +import { + getLocalProjectWorktreeGitOptions, + type LocalProjectWorktreeGitOptions +} from '../../project-runtime-git-options' import type { CommitMessageAgentRuntimeTarget } from '../../text-generation/commit-message-agent-environment' import type { CommitMessageGenerationTarget } from '../../text-generation/commit-message-text-generation' import { resolve } from 'node:path' @@ -77,7 +80,11 @@ async function localRepoOwnsWorktree( return true } try { - const worktrees = await listRepoWorktreeGraph(repo) + // Why the project runtime: WSL Git names a drive worktree differently than host Git does. + const worktrees = await listRepoWorktreeGraph( + repo, + getLocalProjectWorktreeGitOptions(store, repo) + ) return worktrees.some((worktree) => candidatePaths.has(comparableLocalPath(worktree.path))) } catch { return false diff --git a/src/main/ipc/registered-worktree-root-owner.ts b/src/main/ipc/registered-worktree-root-owner.ts index 37b1ed25e38..d1b45caf2f6 100644 --- a/src/main/ipc/registered-worktree-root-owner.ts +++ b/src/main/ipc/registered-worktree-root-owner.ts @@ -3,6 +3,29 @@ import { getRepoExecutionHostId } from '../../shared/execution-host' import type { Repo } from '../../shared/repo-types' import { hasRemoteFilesystemOwner } from './remote-filesystem-owner' +export type RegisteredOwner = { + repoId: string + listed: Set | null + /** The WSL distro whose Git produced `listed`; undefined is host Git. */ + listedWslDistro: string | undefined + recovered: Set + aliases: Set + revision: number + dirty: boolean +} + +export function createRegisteredOwner(repoId: string, revision: number): RegisteredOwner { + return { + repoId, + listed: null, + listedWslDistro: undefined, + recovered: new Set(), + aliases: new Set(), + revision, + dirty: true + } +} + export function getWorktreeRootOwnerKey(repo: Repo): string { return JSON.stringify([repo.id, resolve(repo.path), getRepoExecutionHostId(repo)]) } diff --git a/src/main/ipc/registered-worktree-root-probes.ts b/src/main/ipc/registered-worktree-root-probes.ts index ca295f02dd1..a1ba05441b9 100644 --- a/src/main/ipc/registered-worktree-root-probes.ts +++ b/src/main/ipc/registered-worktree-root-probes.ts @@ -3,16 +3,27 @@ import { resolve } from 'node:path' import { withTimeout } from '../../shared/promise-timeout-fallback' import type { Repo } from '../../shared/repo-types' import { getErrorCode } from '../git/worktree-operation-options' +import { resolveLocalProjectRuntimesForRepos } from '../local-project-runtime-resolution' +import type { Store } from '../persistence' +import { getWorktreeMirrorDistroForRuntime } from '../project-runtime-git-options' import { listRepoWorktreeGraph } from '../repo-worktrees' const CREATED_WORKTREE_ROOT_PROBE_TIMEOUT_MS = 1_000 const AUTHORIZED_ROOTS_REBUILD_CONCURRENCY = 8 -type ListedRoots = { roots: Set; listingFailed: boolean } +/** `wslDistro` names the Git that listed the roots; undefined is host Git. */ +type ListedRoots = { roots: Set; listingFailed: boolean; wslDistro: string | undefined } +/** + * Why the project runtime's distro: the catalog and removal list through it, and WSL Git records a + * `C:\` worktree as `/mnt/c/...`, which host Git reading that metadata names as another path. A + * runtime awaiting repair has no distro, so it keeps the host Git this listing always used. + */ export async function listWorktreeRootsWithConcurrency( + store: Store, repos: readonly Repo[] ): Promise { + const runtimes = resolveLocalProjectRuntimesForRepos(store, repos) const results: ListedRoots[] = [] let nextIndex = 0 await Promise.all( @@ -22,10 +33,14 @@ export async function listWorktreeRootsWithConcurrency( while (nextIndex < repos.length) { const index = nextIndex++ const repo = repos[index] + const wslDistro = getWorktreeMirrorDistroForRuntime(runtimes.get(repo.id)) const roots = new Set([resolve(repo.path)]) let listingFailed = false try { - for (const worktree of await listRepoWorktreeGraph(repo)) { + for (const worktree of await listRepoWorktreeGraph( + repo, + wslDistro ? { wslDistro } : {} + )) { roots.add(resolve(worktree.path)) } } catch (error) { @@ -35,7 +50,7 @@ export async function listWorktreeRootsWithConcurrency( ) listingFailed = true } - results[index] = { roots, listingFailed } + results[index] = { roots, listingFailed, wslDistro } } } ) diff --git a/src/main/ipc/registered-worktree-root-relist-policy.ts b/src/main/ipc/registered-worktree-root-relist-policy.ts index 7707bcce983..b7348a2f5f1 100644 --- a/src/main/ipc/registered-worktree-root-relist-policy.ts +++ b/src/main/ipc/registered-worktree-root-relist-policy.ts @@ -4,6 +4,12 @@ * Split out of registered-worktree-roots-cache so the policy is testable on its own * and the cache module stays within its line budget. */ +import type { Repo } from '../../shared/repo-types' +import { resolveLocalProjectRuntimesForRepos } from '../local-project-runtime-resolution' +import type { Store } from '../persistence' +import { getWorktreeMirrorDistroForRuntime } from '../project-runtime-git-options' +import type { RegisteredOwner } from './registered-worktree-root-owner' + /** The owner fields the policy reads; `undefined` means no owner record yet. */ export type RelistCandidate = | { dirty: boolean; listed: unknown; recovered: { size: number } } @@ -24,3 +30,26 @@ export function shouldRelistOwner(owner: RelistCandidate, onlyDirty: boolean): b } return owner.dirty || owner.listed == null || owner.recovered.size > 0 } + +/** + * Owners whose roots came from a Git other than the one their project runtime now selects. + * + * A runtime awaiting repair is skipped: it names no Git, and re-listing through host Git would + * revoke worktrees only the last real listing can name. Its owners keep that listing. + */ +export function findOwnersListedThroughAnotherGit( + store: Store, + repos: ReadonlyMap, + owners: ReadonlyMap +): RegisteredOwner[] { + const runtimes = resolveLocalProjectRuntimesForRepos(store, [...repos.values()]) + return [...repos].flatMap(([key, repo]) => { + const owner = owners.get(key) + const runtime = runtimes.get(repo.id) + return owner?.listed && + runtime?.status !== 'repair-required' && + owner.listedWslDistro !== getWorktreeMirrorDistroForRuntime(runtime) + ? [owner] + : [] + }) +} diff --git a/src/main/ipc/registered-worktree-root-wsl-runtime.test.ts b/src/main/ipc/registered-worktree-root-wsl-runtime.test.ts new file mode 100644 index 00000000000..6d516a3574a --- /dev/null +++ b/src/main/ipc/registered-worktree-root-wsl-runtime.test.ts @@ -0,0 +1,219 @@ +import { join, resolve } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Store } from '../persistence' +import type { Repo } from '../../shared/repo-types' +import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' + +const mocks = vi.hoisted(() => ({ graph: vi.fn(), runtimes: vi.fn(), realpath: vi.fn() })) +vi.mock('node:fs', () => ({ realpathSync: (path: string) => path })) +vi.mock('node:fs/promises', () => ({ stat: vi.fn(async () => ({})), realpath: mocks.realpath })) +vi.mock('../repo-worktrees', () => ({ + listRepoWorktreeGraph: mocks.graph, + isRepoRoot: vi.fn(() => false) +})) +vi.mock('../local-project-runtime-resolution', () => ({ + resolveLocalProjectRuntimesForRepos: mocks.runtimes +})) +import { PATH_ACCESS_DENIED_MESSAGE, resolveAuthorizedPath } from './filesystem-auth' +import { + __resetCreatedWorktreeRootsForTests, + invalidateAuthorizedRootsCache, + registerWorktreeRootsForRepo, + resolveRegisteredWorktreePath +} from './registered-worktree-roots-cache' + +const DISTRO = 'Ubuntu-24.04' +const repo: Repo = { + id: 'wsl-drive-repo', + path: resolve('/wsl-drive/repo'), + displayName: 'repo', + badgeColor: '#000', + addedAt: 0 +} +// The same checkout as each Git names it: WSL Git's `/mnt/c/...` translated to the drive spelling +// every other consumer sends, and host Git reading that `/mnt/c` metadata as a different path. +const worktree = resolve('/wsl-drive/workspaces/feature-extra') +const hostGitSpelling = resolve('/mnt/c/wsl-drive/workspaces/feature-extra') +const hostRepo: Repo = { ...repo, id: 'host-repo', path: resolve('/host-drive/repo') } +const hostRepoWorktree = resolve('/host-drive/workspaces/feature') + +const wslRuntime: ProjectExecutionRuntimeResolution = { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + distro: DISTRO, + projectId: 'project', + reason: 'project-override', + cacheKey: `project:wsl:${DISTRO}` + } +} +const hostRuntime: ProjectExecutionRuntimeResolution = { + status: 'resolved', + runtime: { + kind: 'windows-host', + hostPlatform: 'win32', + projectId: 'project', + reason: 'project-override', + cacheKey: 'project:windows-host' + } +} +const repairRuntime: ProjectExecutionRuntimeResolution = { + status: 'repair-required', + repair: { + projectId: 'project', + preferredRuntime: { kind: 'wsl', distro: DISTRO }, + reason: 'wsl-distro-missing', + source: 'project-override', + cacheKey: `project:repair:wsl-distro-missing:${DISTRO}` + } +} + +function fixture(repos: Repo[] = [repo]): Store { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: authorization reads only these store methods; listing and runtime resolution are mocked. + return { + getRepos: () => repos, + getProjectGroups: () => [], + getFolderWorkspaces: () => [], + getSettings: () => ({}) + } as unknown as Store +} + +function useRuntime(runtime: ProjectExecutionRuntimeResolution): void { + mocks.runtimes.mockImplementation( + (_store: Store, repos: readonly Repo[]) => + new Map(repos.map((entry) => [entry.id, entry.id === hostRepo.id ? hostRuntime : runtime])) + ) +} + +beforeEach(() => { + invalidateAuthorizedRootsCache() + __resetCreatedWorktreeRootsForTests() + vi.resetAllMocks() + mocks.realpath.mockImplementation(async (path: string) => path) + mocks.graph.mockImplementation(async (listed: Repo, options?: { wslDistro?: string }) => [ + { + path: + listed.id === hostRepo.id + ? hostRepoWorktree + : options?.wslDistro === DISTRO + ? worktree + : hostGitSpelling + } + ]) + useRuntime(wslRuntime) +}) + +describe('authorized worktree roots for a drive repo whose project runtime is WSL', () => { + it('lists through the project distro, so the path delete and git status send is authorized', async () => { + await expect(resolveAuthorizedPath(worktree, fixture())).resolves.toBe(worktree) + await expect(resolveRegisteredWorktreePath(worktree, fixture())).resolves.toBe(worktree) + expect(mocks.graph).toHaveBeenCalledWith(repo, { wslDistro: DISTRO }) + }) + + it('still refuses a path outside every root, including the spelling only host Git lists', async () => { + const store = fixture() + await expect( + resolveAuthorizedPath(resolve('/wsl-drive/elsewhere/secret'), store) + ).rejects.toThrow(PATH_ACCESS_DENIED_MESSAGE) + await expect(resolveAuthorizedPath(join(hostGitSpelling, 'file'), store)).rejects.toThrow( + PATH_ACCESS_DENIED_MESSAGE + ) + }) + + it('re-lists through the new Git when the project runtime changes after a listing', async () => { + useRuntime(hostRuntime) + const store = fixture() + await expect(resolveAuthorizedPath(join(hostGitSpelling, 'file'), store)).resolves.toBe( + join(hostGitSpelling, 'file') + ) + + useRuntime(wslRuntime) + await expect(resolveAuthorizedPath(join(worktree, 'file'), store)).resolves.toBe( + join(worktree, 'file') + ) + expect(mocks.graph).toHaveBeenLastCalledWith(repo, { wslDistro: DISTRO }) + // The host-Git listing is retired with its owner, not kept beside the new one. + await expect(resolveAuthorizedPath(join(hostGitSpelling, 'file'), store)).rejects.toThrow( + PATH_ACCESS_DENIED_MESSAGE + ) + }) + + it('keeps a registration WSL Git produced without re-listing', async () => { + const store = fixture() + registerWorktreeRootsForRepo(store, repo.id, [repo.path, worktree], { wslDistro: DISTRO }) + await expect(resolveAuthorizedPath(resolve('/wsl-drive/outside'), store)).rejects.toThrow( + PATH_ACCESS_DENIED_MESSAGE + ) + expect(mocks.graph).not.toHaveBeenCalled() + await expect(resolveAuthorizedPath(join(worktree, 'file'), store)).resolves.toBe( + join(worktree, 'file') + ) + }) + + it('re-lists a registration host Git produced for a WSL project on the next miss', async () => { + const store = fixture() + registerWorktreeRootsForRepo(store, repo.id, [repo.path, hostGitSpelling]) + await expect(resolveAuthorizedPath(join(worktree, 'file'), store)).resolves.toBe( + join(worktree, 'file') + ) + expect(mocks.graph).toHaveBeenCalledWith(repo, { wslDistro: DISTRO }) + }) + + it('lists with host Git while the runtime awaits repair, as before routing', async () => { + useRuntime(repairRuntime) + await expect(resolveAuthorizedPath(join(hostGitSpelling, 'file'), fixture())).resolves.toBe( + join(hostGitSpelling, 'file') + ) + expect(mocks.graph).toHaveBeenCalledWith(repo, {}) + }) + + it('keeps the WSL listing when a resolved runtime starts awaiting repair', async () => { + const store = fixture() + await expect(resolveAuthorizedPath(join(worktree, 'file'), store)).resolves.toBe( + join(worktree, 'file') + ) + + useRuntime(repairRuntime) + await expect(resolveAuthorizedPath(resolve('/wsl-drive/outside'), store)).rejects.toThrow( + PATH_ACCESS_DENIED_MESSAGE + ) + expect(mocks.graph).toHaveBeenCalledTimes(1) + await expect(resolveAuthorizedPath(join(worktree, 'file'), store)).resolves.toBe( + join(worktree, 'file') + ) + }) + + it('re-lists in the same request when the runtime changes during an in-flight rebuild', async () => { + useRuntime(hostRuntime) + const store = fixture() + let releaseHostListing = (): void => {} + mocks.graph.mockImplementationOnce( + (_repo: Repo) => + new Promise((resolveListing) => { + releaseHostListing = () => resolveListing([{ path: hostGitSpelling }]) + }) + ) + const earlier = resolveAuthorizedPath(resolve('/wsl-drive/outside'), store) + await vi.waitFor(() => expect(mocks.graph).toHaveBeenCalledTimes(1)) + + useRuntime(wslRuntime) + const afterSwitch = resolveAuthorizedPath(join(worktree, 'file'), store) + releaseHostListing() + await expect(earlier).rejects.toThrow(PATH_ACCESS_DENIED_MESSAGE) + await expect(afterSwitch).resolves.toBe(join(worktree, 'file')) + }) + + it('keeps a host-runtime repo in the same cache on host Git', async () => { + const store = fixture([repo, hostRepo]) + await expect(resolveAuthorizedPath(join(hostRepoWorktree, 'file'), store)).resolves.toBe( + join(hostRepoWorktree, 'file') + ) + await expect(resolveAuthorizedPath(join(worktree, 'file'), store)).resolves.toBe( + join(worktree, 'file') + ) + expect(mocks.graph).toHaveBeenCalledWith(hostRepo, {}) + expect(mocks.graph).toHaveBeenCalledWith(repo, { wslDistro: DISTRO }) + expect(mocks.graph).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/ipc/registered-worktree-roots-cache.ts b/src/main/ipc/registered-worktree-roots-cache.ts index ba538356b9a..ecdabd46abb 100644 --- a/src/main/ipc/registered-worktree-roots-cache.ts +++ b/src/main/ipc/registered-worktree-roots-cache.ts @@ -6,21 +6,17 @@ import { pruneCreatedWorktreeRoots } from './registered-worktree-root-probes' import { isDescendantOrEqual, normalizeExistingPath } from './filesystem-path-containment' -import { shouldRelistOwner } from './registered-worktree-root-relist-policy' import { + findOwnersListedThroughAnotherGit, + shouldRelistOwner +} from './registered-worktree-root-relist-policy' +import { + createRegisteredOwner, getLocalWorktreeRootOwners, - resolveWorktreeRootOwner + resolveWorktreeRootOwner, + type RegisteredOwner } from './registered-worktree-root-owner' -type RegisteredOwner = { - repoId: string - listed: Set | null - recovered: Set - aliases: Set - revision: number - dirty: boolean -} - const registeredWorktreeRoots = new Set() const registeredOwners = new Map() const registeredWorktreeRootsRevisionByRepo = new Map() @@ -45,6 +41,16 @@ function resolveOwnerForRepo(store: Store, repo: Repo | string): RegisteredOwner return key === undefined ? undefined : registeredOwners.get(key) } +function relistOwners(owners: Iterable): void { + for (const owner of owners) { + owner.listed = null + owner.dirty = true + advanceOwner(owner) + } + refreshRegisteredWorktreeRoots() + registeredWorktreeRootsDirty = true +} + function synchronizeOwners(store: Store): Repo[] { const repos = store.getRepos() const owners = getLocalWorktreeRootOwners(repos) @@ -63,14 +69,7 @@ function synchronizeOwners(store: Store): Repo[] { } for (const [key, repo] of owners) { if (!registeredOwners.has(key)) { - registeredOwners.set(key, { - repoId: repo.id, - listed: null, - recovered: new Set(), - aliases: new Set(), - revision: ++revisionSequence, - dirty: true - }) + registeredOwners.set(key, createRegisteredOwner(repo.id, ++revisionSequence)) changed = true } } @@ -84,13 +83,7 @@ function synchronizeOwners(store: Store): Repo[] { export function invalidateAuthorizedRootsCache(): void { invalidationGeneration++ - for (const owner of registeredOwners.values()) { - owner.listed = null - owner.dirty = true - advanceOwner(owner) - } - refreshRegisteredWorktreeRoots() - registeredWorktreeRootsDirty = true + relistOwners(registeredOwners.values()) baseRevision = ++revisionSequence registeredWorktreeRootsRevisionByRepo.clear() } @@ -111,7 +104,10 @@ export async function rebuildAuthorizedRootsCache(store: Store, onlyDirty = fals // every registered repo. An owner with no listing yet (`listed === null`) is // always included, so a first rebuild is unchanged. .filter((entry) => shouldRelistOwner(entry.owner, onlyDirty)) - const listings = await listWorktreeRootsWithConcurrency(pending.map((entry) => entry.repo)) + const listings = await listWorktreeRootsWithConcurrency( + store, + pending.map((entry) => entry.repo) + ) const results = pending.map((entry, index) => ({ ...entry, ...listings[index] })) const isCurrent = (entry: (typeof pending)[number]): boolean => generation === invalidationGeneration && @@ -134,6 +130,7 @@ export async function rebuildAuthorizedRootsCache(store: Store, onlyDirty = fals continue } entry.owner.listed = entry.roots + entry.owner.listedWslDistro = entry.wslDistro entry.owner.dirty = false entry.owner.recovered = entry.recovered advanceOwner(entry.owner) @@ -155,24 +152,23 @@ export function markAuthorizedRootsOwnerDirty(store: Store, repo: Repo | string) if (!owner) { return false } - owner.listed = null - owner.dirty = true - advanceOwner(owner) - refreshRegisteredWorktreeRoots() - registeredWorktreeRootsDirty = true + relistOwners([owner]) return true } +/** `listing.wslDistro` is the distro whose Git produced `worktreeRoots`; omitted is host Git. */ export function registerWorktreeRootsForRepo( store: Store, repo: Repo | string, - worktreeRoots: string[] + worktreeRoots: string[], + listing: { wslDistro?: string } = {} ): void { const owner = resolveOwnerForRepo(store, repo) if (!owner) { return } owner.listed = new Set(worktreeRoots.map((root) => resolve(root))) + owner.listedWslDistro = listing.wslDistro owner.dirty = false advanceOwner(owner) refreshRegisteredWorktreeRoots() @@ -218,7 +214,16 @@ export function getRegisteredWorktreeRootsRevision(repoId: string): number { export async function ensureAuthorizedRootsCache(store: Store): Promise { synchronizeOwners(store) // Follow one superseded refresh; continuous catalog churn must not pin authorization forever. - for (let attempt = 0; registeredWorktreeRootsDirty && attempt < 2; attempt++) { + for (let attempt = 0; attempt < 2; attempt++) { + // Why re-derived each attempt: nothing invalidates this cache when a project's runtime changes, + // and a refresh that began before the change commits a listing from the old Git. + const drifted = findOwnersListedThroughAnotherGit(store, currentOwners, registeredOwners) + if (drifted.length > 0) { + relistOwners(drifted) + } + if (!registeredWorktreeRootsDirty) { + break + } if (!registeredWorktreeRootsRefresh) { registeredWorktreeRootsRefresh = rebuildAuthorizedRootsCache(store, true).finally(() => { registeredWorktreeRootsRefresh = null diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 11b897b81db..36e25a018b6 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -2828,10 +2828,12 @@ async function performLocalWorktreeCreate( // Why gated: registration replaces the repo's root set, so registering a create recovered without // a listing would revoke filesystem access to every worktree that listing would have named. if (listingComplete) { - registerWorktreeRootsForRepo(store, repo, [ - repo.path, - ...gitWorktrees.map((worktree) => worktree.path) - ]) + registerWorktreeRootsForRepo( + store, + repo, + [repo.path, ...gitWorktrees.map((worktree) => worktree.path)], + localWorktreeGitOptions + ) } else { // Recovered without a listing: authorize just the new root, or the create the user just made // is rejected by filesystem/git-status IPC until a full scan repopulates the cache. diff --git a/src/main/ipc/worktrees/listing/detected-worktree-scan-cache.ts b/src/main/ipc/worktrees/listing/detected-worktree-scan-cache.ts index dad44c64a69..4df3d379d4d 100644 --- a/src/main/ipc/worktrees/listing/detected-worktree-scan-cache.ts +++ b/src/main/ipc/worktrees/listing/detected-worktree-scan-cache.ts @@ -47,6 +47,8 @@ export type DetectedWorktreeScan = { export type DetectedWorktreeSideEffectToken = Readonly<{ generation: number authorizedRootsRevision: number + /** The distro whose Git listed the scan; undefined is host Git. */ + wslDistro?: string }> export type DetectedWorktreeMetadataPrune = Readonly<{ @@ -173,7 +175,7 @@ export async function listDetectedGitWorktrees( const scan: DetectedWorktreeScan = { invalidated: false, promise: listRepoWorktreesForDetectedScan(repo, localWorktreeGitOptions), - sideEffectToken: { generation, authorizedRootsRevision }, + sideEffectToken: { generation, authorizedRootsRevision, ...localWorktreeGitOptions }, hygieneDue, ...(metadataPruneExpectation ? { @@ -269,7 +271,7 @@ export async function applyFreshDetectedWorktreeScanSideEffects( ) { return false } - rememberLocalWorktreeRoots(store, repo, gitWorktrees) + rememberLocalWorktreeRoots(store, repo, gitWorktrees, sideEffectToken) // Why: lineage retention is decided against the metadata rows the prune preserved, so running it // without that pass would drop lineage for rows the pass would have kept. Both halves share the // hygiene cadence instead. @@ -294,14 +296,17 @@ export function getDetectedWorktreeScanCacheKey( export function rememberLocalWorktreeRoots( store: Store, repo: Repo, - gitWorktrees: GitWorktreeInfo[] + gitWorktrees: GitWorktreeInfo[], + listing: { wslDistro?: string } = {} ): void { if (getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID) { return } // Why: reuse the `git worktree list` result so later git/file IPC validation skips a second scan that can trigger macOS folder-permission prompts. - registerWorktreeRootsForRepo(store, repo, [ - repo.path, - ...gitWorktrees.map((worktree) => worktree.path) - ]) + registerWorktreeRootsForRepo( + store, + repo, + [repo.path, ...gitWorktrees.map((worktree) => worktree.path)], + listing + ) } diff --git a/src/main/ipc/worktrees/listing/detected-worktree-scan-listing-distro.test.ts b/src/main/ipc/worktrees/listing/detected-worktree-scan-listing-distro.test.ts new file mode 100644 index 00000000000..952ae1c68d2 --- /dev/null +++ b/src/main/ipc/worktrees/listing/detected-worktree-scan-listing-distro.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import type { Store } from '../../../persistence/loading-store/store' + +const mocks = vi.hoisted(() => ({ + list: vi.fn(), + gitOptions: vi.fn(), + registerRoots: vi.fn() +})) + +vi.mock('../../../repo-worktrees', () => ({ listRepoWorktreesForDetectedScan: mocks.list })) +vi.mock('../../../project-runtime-git-options', () => ({ + getLocalProjectWorktreeGitOptions: mocks.gitOptions +})) +vi.mock('../../registered-worktree-roots-cache', () => ({ + getRegisteredWorktreeRootsRevision: () => 1, + registerWorktreeRootsForRepo: mocks.registerRoots +})) +vi.mock('../../../worktree-lineage-pruning', () => ({ + pruneLineageForMissingRepoWorktrees: vi.fn() +})) + +const { + __resetDetectedWorktreeScanCacheForTests, + applyFreshDetectedWorktreeScanSideEffects, + listDetectedGitWorktrees +} = await import('./detected-worktree-scan-cache') + +const repo: Repo = { + id: 'repo-1', + path: '/repos/one', + displayName: 'one', + badgeColor: '#000', + addedAt: 0 +} +const worktree = { path: '/repos/one-feature', head: 'abc', branch: 'feature', isBare: false } +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a first local scan reads only this store method; routing, listing and registration are mocked. +const store = { captureNativeLocalWorktreeMetadataScanExpectation: vi.fn() } as unknown as Store + +async function scanAndRegister(): Promise { + const scan = await listDetectedGitWorktrees(store, repo) + await applyFreshDetectedWorktreeScanSideEffects(store, repo, scan.gitWorktrees, undefined, { + sideEffectToken: scan.sideEffectToken, + hygieneDue: false + }) +} + +describe('detected worktree scan root registration', () => { + beforeEach(() => { + vi.resetAllMocks() + mocks.list.mockResolvedValue([{ ...worktree, isMainWorktree: false }]) + __resetDetectedWorktreeScanCacheForTests() + }) + + it('records the distro whose Git listed the scan', async () => { + mocks.gitOptions.mockReturnValue({ wslDistro: 'Ubuntu-24.04' }) + await scanAndRegister() + + expect(mocks.registerRoots).toHaveBeenCalledWith( + store, + repo, + [repo.path, worktree.path], + expect.objectContaining({ wslDistro: 'Ubuntu-24.04' }) + ) + }) + + it('records host Git for a scan listed without a distro', async () => { + mocks.gitOptions.mockReturnValue({}) + await scanAndRegister() + + expect(mocks.registerRoots).toHaveBeenCalledTimes(1) + expect(mocks.registerRoots.mock.calls[0]?.[3]?.wslDistro).toBeUndefined() + }) +}) From f2257ffa691be53938afccddafc2ecc00de0215a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:40:43 -0700 Subject: [PATCH 09/21] fix: dismiss Codex account prompt and return focus to terminal (#24683) --- .../src/components/CodexRestartChip.tsx | 18 ++++- .../components/codex-restart-chip.test.tsx | 73 +++++++++++++++++- .../TerminalPaneRuntimePortals.tsx | 17 ++++- .../e2e/codex-account-switch-dismiss.spec.ts | 76 +++++++++++++++++++ 4 files changed, 180 insertions(+), 4 deletions(-) create mode 100644 tests/e2e/codex-account-switch-dismiss.spec.ts diff --git a/src/renderer/src/components/CodexRestartChip.tsx b/src/renderer/src/components/CodexRestartChip.tsx index 09835b71302..cc90244579e 100644 --- a/src/renderer/src/components/CodexRestartChip.tsx +++ b/src/renderer/src/components/CodexRestartChip.tsx @@ -20,10 +20,12 @@ type RestartNotice = { export default function CodexRestartChip({ isVisible = true, ptyId, + onReturnFocus, shouldFocus = false }: { isVisible?: boolean ptyId: string + onReturnFocus: () => void shouldFocus?: boolean }): React.JSX.Element | null { // Why: one O(1) selector per mounted pane stays idle when unrelated PTY maps @@ -35,10 +37,12 @@ export default function CodexRestartChip({ const handleRestart = (): void => { useAppStore.getState().queueCodexPaneRestarts([ptyId]) + onReturnFocus() } const handleDismiss = (): void => { useAppStore.getState().dismissCodexRestartNotices([ptyId]) + onReturnFocus() // Why: notices are renderer-only, so the persisted launch record must be // cleared for this pane or the startup sweep re-raises its answered prompt. void window.api.codexAccounts.forgetStalePanes({ ptyIds: [ptyId] }).catch((err: unknown) => { @@ -106,7 +110,19 @@ function LoudRestartOverlay({ aria-live="assertive" aria-labelledby={titleId} aria-describedby={bodyId} - className="pointer-events-none absolute inset-0 z-50 flex items-center justify-center p-6 outline-none" + onMouseDown={(event) => { + if (event.button === 0 && event.target === event.currentTarget) { + onDismiss() + } + }} + onKeyDown={(event) => { + if (event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + onDismiss() + } + }} + className="absolute inset-0 z-50 flex items-center justify-center p-6 outline-none" >
diff --git a/src/renderer/src/components/codex-restart-chip.test.tsx b/src/renderer/src/components/codex-restart-chip.test.tsx index d3767b2dc92..bee04189ad4 100644 --- a/src/renderer/src/components/codex-restart-chip.test.tsx +++ b/src/renderer/src/components/codex-restart-chip.test.tsx @@ -4,7 +4,14 @@ import React, { act, Profiler } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '../store' -import CodexRestartChip from './CodexRestartChip' +import CodexRestartChipComponent from './CodexRestartChip' + +const returnFocus = vi.fn() +function CodexRestartChip( + props: Omit, 'onReturnFocus'> +) { + return +} globalThis.IS_REACT_ACT_ENVIRONMENT = true @@ -30,6 +37,7 @@ function button(scope: ParentNode, label: string): HTMLButtonElement { } beforeEach(() => { + returnFocus.mockReset() useAppStore.setState(useAppStore.getInitialState(), true) container = document.createElement('div') document.body.appendChild(container) @@ -118,6 +126,7 @@ describe('CodexRestartChip pane ownership', () => { button(firstPane, 'Restart').click() }) + expect(returnFocus).toHaveBeenCalledOnce() const state = useAppStore.getState() expect(state.pendingCodexPaneRestartIds).toEqual({ [PTY_ONE]: true }) expect(state.codexRestartNoticeByPtyId[PTY_ONE]?.restartRequested).toBe(true) @@ -155,6 +164,7 @@ describe('CodexRestartChip pane ownership', () => { button(firstPane, 'Keep old account').click() }) + expect(returnFocus).toHaveBeenCalledOnce() expect(forgetStalePanes).toHaveBeenCalledExactlyOnceWith({ ptyIds: [PTY_ONE] }) expect(useAppStore.getState().codexRestartNoticeByPtyId[PTY_ONE]?.dismissed).toBe(true) expect(useAppStore.getState().codexRestartNoticeByPtyId[PTY_TWO]?.dismissed).toBeUndefined() @@ -273,6 +283,67 @@ describe('CodexRestartChip pane focus', () => { return terminalInput } + it.each(['outside', 'keep', 'escape', 'restart'])( + 'returns focus to the terminal after %s', + async (action) => { + const terminalInput = await renderPane(true) + returnFocus.mockImplementation(() => terminalInput.focus()) + const dialog = container.querySelector('[role="dialog"]')! + await act(async () => { + if (action === 'outside') { + dialog.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 })) + } else if (action === 'escape') { + dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + } else { + button(container, action === 'restart' ? 'Restart' : 'Keep old account').click() + } + }) + expect(document.activeElement).toBe(terminalInput) + expect(container.querySelector('[role="dialog"]')).toBeNull() + expect(returnFocus).toHaveBeenCalledOnce() + if (action !== 'restart') { + expect(forgetStalePanes).toHaveBeenCalledExactlyOnceWith({ ptyIds: [PTY_ONE] }) + expect(useAppStore.getState().pendingCodexPaneRestartIds).toEqual({}) + } + } + ) + + it('does not dismiss when the card body is clicked', async () => { + await renderPane(true) + await act(async () => { + container + .querySelector('[role="dialog"] > div')! + .dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(container.querySelector('[role="dialog"]')).not.toBeNull() + expect(returnFocus).not.toHaveBeenCalled() + expect(forgetStalePanes).not.toHaveBeenCalled() + }) + + it('does not dismiss when a card text selection ends on the backdrop', async () => { + await renderPane(true) + const dialog = container.querySelector('[role="dialog"]')! + await act(async () => { + dialog.querySelector('div')!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) + dialog.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })) + dialog.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(container.querySelector('[role="dialog"]')).not.toBeNull() + expect(returnFocus).not.toHaveBeenCalled() + expect(forgetStalePanes).not.toHaveBeenCalled() + }) + + it('does not dismiss on a secondary mouse button press', async () => { + await renderPane(true) + await act(async () => { + container + .querySelector('[role="dialog"]')! + .dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 2 })) + }) + expect(container.querySelector('[role="dialog"]')).not.toBeNull() + expect(returnFocus).not.toHaveBeenCalled() + }) + it('focuses the dialog itself for the active stale pane', async () => { const terminalInput = await renderPane(true) diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneRuntimePortals.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneRuntimePortals.tsx index 0d6d2d53056..ee003a527b1 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneRuntimePortals.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneRuntimePortals.tsx @@ -1,4 +1,5 @@ import { createPortal } from 'react-dom' +import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import CodexRestartChip from '../CodexRestartChip' import { CodexSharedServerBanner } from './CodexSharedServerBanner' import { TerminalSshReconnectOverlay } from './TerminalSshReconnectOverlay' @@ -16,8 +17,16 @@ export function TerminalPaneCodexRestartPortals({ }: { controller: TerminalPaneController }): React.JSX.Element { - const { activePane, isActive, isVisible, managedPanes, paneTransportsRef, savedLayout, tabId } = - controller + const { + activePane, + isActive, + isVisible, + managedPanes, + managerRef, + paneTransportsRef, + savedLayout, + tabId + } = controller return ( <> {managedPanes.map((pane) => { @@ -34,6 +43,10 @@ export function TerminalPaneCodexRestartPortals({ key={`codex-restart-${pane.id}-${ptyId}`} isVisible={isVisible} ptyId={ptyId} + onReturnFocus={() => { + managerRef.current?.setActivePane(pane.id, { focus: false }) + focusTerminalTabSurface(tabId, pane.leafId) + }} shouldFocus={isActive && isVisible && activePane?.id === pane.id} /> { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await configureGoldenStubAgent(orcaPage) + await launchGoldenStubAgentFromNewTab(orcaPage) + + for (const action of ['outside', 'keep', 'escape', 'restart'] as const) { + const ptyId = await waitForActivePanePtyId(orcaPage) + await orcaPage.evaluate( + ({ ptyId, action }) => { + window.__store.getState().markCodexRestartNotices([ + { + ptyId, + previousAccountLabel: 'Previous account', + nextAccountLabel: `Current account (${action})` + } + ]) + }, + { ptyId, action } + ) + const dialog = orcaPage.getByRole('dialog').filter({ hasText: 'Account switched' }) + await expect(dialog).toBeVisible() + await expect(dialog).toBeFocused() + if (action === 'outside') { + const cardText = await dialog.getByText(/Restart this session to use/).boundingBox() + const backdrop = await dialog.boundingBox() + if (!cardText || !backdrop) { + throw new Error('Account prompt has no rendered bounds') + } + await orcaPage.mouse.move(cardText.x + 8, cardText.y + 8) + await orcaPage.mouse.down() + await orcaPage.mouse.move(backdrop.x + 8, backdrop.y + 8) + await orcaPage.mouse.up() + await expect(dialog).toBeVisible() + await orcaPage.screenshot({ path: testInfo.outputPath('account-switched.png') }) + await dialog.click({ position: { x: 8, y: 8 } }) + } else if (action === 'escape') { + await orcaPage.keyboard.press('Escape') + } else { + await dialog + .getByRole('button', { + name: action === 'keep' ? 'Keep old account' : 'Restart', + exact: true + }) + .click() + } + await expect(dialog).toHaveCount(0) + const input = orcaPage.locator('.xterm-helper-textarea:focus') + await expect(input).toHaveCount(1) + if (action === 'restart') { + // This check covers the focus handoff; process replacement is a separate contract. + break + } + // A printed reply proves input reached the process without an extra terminal click. + const marker = `ACCOUNT_SWITCH_${action.toUpperCase()}` + await orcaPage.keyboard.type(marker) + await orcaPage.keyboard.press('Enter') + await waitForTerminalOutput(orcaPage, `[GOLDEN_STUB_AGENT_SUBMITTED] ${marker}`, 20_000) + await expect(input).toHaveCount(1) + await orcaPage.screenshot({ path: testInfo.outputPath(`after-${action}.png`) }) + } +}) From d6d2795da52bff01347d12848c51ebd04aa8fd40 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:46:13 -0700 Subject: [PATCH 10/21] chore(worktree): include create timing and spare outcome in the workspace create events (#24483) * chore(worktree): include create timing and spare outcome in the workspace-created event The workspace_created and workspace_create_failed events gain optional, numbers-and-enums-only fields built from what the create already measured: total and per-phase durations, the prepared-checkout hit/miss and miss reason, the execution host (local/WSL/SSH), a worktree count bucket, how many other creates were in flight, whether the repo has a post-checkout hook (file existence only, probed after the create returns), and for a failure the phase it died in plus elapsed time. No new git process runs; consent and opt-out are unchanged. * fix(worktree): attribute failed_phase by error, label WSL-path repos, skip the hook check with telemetry off - failed_phase now names the outermost timed step the thrown error (or its cause) left, so a caught failure or a concurrent sibling step can no longer be misattributed; the old-relay SSH error keeps its cause so it still reads as git_worktree_add. - execution_host follows the same rule Git routing uses, so a \\wsl.localhost repo reads wsl. - The post-checkout hook check does not read the repo when telemetry is disabled. - Privacy page mentions the miss reason code and the failed step. * test(worktree): pin the old-relay SSH add error to git_worktree_add through its cause * fix(worktree): name the create event field sets for their role, and type the old-relay test's caught error * fix(worktree): send create events from runtime creates and record what the spare checkout did Runtime creates (CLI, agents, phone app, paired clients, orchestration, server automations) reuse prepared checkouts like the app's own creates, but recorded no timing and sent no events. Both entry points now start one shared sender (workspace-create-telemetry.ts), so every create sends exactly one event with the same fields, plus create_entry_point (app | runtime). Spare-checkout fields: - concurrent_preparations: peak prepared-checkout builds and background discards running during the create, excluding the one it used; the window closes before the create's own re-arm starts. - prepared_checkout_claim / prepared_checkout_discard phases, so on a miss git_worktree_add minus the prepared_checkout_* phases is the plain checkout. - prepared_checkout_reset (none | base_moved | retargeted) replaces the retargeted flag; prepared_checkout_origin (prefetch | rearm) on hits. - workspace_create_failed carries the spare outcome and its wait. - repo_index_size_bucket from one stat of .git/index in the existing post-create probe (telemetry on, local/WSL only, 2 s cap). * fix(worktree): add spare build and idle time, the re-arm prefetch origin, and a tracked-file count - prepared_checkout_build_ms / prepared_checkout_idle_ms on hits: from arming the spare to ready, and how long it sat ready before the claim (0 when the create waited). readyAt is recorded in the pool's existing ready handler. - prepared_checkout_origin gains rearm_then_prefetch: an automatic re-arm that the dialog prefetch then asked for too, so rearm means the re-arm alone. - repo_file_count_bucket replaces the index byte size: the entry count from the 12-byte index header, which is the same in every index version; left out for a split or sparse index. - The shared sender never lets a failed send change the create's result or error; it logs instead and still ends the create's concurrency membership. - Tests pin the runtime SSH create's timing hand-off and the throwing-send cases on both entry points. * fix(worktree): leave out the file count under any sparse checkout and time spare builds monotonically - The repo probe also reads .git/config.worktree, where git sparse-checkout --sparse-index writes index.sparse, and omits the file count whenever sparse checkout or a sparse index is on in either file, with Git's boolean spellings. core.hooksPath there is honoured too. - prepared_checkout_build_ms / _idle_ms use performance.now(), like every other duration; the build is timed from its own start (buildStartedAt). - The origin field comment names all three values. * fix(worktree): keep the spare's build time on its first build and count worktrees by lock reason - prepared_checkout_build_ms runs from the first build's start (including any wait for the base fetch it is built on) to its first ready; a later tip refresh no longer restarts it, though it still counts as new preparation work. prepared_checkout_idle_ms runs from the latest ready (build or refresh) to the claim. - The worktree count reads each .git/worktrees entry's locked file and leaves out entries whose lock reason names an Orca preparation, the way the listing does, instead of subtracting this process's spares. That covers spares from other processes, crash leftovers and spares being discarded, and cannot run one low while a spare's admin dir does not exist yet. It has its own 1.5 s cap inside the probe. --- docs/site/content/docs/telemetry.mdx | 2 +- .../command-runner/git-command-resolution.ts | 3 +- src/main/git/create-event-repo-probe.test.ts | 180 +++++++++ src/main/git/create-event-repo-probe.ts | 170 +++++++++ src/main/git/worktree-create-preparation.ts | 12 +- src/main/ipc/worktree-remote.ts | 33 +- .../ipc/worktrees-create-event-timing.test.ts | 359 ++++++++++++++++++ .../register-worktree-create-handlers.ts | 44 ++- .../observability/instrumentation.test.ts | 15 +- src/main/observability/instrumentation.ts | 21 +- .../managed-worktree-create-telemetry.test.ts | 335 ++++++++++++++++ .../orca-runtime-create-managed-worktree.ts | 16 +- .../runtime-local-git-worktree-create.ts | 108 +++--- .../runtime-local-worktree-create.test.ts | 45 ++- .../runtime/runtime-local-worktree-create.ts | 38 +- ...ime-local-worktree-materialization.test.ts | 4 +- .../runtime-local-worktree-materialization.ts | 143 +++---- .../runtime-remote-worktree-create-request.ts | 6 +- .../workspace-create-event-fields.test.ts | 243 ++++++++++++ src/main/workspace-create-event-fields.ts | 160 ++++++++ src/main/workspace-create-telemetry.ts | 160 ++++++++ src/main/worktree-create-concurrency.test.ts | 87 +++++ src/main/worktree-create-concurrency.ts | 109 ++++++ ...rktree-create-preparation-activity.test.ts | 91 +++++ .../worktree-create-preparation-activity.ts | 80 ++++ ...ktree-create-preparation-claim-registry.ts | 78 ++++ src/main/worktree-create-preparation-pool.ts | 100 ++--- ...worktree-create-preparation-reservation.ts | 96 +++++ ...worktree-create-preparation-timing.test.ts | 174 ++++++++- src/main/worktree-create-preparation.test.ts | 14 + src/main/worktree-create-preparation.ts | 204 +++++----- src/main/worktree-create-timing.test.ts | 168 +++++++- src/main/worktree-create-timing.ts | 83 +++- .../worktree-preparation-discard-retry.ts | 4 +- .../worktree-preparation-tip-refresh.test.ts | 44 ++- src/shared/telemetry-app-event-schemas.ts | 4 +- .../telemetry-repository-event-schemas.ts | 4 +- ...telemetry-workspace-create-schemas.test.ts | 147 +++++++ .../telemetry-workspace-create-schemas.ts | 121 ++++++ .../worktree/create-timing-vocabulary.ts | 74 ++++ src/shared/worktree/create-types.ts | 51 ++- 41 files changed, 3427 insertions(+), 403 deletions(-) create mode 100644 src/main/git/create-event-repo-probe.test.ts create mode 100644 src/main/git/create-event-repo-probe.ts create mode 100644 src/main/ipc/worktrees-create-event-timing.test.ts create mode 100644 src/main/runtime/managed-worktree-create-telemetry.test.ts create mode 100644 src/main/workspace-create-event-fields.test.ts create mode 100644 src/main/workspace-create-event-fields.ts create mode 100644 src/main/workspace-create-telemetry.ts create mode 100644 src/main/worktree-create-concurrency.test.ts create mode 100644 src/main/worktree-create-concurrency.ts create mode 100644 src/main/worktree-create-preparation-activity.test.ts create mode 100644 src/main/worktree-create-preparation-activity.ts create mode 100644 src/main/worktree-create-preparation-claim-registry.ts create mode 100644 src/main/worktree-create-preparation-reservation.ts create mode 100644 src/shared/telemetry-workspace-create-schemas.test.ts create mode 100644 src/shared/telemetry-workspace-create-schemas.ts create mode 100644 src/shared/worktree/create-timing-vocabulary.ts diff --git a/docs/site/content/docs/telemetry.mdx b/docs/site/content/docs/telemetry.mdx index 2dc4549401d..a8b8715812a 100644 --- a/docs/site/content/docs/telemetry.mdx +++ b/docs/site/content/docs/telemetry.mdx @@ -20,7 +20,7 @@ Alongside each event we include basic build and platform information: the Orca v The categories of behavior we observe: - **Lifecycle** — when the app opens. Used to estimate daily, weekly, and monthly active users. -- **Repos and workspaces** — when you add a repo or create a workspace. We record _how_ you did it (e.g. folder picker vs. clone URL; command palette vs. drag-and-drop), never the repo name, URL, path, branch name, or any free-form text. +- **Repos and workspaces** — when you add a repo or create a workspace. We record _how_ you did it (e.g. folder picker vs. clone URL; command palette vs. drag-and-drop), never the repo name, URL, path, branch name, or any free-form text. For a workspace create we also record how long it took and how that time split across its steps, whether a checkout Orca had prepared in advance was reused (and if so, what reset it needed, what prepared it, how long it took to prepare and how long it waited unused; if not, a fixed reason code), for a failed create which step it stopped in, plus coarse context: whether it came from the Orca window or from the CLI, phone or agent interface, whether Git ran locally, in WSL or over SSH, a bucketed worktree count, a bucketed count of the files the repo tracks, how many other creates and prepared-checkout jobs were running, and whether the repo has a post-checkout hook (never its contents). - **Agents** — which agents you use and their token counts. Never prompts or agent output. - **Agent errors** — a coarse error category and which agent kind was involved. We never see raw error messages or stack traces; per-incident detail stays in a local diagnostic trace file on your machine and only reaches Orca if you explicitly share a diagnostic bundle. - **Settings** — when you toggle one of a small whitelisted set of feature-flag or UX preferences. We record which preference changed and whether it's a boolean or an enum, never the raw value of any free-form setting. diff --git a/src/main/git/command-runner/git-command-resolution.ts b/src/main/git/command-runner/git-command-resolution.ts index ffd022ad5ab..b63f6699dcb 100644 --- a/src/main/git/command-runner/git-command-resolution.ts +++ b/src/main/git/command-runner/git-command-resolution.ts @@ -14,7 +14,8 @@ import { usesHostGitForWslLinkedWorktree } from '../wsl-linked-worktree-git-rout import { resolveCommand, type ResolvedCommand } from './wsl-command-resolution' import type { GitExecOptions } from './git-exec-options' -function wslDistroForCommand(cwd: string | undefined, override?: string): string | null { +/** The WSL distro a Git command with this cwd runs in, or null when it runs on the host. */ +export function wslDistroForCommand(cwd: string | undefined, override?: string): string | null { if (process.platform !== 'win32') { return null } diff --git a/src/main/git/create-event-repo-probe.test.ts b/src/main/git/create-event-repo-probe.test.ts new file mode 100644 index 00000000000..2bd993f0be1 --- /dev/null +++ b/src/main/git/create-event-repo-probe.test.ts @@ -0,0 +1,180 @@ +import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { probeCreateEventRepoFacts } from './create-event-repo-probe' +import { createWorktreePreparationLockReason } from '../../shared/worktree/create-preparation' + +async function hookPresence(repoPath: string, platform: NodeJS.Platform): Promise { + return (await probeCreateEventRepoFacts(repoPath, platform)).postCheckoutHook +} + +describe('probeCreateEventRepoFacts', () => { + let repo: string + + beforeEach(async () => { + repo = await mkdtemp(path.join(tmpdir(), 'orca-hook-probe-')) + await mkdir(path.join(repo, '.git', 'hooks'), { recursive: true }) + await writeFile(path.join(repo, '.git', 'config'), '[core]\n\tbare = false\n') + }) + + afterEach(async () => { + await rm(repo, { recursive: true, force: true }) + }) + + async function writeHook(mode: number): Promise { + const hook = path.join(repo, '.git', 'hooks', 'post-checkout') + await writeFile(hook, '#!/bin/sh\nexit 0\n') + await chmod(hook, mode) + } + + it('reports absent when only sample hooks exist', async () => { + await writeFile(path.join(repo, '.git', 'hooks', 'post-checkout.sample'), '#!/bin/sh\n') + expect(await hookPresence(repo, 'darwin')).toBe('absent') + }) + + it('reports present for an executable hook', async () => { + await writeHook(0o755) + expect(await hookPresence(repo, 'linux')).toBe('present') + }) + + it('reports absent for a hook Git would skip as non-executable', async () => { + await writeHook(0o644) + expect(await hookPresence(repo, 'linux')).toBe('absent') + }) + + it('reports present for any hook file on Windows', async () => { + await writeHook(0o644) + expect(await hookPresence(repo, 'win32')).toBe('present') + }) + + it('reports a configured hooks path without following it', async () => { + await writeFile(path.join(repo, '.git', 'config'), '[core]\n\thooksPath = .husky/_\n') + await writeHook(0o755) + expect(await hookPresence(repo, 'linux')).toBe('custom_hooks_path') + }) + + it('reports neither fact when .git is a file it would need Git to resolve', async () => { + await rm(path.join(repo, '.git'), { recursive: true }) + await writeFile(path.join(repo, '.git'), 'gitdir: /elsewhere/.git/worktrees/x\n') + expect(await probeCreateEventRepoFacts(repo, 'linux')).toEqual({ postCheckoutHook: 'unknown' }) + }) + + async function registerWorktree(name: string, lockReason?: string): Promise { + const adminDir = path.join(repo, '.git', 'worktrees', name) + await mkdir(adminDir, { recursive: true }) + if (lockReason !== undefined) { + await writeFile(path.join(adminDir, 'locked'), `${lockReason}\n`) + } + } + + it('counts the main checkout plus the linked worktrees, leaving out Orca prepared checkouts', async () => { + await registerWorktree('feature-a') + await registerWorktree('feature-b', 'on a USB drive') + // This process's spare, another process's spare, and a crash leftover all carry the marker. + await registerWorktree('spare-here', createWorktreePreparationLockReason('here')) + await registerWorktree('spare-other', 'orca-create-preparation:v1:424242:other') + await registerWorktree('spare-crashed', 'orca-create-preparation:v1:7:gone') + await writeFile(path.join(repo, '.git', 'worktrees', 'stray-file'), '') + expect(await probeCreateEventRepoFacts(repo, 'linux')).toMatchObject({ worktreeCount: 3 }) + }) + + it('does not count a spare whose admin directory Git has not written yet', async () => { + await registerWorktree('feature-a') + // A spare that has only just started has no entry under .git/worktrees, so nothing to drop. + expect(await probeCreateEventRepoFacts(repo, 'linux')).toMatchObject({ worktreeCount: 2 }) + }) + + it('reports neither fact for a missing repo', async () => { + expect(await probeCreateEventRepoFacts(path.join(repo, 'missing'), 'linux')).toEqual({ + postCheckoutHook: 'unknown' + }) + }) + + async function writeIndex(signature: string, version: number, entries: number): Promise { + const header = Buffer.alloc(12) + header.write(signature, 0, 'latin1') + header.writeUInt32BE(version, 4) + header.writeUInt32BE(entries, 8) + await writeFile(path.join(repo, '.git', 'index'), Buffer.concat([header, Buffer.alloc(64)])) + } + + it.each([2, 4])( + 'reads the tracked-file count from a version %i index header', + async (version) => { + await writeIndex('DIRC', version, 123_456) + expect(await probeCreateEventRepoFacts(repo, 'linux')).toEqual({ + postCheckoutHook: 'absent', + indexEntryCount: 123_456, + worktreeCount: 1 + }) + } + ) + + it('omits the count when the repo has no index yet', async () => { + expect(await probeCreateEventRepoFacts(repo, 'linux')).toEqual({ + postCheckoutHook: 'absent', + worktreeCount: 1 + }) + }) + + it('omits the count for a file that is not an index', async () => { + await writeIndex('NOPE', 2, 10) + expect(await probeCreateEventRepoFacts(repo, 'linux')).not.toHaveProperty('indexEntryCount') + }) + + it('omits the count for a truncated index', async () => { + await writeFile(path.join(repo, '.git', 'index'), Buffer.from('DIRC\0\0')) + expect(await probeCreateEventRepoFacts(repo, 'linux')).not.toHaveProperty('indexEntryCount') + }) + + it('omits the count for a split index, whose entries live in a shared index', async () => { + await writeIndex('DIRC', 2, 3) + await writeFile(path.join(repo, '.git', 'sharedindex.0123abcd'), '') + expect(await probeCreateEventRepoFacts(repo, 'linux')).not.toHaveProperty('indexEntryCount') + }) + + it('omits the count for a sparse index, which collapses directories', async () => { + await writeIndex('DIRC', 4, 3) + await writeFile(path.join(repo, '.git', 'config'), '[index]\n\tsparse = true\n') + expect(await probeCreateEventRepoFacts(repo, 'linux')).not.toHaveProperty('indexEntryCount') + }) + + it.each([ + '[index]\n\tsparse = true\n', + '[index]\n\tsparse = YES\n', + '[index]\n\tsparse\n', + '[core]\n\tsparseCheckout = 1\n' + ])( + 'omits the count when config.worktree turns sparse checkout on (%j)', + async (worktreeConfig) => { + await writeIndex('DIRC', 4, 7) + await writeFile(path.join(repo, '.git', 'config.worktree'), worktreeConfig) + expect(await probeCreateEventRepoFacts(repo, 'linux')).not.toHaveProperty('indexEntryCount') + } + ) + + it('keeps the count when sparse checkout is explicitly off', async () => { + await writeIndex('DIRC', 2, 11) + await writeFile( + path.join(repo, '.git', 'config'), + '[core]\n\tsparseCheckout = false\n\tsparseCheckoutCone = true\n[index]\n\tsparse = off\n' + ) + expect(await probeCreateEventRepoFacts(repo, 'linux')).toMatchObject({ indexEntryCount: 11 }) + }) + + it('reports a hooks path set in config.worktree', async () => { + await writeFile(path.join(repo, '.git', 'config.worktree'), '[core]\n\thooksPath = .githooks\n') + await writeHook(0o755) + expect(await hookPresence(repo, 'linux')).toBe('custom_hooks_path') + }) + + it('reports neither config-based fact when the config cannot be read', async () => { + await rm(path.join(repo, '.git', 'config')) + await writeIndex('DIRC', 2, 10) + expect(await probeCreateEventRepoFacts(repo, 'linux')).toEqual({ + postCheckoutHook: 'unknown', + worktreeCount: 1 + }) + }) +}) diff --git a/src/main/git/create-event-repo-probe.ts b/src/main/git/create-event-repo-probe.ts new file mode 100644 index 00000000000..6725c6948f5 --- /dev/null +++ b/src/main/git/create-event-repo-probe.ts @@ -0,0 +1,170 @@ +import { open, readFile, readdir, stat } from 'node:fs/promises' +import path from 'node:path' +import { getErrorCode } from './worktree-operation-options' +import { parseWorktreePreparationOwnerPid } from '../../shared/worktree/create-preparation' + +/** Existence only: the hook's content is never read. */ +export type PostCheckoutHookPresence = 'present' | 'absent' | 'custom_hooks_path' | 'unknown' + +/** What the create event says about the repo, read from its `.git` directory. */ +export type CreateEventRepoFacts = { + postCheckoutHook: PostCheckoutHookPresence + /** Entries in the index header, i.e. tracked files; absent when the count is missing or unreliable. */ + indexEntryCount?: number + /** The main checkout plus registered linked worktrees, Orca's prepared checkouts excluded. */ + worktreeCount?: number +} + +const PROBE_TIMEOUT_MS = 2_000 +// Shorter than the whole probe, so a slow per-worktree read cannot cost the other facts. +const WORKTREE_COUNT_TIMEOUT_MS = 1_500 + +/** A hung network or WSL mount must not hold the caller; every answer here is best-effort. */ +async function withTimeout(operation: Promise, ms: number, fallback: T): Promise { + let timer: ReturnType | undefined + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve(fallback), ms) + timer.unref?.() + }) + try { + return await Promise.race([operation, timeout]) + } finally { + clearTimeout(timer) + } +} + +/** + * Reads, from files only and never by spawning Git, whether `git worktree add` in this repo will + * run a repo-local post-checkout hook, how many files the repo tracks, and how many worktrees it has. + * + * A `core.hooksPath` in the repo's config is reported as such rather than followed, and global or + * included config is not read, so a hooks path set there reads as `absent`. A `.git` file (a repo + * registered from a linked worktree or submodule) needs Git to resolve, so it yields no facts. + */ +export function probeCreateEventRepoFacts( + repoPath: string, + platform: NodeJS.Platform = process.platform +): Promise { + return withTimeout(readFacts(repoPath, platform), PROBE_TIMEOUT_MS, { + postCheckoutHook: 'unknown' + }) +} + +async function readFacts( + repoPath: string, + platform: NodeJS.Platform +): Promise { + const gitDir = path.join(repoPath, '.git') + const isGitDirectory = await stat(gitDir).then( + (entry) => entry.isDirectory(), + () => false + ) + if (!isGitDirectory) { + return { postCheckoutHook: 'unknown' } + } + // `git sparse-checkout --sparse-index` and per-worktree settings write to config.worktree. + const [mainConfig, worktreeConfig] = await Promise.all([ + readFile(path.join(gitDir, 'config'), 'utf8').catch(() => null), + readFile(path.join(gitDir, 'config.worktree'), 'utf8').catch(() => '') + ]) + const config = mainConfig === null ? null : `${mainConfig}\n${worktreeConfig}` + const [postCheckoutHook, indexEntryCount, worktreeCount] = await Promise.all([ + readPostCheckoutHook(gitDir, config, platform), + readIndexEntryCount(gitDir, config), + withTimeout(readWorktreeCount(gitDir), WORKTREE_COUNT_TIMEOUT_MS, undefined) + ]) + return { + postCheckoutHook, + ...(indexEntryCount !== undefined ? { indexEntryCount } : {}), + ...(worktreeCount !== undefined ? { worktreeCount } : {}) + } +} + +/** + * The worktree count from Git's registry of linked worktrees, since the create itself no longer + * lists them. Like the listing, an entry whose lock reason names an Orca preparation is a prepared + * checkout, not the user's worktree, whichever process or crash left it. + */ +async function readWorktreeCount(gitDir: string): Promise { + const registry = path.join(gitDir, 'worktrees') + try { + const entries = await readdir(registry, { withFileTypes: true }) + const preparations = await Promise.all( + entries + .filter((entry) => entry.isDirectory()) + .map((entry) => + readFile(path.join(registry, entry.name, 'locked'), 'utf8').then( + (reason) => parseWorktreePreparationOwnerPid(reason.trim()) !== null, + () => false + ) + ) + ) + return preparations.filter((isPreparation) => !isPreparation).length + 1 + } catch (error) { + return getErrorCode(error) === 'ENOENT' ? 1 : undefined + } +} + +/** `core.sparseCheckout` or `index.sparse` set to anything Git reads as true, bare key included. */ +const SPARSE_CHECKOUT_ON = /^\s*sparse(checkout)?\s*(=(?!\s*(false|no|off|0)\s*$).*)?$/im + +/** + * The entry count from the index header (`DIRC`, version, big-endian count), which is the same in + * every index version. A split index keeps entries elsewhere and a sparse index collapses them into + * directories, so with either possible (any sparse checkout) the count is left out. + */ +async function readIndexEntryCount( + gitDir: string, + config: string | null +): Promise { + if (config === null || SPARSE_CHECKOUT_ON.test(config)) { + return undefined + } + try { + const entries = await readdir(gitDir) + if (entries.some((name) => name.startsWith('sharedindex.'))) { + return undefined + } + const index = await open(path.join(gitDir, 'index'), 'r') + try { + const header = Buffer.alloc(12) + const { bytesRead } = await index.read(header, 0, 12, 0) + if (bytesRead < 12 || header.toString('latin1', 0, 4) !== 'DIRC') { + return undefined + } + return header.readUInt32BE(8) + } finally { + await index.close() + } + } catch { + return undefined + } +} + +async function readPostCheckoutHook( + gitDir: string, + config: string | null, + platform: NodeJS.Platform +): Promise { + if (config === null) { + return 'unknown' + } + try { + if (/^\s*hookspath\s*=/im.test(config)) { + return 'custom_hooks_path' + } + const hook = await stat(path.join(gitDir, 'hooks', 'post-checkout')).catch((error) => { + if (getErrorCode(error) === 'ENOENT') { + return null + } + throw error + }) + if (!hook || !hook.isFile()) { + return 'absent' + } + // Git skips a hook without an execute bit, except on Windows where it has none to check. + return platform === 'win32' || (hook.mode & 0o111) !== 0 ? 'present' : 'absent' + } catch { + return 'unknown' + } +} diff --git a/src/main/git/worktree-create-preparation.ts b/src/main/git/worktree-create-preparation.ts index cb51e1ca8ae..2f18727b9e8 100644 --- a/src/main/git/worktree-create-preparation.ts +++ b/src/main/git/worktree-create-preparation.ts @@ -125,6 +125,11 @@ export async function unlockPreparedWorktree( } } +export type FinalizedPreparedWorktree = AddWorktreeResult & { + /** The prepared checkout was not already at the requested commit, so it was reset onto it. */ + preparedHeadReset: boolean +} + export async function finalizePreparedWorktree( repoPath: string, preparedPath: string, @@ -134,7 +139,7 @@ export async function finalizePreparedWorktree( refreshLocalBaseRef = false, options: AddWorktreeOptions = {}, expectedLockReason: string -): Promise { +): Promise { const finalizeGitOptions: AddWorktreeOptions = { ...options, timeout: options.timeout ?? resolveWorktreeAddTimeoutMs() @@ -181,8 +186,8 @@ export async function finalizePreparedWorktree( await baseContext.pendingLocalBaseRefRefresh throw preparedResult.reason } - const preparedHeadOutput = preparedResult.value.stdout - if (preparedHeadOutput.trim() !== targetHead) { + const preparedHeadReset = preparedResult.value.stdout.trim() !== targetHead + if (preparedHeadReset) { await verifyOwnership() await gitExecFileAsync( [...windowsLongPathGitArgs(preparedPath), 'reset', '--hard', targetHead], @@ -255,6 +260,7 @@ export async function finalizePreparedWorktree( // Why: the refresh overlapped the finalize above; it has no bearing on the checkout's content. const localBaseRefRefresh = await baseContext.pendingLocalBaseRefRefresh return { + preparedHeadReset, ...(localBaseRefRefresh ? { localBaseRefRefresh } : {}), ...(baseContext.localBaseRefUpdateSuggestion ? { localBaseRefUpdateSuggestion: baseContext.localBaseRefUpdateSuggestion } diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 36e25a018b6..a505cfe3da2 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -148,7 +148,11 @@ import { } from '../../shared/setup-runner-command' import { createSequencedSetupAgentCommands } from '../../shared/setup-agent-sequencing' import { shouldWaitForSetupBeforeAgentStartup } from '../../shared/setup-agent-startup-policy' -import { createWorktreeCreateTimingRecorder } from '../worktree-create-timing' +import { + createWorktreeCreateTimingRecorder, + localWorktreeCreateExecutionHost, + type WorktreeCreateTimingRecorder +} from '../worktree-create-timing' import { getLocalProjectGitExecOptions, getLocalProjectWorktreeGitOptions, @@ -1717,9 +1721,10 @@ export async function createRemoteWorktree( args: CreateWorktreeArgsWithSystemProvenance, repo: Repo, store: Store, - mainWindow: BrowserWindow + mainWindow: BrowserWindow, + timing: WorktreeCreateTimingRecorder = createWorktreeCreateTimingRecorder() ): Promise { - const timing = createWorktreeCreateTimingRecorder() + timing.recordExecutionHost('ssh') const provider = requireSshGitProvider(repo.connectionId!) const fsProvider = getSshFilesystemProvider(repo.connectionId!) @@ -1976,7 +1981,8 @@ export async function createRemoteWorktree( ) { // Why: only OLD relays (pre-allowlist-removal) throw these; surface an upgrade message. Remove after version floor moves (docs/relay-fs-allowlist-removal.md). throw new Error( - `Older relay reported an authorization error; please reconnect to deploy the latest relay. (${err.message})` + `Older relay reported an authorization error; please reconnect to deploy the latest relay. (${err.message})`, + { cause: err } ) } throw err @@ -2030,6 +2036,7 @@ export async function createRemoteWorktree( const gitWorktrees = await timing.time('list_created_worktree', async () => provider.listWorktrees(repo.path) ) + timing.recordWorktreeCount(gitWorktrees.length) // Match the exact requested path first, then the exact branch ref. Suffix matching can // select an older `prefix/` worktree when the newly created row is present. const created = findCreatedWorktree(gitWorktrees, remotePath, branchName) @@ -2190,15 +2197,18 @@ export function createLocalWorktree( repo: Repo, store: Store, mainWindow: BrowserWindow, - runtime?: OrcaRuntimeService + runtime?: OrcaRuntimeService, + timing: WorktreeCreateTimingRecorder = createWorktreeCreateTimingRecorder() ): Promise { // Why a holder fired in `finally`: consuming a prepared checkout leaves the pool one short, so a // create that fails after that point — include copy, push target, terminal startup — must still // arm the replacement. Fires exactly once, after startup on the success path. const rearm: PreparationRearmHolder = { fire: () => {} } return worktreeCreateGit - .run(() => performLocalWorktreeCreate(args, repo, store, mainWindow, rearm, runtime)) + .run(() => performLocalWorktreeCreate(args, repo, store, mainWindow, rearm, timing, runtime)) .finally(() => { + // Closes the create's measured window first, so its own re-arm is not counted against it. + timing.finish() rearm.fire() }) } @@ -2209,9 +2219,9 @@ async function performLocalWorktreeCreate( store: Store, mainWindow: BrowserWindow, rearm: PreparationRearmHolder, + timing: WorktreeCreateTimingRecorder, runtime?: OrcaRuntimeService ): Promise { - const timing = createWorktreeCreateTimingRecorder() const settings = store.getSettings() const worktreePathSettings = getWorktreePathSettings( repo, @@ -2220,6 +2230,7 @@ async function performLocalWorktreeCreate( ) const localGitExecOptions = getLocalProjectGitExecOptions(store, repo) const localWorktreeGitOptions = getLocalProjectWorktreeGitOptions(store, repo) + timing.recordExecutionHost(localWorktreeCreateExecutionHost(localGitExecOptions)) const hasLocalWorktreeGitOptions = Object.keys(localWorktreeGitOptions).length > 0 const localWorktreeGitOptionArgs: [] | [{ wslDistro?: string }] = hasLocalWorktreeGitOptions ? [localWorktreeGitOptions] @@ -2628,11 +2639,6 @@ async function performLocalWorktreeCreate( options: preparedWorktreeOptions, timing }) - timing.recordPreparedCheckout( - prepared.status === 'hit' - ? { status: 'hit', retargeted: prepared.retargeted } - : { status: 'miss', reason: prepared.reason } - ) if (prepared.status === 'hit') { // Why deferred: re-arming is a full `reset --hard`; started here it would hold a // general admission slot for the rest of this create's own git. @@ -2750,6 +2756,9 @@ async function performLocalWorktreeCreate( } = await timing.time('list_created_worktree', async () => resolveCreatedWorktree(repo.path, worktreePath, branchName, localWorktreeGitOptions) ) + if (listingComplete) { + timing.recordWorktreeCount(gitWorktrees.length) + } const worktreeId = `${repo.id}::${created.path}` const now = Date.now() diff --git a/src/main/ipc/worktrees-create-event-timing.test.ts b/src/main/ipc/worktrees-create-event-timing.test.ts new file mode 100644 index 00000000000..4a40bd43b81 --- /dev/null +++ b/src/main/ipc/worktrees-create-event-timing.test.ts @@ -0,0 +1,359 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + addWorktreeMock, + getActiveMultiplexerMock, + getSshGitProviderMock, + gitExecFileAsyncMock, + listWorktreesMock, + setPlatform +} from './worktrees-test-module-mocks' +import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' +import { beginPreparationWork } from '../worktree-create-concurrency' + +const { trackMock, probeHookMock, telemetryEnabledMock } = vi.hoisted(() => ({ + trackMock: vi.fn<(name: string, props: Record) => void>(), + probeHookMock: vi.fn(), + telemetryEnabledMock: vi.fn(() => true) +})) + +vi.mock('electron', async () => + (await import('./worktrees-test-module-mocks')).electronModuleMock() +) +vi.mock('../git/worktree', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() +) +vi.mock('../git/runner', async () => + (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() +) +vi.mock('../git/repo', async () => + (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() +) +vi.mock('../git/git-username', async (importOriginal) => ({ + ...(await importOriginal>()), + resolveLocalGitUsername: (await import('./worktrees-test-module-mocks')) + .resolveLocalGitUsernameMock +})) +vi.mock('../github/client', async () => + (await import('./worktrees-test-module-mocks')).githubClientModuleMock() +) +vi.mock('../source-control/hosted-review', async () => + (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() +) +vi.mock('../providers/ssh-git-dispatch', async () => + (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() +) +vi.mock('../providers/ssh-filesystem-dispatch', async () => + (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() +) +vi.mock('./worktree-symlinks', async () => + (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() +) +vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) +vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) +vi.mock('../setup-runner-script-text', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( + await importOriginal>() + ) +) +vi.mock('../worktree-runner-script', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock( + await importOriginal>() + ) +) +vi.mock('../effective-hook-config', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock( + await importOriginal>() + ) +) +vi.mock('../setup-hook-env-vars', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock( + await importOriginal>() + ) +) +vi.mock('./worktree-logic', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock( + await importOriginal>() + ) +) +vi.mock('../terminal-history-deletion', async () => + (await import('./worktrees-test-module-mocks')).terminalHistoryDeletionModuleMock() +) +vi.mock('../ports/advertised-url-watcher', async () => + (await import('./worktrees-test-module-mocks')).advertisedUrlWatcherModuleMock() +) +vi.mock('../workspace-cleanup-scan-snapshot', async () => + (await import('./worktrees-test-module-mocks')).workspaceCleanupScanSnapshotModuleMock() +) +vi.mock('../workspace-space-analysis-snapshot', async () => + (await import('./worktrees-test-module-mocks')).workspaceSpaceAnalysisSnapshotModuleMock() +) +vi.mock('../workspace-cleanup-removal-snapshot-prune', async () => + (await import('./worktrees-test-module-mocks')).workspaceCleanupRemovalSnapshotPruneModuleMock() +) +vi.mock('../runtime/worktree-teardown', async () => + (await import('./worktrees-test-module-mocks')).worktreeTeardownModuleMock() +) +vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock()) + +vi.mock('../telemetry/client', () => ({ + track: trackMock, + isTelemetryEnabled: telemetryEnabledMock +})) +vi.mock('../git/create-event-repo-probe', () => ({ + probeCreateEventRepoFacts: probeHookMock +})) + +function makeRepo(fields: Record) { + return { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + worktreeBaseRef: 'origin/main', + ...fields + } +} + +function useRepo(repo: ReturnType): void { + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + store.setWorktreeMeta.mockImplementation((_worktreeId: string, meta: unknown) => meta) + getActiveMultiplexerMock.mockReturnValue({ + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + }) +} + +function useLocalListing(): void { + listWorktreesMock.mockResolvedValue([ + { path: '/workspace/repo', head: 'abc', branch: 'main', isBare: false, isMainWorktree: true }, + { path: '/workspace/wt', head: 'abc123', branch: 'wt', isBare: false, isMainWorktree: false } + ]) +} + +function useSshProvider() { + useRepo(makeRepo({ path: '/remote/repo', executionHostId: 'ssh:target-a' })) + const provider = { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'show-ref') { + throw Object.assign(new Error('missing exact ref'), { code: 1 }) + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/repo-wt', + head: 'abc', + branch: 'refs/heads/wt', + isBare: false, + isMainWorktree: false + } + ]) + } + getSshGitProviderMock.mockImplementation((connectionId: string) => + connectionId === 'target-a' ? provider : undefined + ) + return provider +} + +function trackedEvents(name: string): Record[] { + return trackMock.mock.calls.filter(([eventName]) => eventName === name).map(([, props]) => props) +} + +function trackedEvent(name: string): Record | undefined { + const call = trackMock.mock.calls.find(([eventName]) => eventName === name) + return call?.[1] +} + +function gitWorkCallCount(): number { + return ( + gitExecFileAsyncMock.mock.calls.length + + addWorktreeMock.mock.calls.length + + listWorktreesMock.mock.calls.length + ) +} + +describe('worktrees:create event timing fields', () => { + beforeEach(() => { + setupWorktreeHandlers() + trackMock.mockReset() + probeHookMock.mockReset() + telemetryEnabledMock.mockReset() + telemetryEnabledMock.mockReturnValue(true) + }) + + it('sends timing after the create returns, without any further git work', async () => { + useRepo(makeRepo({})) + useLocalListing() + let resolveProbe: (value: { + postCheckoutHook: string + indexEntryCount?: number + }) => void = () => {} + probeHookMock.mockReturnValue( + new Promise((resolve) => { + resolveProbe = resolve + }) + ) + + await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) + + // The create already answered; the hook probe has not, so the event is not sent yet. + expect(trackedEvent('workspace_created')).toBeUndefined() + const gitCallsAtReturn = gitWorkCallCount() + + resolveProbe({ postCheckoutHook: 'present', indexEntryCount: 6_000 }) + await vi.waitFor(() => expect(trackedEvent('workspace_created')).toBeDefined()) + + expect(gitWorkCallCount()).toBe(gitCallsAtReturn) + expect(probeHookMock).toHaveBeenCalledWith('/workspace/repo') + const props = trackedEvent('workspace_created') + expect(props).toMatchObject({ + source: 'unknown', + from_existing_branch: false, + create_entry_point: 'app', + execution_host: 'local', + worktree_count_bucket: '2-5', + concurrent_creates: 0, + concurrent_preparations: 0, + repo_file_count_bucket: '1k-10k', + post_checkout_hook: 'present' + }) + // One create, one event: the runtime entry point never runs for an app create. + expect(trackedEvents('workspace_created')).toHaveLength(1) + expect(trackedEvents('workspace_create_failed')).toHaveLength(0) + expect(typeof props?.total_ms).toBe('number') + expect(typeof props?.git_worktree_add_ms).toBe('number') + expect(props).toHaveProperty('prepared_checkout') + // Nothing that names the repo, the branch or a path rides along. + expect(JSON.stringify(props)).not.toMatch(/workspace|wt|repo-1/) + }) + + it('counts prepared-checkout work that ran alongside the create', async () => { + useRepo(makeRepo({})) + useLocalListing() + probeHookMock.mockResolvedValue({ postCheckoutHook: 'absent' }) + const build = beginPreparationWork() + + await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) + build.end() + await vi.waitFor(() => expect(trackedEvent('workspace_created')).toBeDefined()) + + expect(trackedEvent('workspace_created')).toMatchObject({ concurrent_preparations: 1 }) + }) + + it('still answers the create, or its error, when sending the event throws', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + useRepo(makeRepo({})) + useLocalListing() + probeHookMock.mockResolvedValue({ postCheckoutHook: 'absent' }) + trackMock.mockImplementation(() => { + throw new Error('telemetry broke') + }) + + await expect( + handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) + ).resolves.toHaveProperty('worktree') + + addWorktreeMock.mockRejectedValue(new Error('fatal: could not create work tree dir')) + await expect( + handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) + ).rejects.toThrow('could not create work tree dir') + }) + + it('does not read the repo for hooks when telemetry is off', async () => { + useRepo(makeRepo({})) + useLocalListing() + telemetryEnabledMock.mockReturnValue(false) + + await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) + await vi.waitFor(() => expect(trackedEvent('workspace_created')).toBeDefined()) + + expect(probeHookMock).not.toHaveBeenCalled() + expect(trackedEvent('workspace_created')).not.toHaveProperty('post_checkout_hook') + }) + + it.each([ + { repoPath: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo', host: 'wsl' }, + { repoPath: '/workspace/repo', host: 'local' } + ])( + 'labels $repoPath as $host from where Git runs, with no WSL project runtime', + async ({ repoPath, host }) => { + setPlatform('win32') + useRepo(makeRepo({ path: repoPath })) + useLocalListing() + probeHookMock.mockResolvedValue({ postCheckoutHook: 'absent' }) + + await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) + await vi.waitFor(() => expect(trackedEvent('workspace_created')).toBeDefined()) + + expect(trackedEvent('workspace_created')).toMatchObject({ execution_host: host }) + } + ) + + it('records an SSH create without probing the remote for hooks', async () => { + useSshProvider() + + await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) + await vi.waitFor(() => expect(trackedEvent('workspace_created')).toBeDefined()) + + expect(probeHookMock).not.toHaveBeenCalled() + const props = trackedEvent('workspace_created') + expect(props).toMatchObject({ execution_host: 'ssh', worktree_count_bucket: '1' }) + expect(props).not.toHaveProperty('post_checkout_hook') + expect(props).not.toHaveProperty('prepared_checkout') + }) + + it('attributes an old-relay SSH add error to the add through its cause', async () => { + const provider = useSshProvider() + provider.addWorktree.mockRejectedValue(new Error('Path outside authorized workspace: /x')) + + let caught: unknown + try { + await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) + } catch (error) { + caught = error + } + + expect(caught).toBeInstanceOf(Error) + expect(caught instanceof Error && caught.message).toMatch(/^Older relay reported/) + expect(caught instanceof Error && caught.cause).toBeInstanceOf(Error) + expect(trackedEvent('workspace_create_failed')).toMatchObject({ + failed_phase: 'git_worktree_add', + execution_host: 'ssh' + }) + }) + + it('names the phase a failed create died in', async () => { + useRepo(makeRepo({})) + useLocalListing() + addWorktreeMock.mockRejectedValue(new Error('fatal: could not create work tree dir')) + + await expect( + handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) + ).rejects.toThrow() + + const props = trackedEvent('workspace_create_failed') + expect(props).toMatchObject({ + failed_phase: 'git_worktree_add', + create_entry_point: 'app', + execution_host: 'local', + concurrent_creates: 0, + concurrent_preparations: 0, + prepared_checkout: 'miss', + prepared_checkout_miss_reason: 'none_armed' + }) + expect(trackedEvents('workspace_create_failed')).toHaveLength(1) + expect(trackedEvents('workspace_created')).toHaveLength(0) + expect(typeof props?.total_ms).toBe('number') + expect(JSON.stringify(props)).not.toMatch(/fatal|work tree/) + }) +}) diff --git a/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts b/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts index 1ace3deb0b9..e8e03c0706a 100644 --- a/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts +++ b/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts @@ -32,6 +32,7 @@ import { createFolderWorkspace } from './folder-workspace-creation' import { findExactRepoOwner, isCapturedRepoCurrent } from '../listing/worktree-host-ownership' import { requireWorktreeCreateRoute } from '../../../worktree-create-execution-host-route' import type { WorktreeIpcContext } from '../worktree-ipc-context' +import { beginWorkspaceCreateTelemetry } from '../../../workspace-create-telemetry' export function registerWorktreeCreateHandlers(context: WorktreeIpcContext): void { const { mainWindow, store, runtime, options } = context @@ -47,9 +48,6 @@ export function registerWorktreeCreateHandlers(context: WorktreeIpcContext): voi throw new Error(`Repo not found: ${args.repoId}`) } - const sourceParse = workspaceSourceSchema.safeParse(args.telemetrySource) - const source: WorkspaceSource = sourceParse.success ? sourceParse.data : 'unknown' - const automationProvenance = resolveAutomationWorkspaceProvenance({ authority: runtime, repoSelector: args.repoId, @@ -62,6 +60,14 @@ export function registerWorktreeCreateHandlers(context: WorktreeIpcContext): voi } let result: CreateWorktreeResult + // The handler owns the events so a failed create can still say where it died. + const telemetry = beginWorkspaceCreateTelemetry({ + source: args.telemetrySource, + entryPoint: 'app', + repoPath: repo.path, + fromExistingBranch: typeof args.baseBranch === 'string' && args.baseBranch.length > 0, + isFolder: isFolderRepo(repo) + }) try { // Why: wrap only the helpers; the pre-validation throws above are IPC-shape bugs, not the git/filesystem failures the funnel tracks. if (isFolderRepo(repo)) { @@ -74,16 +80,25 @@ export function registerWorktreeCreateHandlers(context: WorktreeIpcContext): voi const createRoute = requireWorktreeCreateRoute(repo) result = createRoute.kind === 'ssh' - ? await createRemoteWorktree(createArgs, createRoute.repo, store, mainWindow) - : await createLocalWorktree(createArgs, repo, store, mainWindow, runtime) + ? await createRemoteWorktree( + createArgs, + createRoute.repo, + store, + mainWindow, + telemetry.timing + ) + : await createLocalWorktree( + createArgs, + repo, + store, + mainWindow, + runtime, + telemetry.timing + ) } } catch (error) { releaseAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest) - track('workspace_create_failed', { - source, - error_class: classifyWorkspaceCreateError(error), - ...getCohortAtEmit() - }) + telemetry.failed(error) throw error } finishAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest) @@ -92,14 +107,7 @@ export function registerWorktreeCreateHandlers(context: WorktreeIpcContext): voi } // Why: reaching here means create succeeded (helpers throw); skip a separate workspace_initialized (telemetry-plan.md§Deferred); never send the branch name. - track('workspace_created', { - source, - from_existing_branch: - !isFolderRepo(repo) && - typeof args.baseBranch === 'string' && - args.baseBranch.length > 0, - ...getCohortAtEmit() - }) + telemetry.succeeded(result.timing) if (isFolderRepo(repo)) { notifyWorktreesChanged(mainWindow, repo.id) diff --git a/src/main/observability/instrumentation.test.ts b/src/main/observability/instrumentation.test.ts index 139bb2494dd..a312bb9307d 100644 --- a/src/main/observability/instrumentation.test.ts +++ b/src/main/observability/instrumentation.test.ts @@ -219,16 +219,23 @@ describe('addWorktreeCreatePhaseAttributes', () => { expect(attributes['worktree.create.unattributed_ms']).toBe(200) }) - it('records a prepared-checkout hit and whether it had to be retargeted', () => { + it('records a prepared-checkout hit, the reset it needed and who armed it', () => { const { attributes, span } = capture() addWorktreeCreatePhaseAttributes(span, { totalDurationMs: 900, phases: [{ phase: 'git_worktree_add', startedAtMs: 0, durationMs: 400 }], - preparedCheckout: { status: 'hit', retargeted: true } + preparedCheckout: { + status: 'hit', + reset: 'retargeted', + origin: 'rearm', + buildMs: 1, + idleMs: 0 + } }) expect(attributes['worktree.create.prepared_checkout']).toBe('hit') - expect(attributes['worktree.create.prepared_checkout_retargeted']).toBe(true) + expect(attributes['worktree.create.prepared_checkout_reset']).toBe('retargeted') + expect(attributes['worktree.create.prepared_checkout_origin']).toBe('rearm') expect(attributes['worktree.create.prepared_checkout_miss']).toBeUndefined() expect(attributes['worktree.create.unattributed_ms']).toBe(500) }) @@ -243,7 +250,7 @@ describe('addWorktreeCreatePhaseAttributes', () => { expect(attributes['worktree.create.prepared_checkout']).toBe('miss') expect(attributes['worktree.create.prepared_checkout_miss']).toBe('base_mismatch') - expect(attributes['worktree.create.prepared_checkout_retargeted']).toBeUndefined() + expect(attributes['worktree.create.prepared_checkout_reset']).toBeUndefined() }) it('stays silent on paths that never consult the prepared checkout', () => { diff --git a/src/main/observability/instrumentation.ts b/src/main/observability/instrumentation.ts index bba248cd663..a010f6e95c7 100644 --- a/src/main/observability/instrumentation.ts +++ b/src/main/observability/instrumentation.ts @@ -270,11 +270,9 @@ export function addWorktreeCreatePhaseAttributes( if (timing.preparedCheckout) { span.setAttribute('worktree.create.prepared_checkout', timing.preparedCheckout.status) if (timing.preparedCheckout.status === 'hit') { - // A retargeted hit still pays a reset, so it must not be read as a free hit. - span.setAttribute( - 'worktree.create.prepared_checkout_retargeted', - timing.preparedCheckout.retargeted - ) + // A hit that had to reset still paid for it, so it must not be read as a free hit. + span.setAttribute('worktree.create.prepared_checkout_reset', timing.preparedCheckout.reset) + span.setAttribute('worktree.create.prepared_checkout_origin', timing.preparedCheckout.origin) } else { span.setAttribute('worktree.create.prepared_checkout_miss', timing.preparedCheckout.reason) } @@ -284,10 +282,15 @@ export function addWorktreeCreatePhaseAttributes( } // What the phases do not cover is the number that matters when create feels slow for no visible // reason, so name it rather than leaving it to subtraction. - span.setAttribute( - 'worktree.create.unattributed_ms', - Math.max(0, Math.round(timing.totalDurationMs - measuredWallClockMs(timing.phases))) - ) + span.setAttribute('worktree.create.unattributed_ms', worktreeCreateUnattributedMs(timing)) +} + +/** Wall-clock time of a create that no timed phase covers; overlapping phases count once. */ +export function worktreeCreateUnattributedMs(timing: { + totalDurationMs: number + phases: readonly WorktreePhaseInterval[] +}): number { + return Math.max(0, Math.round(timing.totalDurationMs - measuredWallClockMs(timing.phases))) } /** Closed set so a typo can't silently mint an orphan span name. */ diff --git a/src/main/runtime/managed-worktree-create-telemetry.test.ts b/src/main/runtime/managed-worktree-create-telemetry.test.ts new file mode 100644 index 00000000000..d790a317ea7 --- /dev/null +++ b/src/main/runtime/managed-worktree-create-telemetry.test.ts @@ -0,0 +1,335 @@ +// Runtime creates (CLI, agents, phone, paired clients, orchestration) reuse prepared checkouts +// like the app's own creates, so they must send the same create events, exactly once each. +import { readFileSync, readdirSync } from 'node:fs' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp'), isPackaged: false } +})) + +const mocks = vi.hoisted(() => ({ + track: vi.fn<(name: string, props: Record) => void>(), + probe: vi.fn(), + createLocal: vi.fn(), + createFolder: vi.fn(), + startTerminals: vi.fn() +})) + +vi.mock('../telemetry/client', () => ({ track: mocks.track, isTelemetryEnabled: () => true })) +vi.mock('../git/create-event-repo-probe', () => ({ probeCreateEventRepoFacts: mocks.probe })) +vi.mock('./runtime-local-worktree-create', () => ({ + createRuntimeLocalManagedWorktree: mocks.createLocal +})) +vi.mock('./runtime-folder-worktree-create', () => ({ + createRuntimeFolderWorktree: mocks.createFolder +})) +vi.mock('./runtime-local-worktree-terminal-startup', () => ({ + startRuntimeLocalWorktreeTerminals: mocks.startTerminals +})) +vi.mock('./runtime-local-worktree-setup', () => ({ + prepareRuntimeLocalWorktreeSetup: vi.fn(async () => ({ + effectiveDecision: 'skip', + hookFound: false, + shouldRunSetup: false, + didStartInProcessSetupHook: false + })) +})) +vi.mock('../ipc/filesystem-auth', () => ({ + invalidateAuthorizedRootsCache: vi.fn(), + invalidateAuthorizedRootsCacheForRepo: vi.fn() +})) + +import { OrcaRuntimeService } from './orca-runtime' +import type { WorktreeCreateTimingRecorder } from '../worktree-create-timing' +import type { PreparationRearmHolder } from '../worktree-create-preparation' +import { + _resetWorktreeCreateConcurrencyForTests, + beginPreparationWork, + type PreparationWork +} from '../worktree-create-concurrency' + +const gitRepo = { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: 'blue', + kind: 'git' +} +const worktree = { id: 'wt-1', path: '/worktrees/app', branch: 'app', repoId: gitRepo.id } + +type RuntimeInternals = { + resolveRepoSelector: (selector: string) => Promise + resolveLineageForWorktreeCreate: (input: unknown) => Promise + recordCreatedWorktreeLineage: (created: unknown, resolution: unknown) => unknown + getLocalGitExecutionOptionArgs: (repo: unknown) => unknown[] + getHostedReviewExecutionOptions: (repo: unknown) => unknown + invalidateResolvedWorktreeCache: () => void + invalidateWorktreeScanCacheForRepo: (repoId: string) => void + notifyWorktreesChanged: (repoId: string) => void + emitWorktreeLifecycle: (event: unknown) => void +} + +function makeRuntime(repo: Record = gitRepo): OrcaRuntimeService { + const store = { + getSettings: () => ({ disabledTuiAgents: ['codex'], workspaceDir: '/worktrees' }), + getProjectHostSetups: () => [] + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every store method this create path reaches is supplied above. + const runtime = new OrcaRuntimeService(store as never) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the named members all exist on the service; the cast only exposes non-public ones to the spies. + const internals = runtime as unknown as RuntimeInternals + vi.spyOn(internals, 'resolveRepoSelector').mockResolvedValue(repo) + vi.spyOn(internals, 'resolveLineageForWorktreeCreate').mockResolvedValue(null) + vi.spyOn(internals, 'recordCreatedWorktreeLineage').mockReturnValue({ + lineage: null, + workspaceLineage: null, + warnings: [] + }) + vi.spyOn(internals, 'getLocalGitExecutionOptionArgs').mockReturnValue([{}]) + vi.spyOn(internals, 'getHostedReviewExecutionOptions').mockReturnValue(undefined) + vi.spyOn(internals, 'invalidateResolvedWorktreeCache').mockReturnValue(undefined) + vi.spyOn(internals, 'invalidateWorktreeScanCacheForRepo').mockReturnValue(undefined) + vi.spyOn(internals, 'notifyWorktreesChanged').mockReturnValue(undefined) + vi.spyOn(internals, 'emitWorktreeLifecycle').mockReturnValue(undefined) + return runtime +} + +type LocalCreateArgs = { timing: WorktreeCreateTimingRecorder; rearm: PreparationRearmHolder } + +function events(name: string): Record[] { + return mocks.track.mock.calls.filter(([event]) => event === name).map(([, props]) => props) +} + +function localCreateResult() { + return { + worktree, + worktreePath: worktree.path, + created: { path: worktree.path, head: 'abc', branch: 'app' }, + addResult: {}, + metadataResult: { lineage: null, workspaceLineage: null, warnings: [] } + } +} + +describe('runtime create events', () => { + beforeEach(() => { + mocks.track.mockReset() + mocks.probe.mockReset().mockResolvedValue({ postCheckoutHook: 'absent', indexEntryCount: 2048 }) + mocks.createFolder.mockReset().mockResolvedValue({ worktree: { id: 'folder-1' } }) + mocks.startTerminals.mockReset().mockResolvedValue({}) + mocks.createLocal.mockReset().mockImplementation(async ({ timing }: LocalCreateArgs) => { + timing.recordExecutionHost('local') + await timing.time('git_worktree_add', async () => { + timing.recordPreparedCheckout({ + status: 'hit', + reset: 'none', + origin: 'prefetch', + buildMs: 9_000, + idleMs: 120 + }) + }) + return localCreateResult() + }) + }) + + afterEach(() => { + _resetWorktreeCreateConcurrencyForTests() + }) + + it('sends one workspace_created with the runtime entry point and the timing fields', async () => { + await makeRuntime().createManagedWorktree({ + repoSelector: 'repo-1', + name: 'app', + baseBranch: 'origin/main', + telemetrySource: 'sidebar' + }) + await vi.waitFor(() => expect(events('workspace_created')).toHaveLength(1)) + + expect(events('workspace_created')[0]).toMatchObject({ + source: 'sidebar', + from_existing_branch: true, + create_entry_point: 'runtime', + execution_host: 'local', + prepared_checkout: 'hit', + prepared_checkout_reset: 'none', + prepared_checkout_origin: 'prefetch', + concurrent_creates: 0, + concurrent_preparations: 0, + repo_file_count_bucket: '1k-10k', + post_checkout_hook: 'absent' + }) + expect(typeof events('workspace_created')[0].git_worktree_add_ms).toBe('number') + expect(mocks.probe).toHaveBeenCalledWith('/repo') + expect(events('workspace_create_failed')).toHaveLength(0) + }) + + it('sends one workspace_create_failed naming the phase the create died in', async () => { + mocks.createLocal.mockImplementation(async ({ timing }: LocalCreateArgs) => + timing.time('git_worktree_add', async () => { + throw new Error('fatal: could not create work tree dir /worktrees/app') + }) + ) + + await expect( + makeRuntime().createManagedWorktree({ repoSelector: 'repo-1', name: 'app' }) + ).rejects.toThrow('could not create work tree') + + expect(events('workspace_create_failed')).toEqual([ + expect.objectContaining({ + source: 'unknown', + failed_phase: 'git_worktree_add', + create_entry_point: 'runtime' + }) + ]) + expect(JSON.stringify(events('workspace_create_failed'))).not.toMatch(/worktrees|fatal/) + expect(events('workspace_created')).toHaveLength(0) + }) + + it('counts preparation work running during the create but not the re-arm the create starts', async () => { + let competing: PreparationWork | undefined + mocks.createLocal.mockImplementation(async ({ rearm }: LocalCreateArgs) => { + competing = beginPreparationWork() + // The re-arm starts its build synchronously when fired, after the create's last step. + rearm.fire = () => { + beginPreparationWork() + beginPreparationWork() + } + return localCreateResult() + }) + + await makeRuntime().createManagedWorktree({ repoSelector: 'repo-1', name: 'app' }) + competing?.end() + await vi.waitFor(() => expect(events('workspace_created')).toHaveLength(1)) + + expect(events('workspace_created')[0]).toMatchObject({ concurrent_preparations: 1 }) + }) + + it('still returns the create when sending its event throws', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + mocks.track.mockImplementation(() => { + throw new Error('telemetry broke') + }) + + await expect( + makeRuntime().createManagedWorktree({ repoSelector: 'repo-1', name: 'app' }) + ).resolves.toMatchObject({ worktree: { id: 'wt-1' } }) + await vi.waitFor(() => expect(mocks.track).toHaveBeenCalledOnce()) + }) + + it('still rejects with the create error when sending the failure event throws', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + mocks.track.mockImplementation(() => { + throw new Error('telemetry broke') + }) + mocks.createLocal.mockRejectedValue(new Error('fatal: could not create work tree')) + + await expect( + makeRuntime().createManagedWorktree({ repoSelector: 'repo-1', name: 'app' }) + ).rejects.toThrow('could not create work tree') + expect(mocks.track).toHaveBeenCalledOnce() + }) + + it('carries the SSH create timing the runtime hands to the remote create', async () => { + const runtime = makeRuntime({ + id: 'repo-remote', + path: '/srv/app', + kind: 'git', + executionHostId: 'ssh:remote-1' + }) + const remoteCreate = vi.fn( + async (_repo: unknown, args: { timing?: WorktreeCreateTimingRecorder }) => { + // Stands in for createRemoteWorktree, which records into the recorder it is given. + args.timing?.recordExecutionHost('ssh') + await args.timing?.time('git_worktree_add', async () => {}) + return { worktree } + } + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: createManagedRemoteWorktree exists on the service; the cast only exposes the protected member to the spy. + const internals = runtime as unknown as { createManagedRemoteWorktree: typeof remoteCreate } + vi.spyOn(internals, 'createManagedRemoteWorktree').mockImplementation(remoteCreate) + + await runtime.createManagedWorktree({ repoSelector: 'repo-remote', name: 'app' }) + await vi.waitFor(() => expect(events('workspace_created')).toHaveLength(1)) + + expect(remoteCreate.mock.calls[0]?.[1].timing).toBeDefined() + expect(events('workspace_created')[0]).toMatchObject({ + create_entry_point: 'runtime', + execution_host: 'ssh' + }) + expect(typeof events('workspace_created')[0].git_worktree_add_ms).toBe('number') + // SSH would need a remote round trip, so the repo is not probed. + expect(mocks.probe).not.toHaveBeenCalled() + }) + + it('sends nothing for a folder workspace, as before', async () => { + await makeRuntime({ id: 'folder', path: '/notes', kind: 'folder' }).createManagedWorktree({ + repoSelector: 'folder', + name: 'notes' + }) + await new Promise((resolve) => setImmediate(resolve)) + expect(mocks.track).not.toHaveBeenCalled() + }) + + it('sends nothing when the request is rejected before any create starts', async () => { + await expect( + makeRuntime().createManagedWorktree({ + repoSelector: 'repo-1', + name: 'app', + startupAgent: 'codex' + }) + ).rejects.toThrow('Selected agent is disabled') + expect(mocks.createLocal).not.toHaveBeenCalled() + expect(mocks.track).not.toHaveBeenCalled() + }) +}) + +function sourceFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) { + return sourceFiles(full) + } + return entry.name.endsWith('.ts') && !/\.(test|spec)\.ts$/.test(entry.name) ? [full] : [] + }) +} + +describe('one event per create', () => { + const mainDir = path.resolve(__dirname, '..') + const relative = (file: string): string => path.relative(mainDir, file).split(path.sep).join('/') + + it('sends the create events only from the shared sender, besides the SSH root adopt', () => { + const senders = sourceFiles(mainDir) + .filter((file) => + /track\(\s*'workspace_(created|create_failed)'/.test(readFileSync(file, 'utf8')) + ) + .map(relative) + .sort() + expect(senders).toEqual([ + 'ipc/worktrees/create/register-worktree-create-handlers.ts', + 'workspace-create-telemetry.ts' + ]) + const handler = readFileSync( + path.join(mainDir, 'ipc/worktrees/create/register-worktree-create-handlers.ts'), + 'utf8' + ) + // The remaining direct sends there belong to the root adopt, which no other entry point runs. + expect(handler.match(/track\(\s*'workspace_(created|create_failed)'/g)).toHaveLength(2) + }) + + it('keeps the app and runtime entry points from running each other', () => { + const handler = readFileSync( + path.join(mainDir, 'ipc/worktrees/create/register-worktree-create-handlers.ts'), + 'utf8' + ) + expect(handler).not.toMatch(/createManagedWorktree/) + const runtimeImportsHandler = sourceFiles(path.join(mainDir, 'runtime')).filter((file) => + /register-worktree-create-handlers|ipcMain\.emit|['"]worktrees:create['"]/.test( + readFileSync(file, 'utf8') + ) + ) + expect(runtimeImportsHandler.map(relative)).toEqual([]) + }) +}) diff --git a/src/main/runtime/orca-runtime-create-managed-worktree.ts b/src/main/runtime/orca-runtime-create-managed-worktree.ts index ce2e4d1bc2f..63e3a010772 100644 --- a/src/main/runtime/orca-runtime-create-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-create-managed-worktree.ts @@ -12,6 +12,8 @@ import type { PreparationRearmHolder } from '../worktree-create-preparation' import { prepareRuntimeLocalWorktreeSetup } from './runtime-local-worktree-setup' import { invalidateAuthorizedRootsCacheForRepo } from '../ipc/filesystem-auth' import { startRuntimeLocalWorktreeTerminals } from './runtime-local-worktree-terminal-startup' +import { trackRuntimeWorkspaceCreate } from '../workspace-create-telemetry' +import type { RuntimeWorkspaceCreateEvents } from '../workspace-create-telemetry' export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWorktreeTerminalProvisioningHost { async createManagedWorktree( @@ -22,7 +24,9 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork // still arm the replacement. On success it fires last, once the startup terminals are up. const rearm: PreparationRearmHolder = { fire: () => {} } try { - return await this.performManagedWorktreeCreate(args, rearm) + return await trackRuntimeWorkspaceCreate(args, (events) => + this.performManagedWorktreeCreate(args, rearm, events) + ) } finally { rearm.fire() } @@ -30,7 +34,8 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork private async performManagedWorktreeCreate( args: RuntimeManagedWorktreeCreateArgs, - rearm: PreparationRearmHolder + rearm: PreparationRearmHolder, + events: RuntimeWorkspaceCreateEvents ): Promise { if (!this.store) { throw new Error('runtime_unavailable') @@ -124,6 +129,7 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork if (createRoute.kind === 'runtime') { throw new ExecutionHostNotDispatchableError(createRoute.hostId) } + const timing = events.begin(repo.path) if (createRoute.kind === 'ssh') { // `createRoute.repo` carries the resolved connection in `connectionId`, because the // remote-create pipeline still reads `repo.connectionId!` at every depth. See the workaround @@ -134,7 +140,8 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork ...(effectiveStartup ? { startup: effectiveStartup } : {}), ...(effectiveStartupFollowup ? { startupFollowup: effectiveStartupFollowup } : {}), ...(effectiveCreatedWithAgent ? { createdWithAgent: effectiveCreatedWithAgent } : {}), - ...(effectiveDraftPaste ? { startupDraftPaste: effectiveDraftPaste } : {}) + ...(effectiveDraftPaste ? { startupDraftPaste: effectiveDraftPaste } : {}), + timing }) const recordedLineage = this.recordCreatedWorktreeLineage(result.worktree, lineageResolution) this.emitWorktreeLifecycle({ @@ -178,7 +185,8 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork this.fetchRemoteWithCache(path, remote, ...options), onWorktreeMetadataPersisted: (persistedWorktree) => this.recordCreatedWorktreeLineage(persistedWorktree, lineageResolution), - rearm + rearm, + timing }) const settings = createSettings const { lineage, workspaceLineage, warnings: lineageWarnings } = metadataResult diff --git a/src/main/runtime/runtime-local-git-worktree-create.ts b/src/main/runtime/runtime-local-git-worktree-create.ts index ccefaa82255..353375999e7 100644 --- a/src/main/runtime/runtime-local-git-worktree-create.ts +++ b/src/main/runtime/runtime-local-git-worktree-create.ts @@ -24,6 +24,7 @@ import { failedWorktreeCreationNeedsRetirement, retireGeneratedWorktreeName } from '../worktree-name-retirement' +import type { WorktreeCreateTimingRecorder } from '../worktree-create-timing' export async function createRuntimeLocalGitWorktree(args: { request: RuntimeManagedWorktreeCreateArgs @@ -59,6 +60,7 @@ export async function createRuntimeLocalGitWorktree(args: { ) => Promise fetchRemote: (repoPath: string, remote: string, options?: LocalGitExecOptions) => Promise rearm: PreparationRearmHolder + timing: WorktreeCreateTimingRecorder }): Promise<{ remoteTrackingBase: RemoteTrackingBase | null sparseDirectories: string[] @@ -80,10 +82,9 @@ export async function createRuntimeLocalGitWorktree(args: { if (!hadRemoteRef && hasLocalBase) { remoteTrackingBase = null } else { - const refresh = await args.refreshRemoteTrackingBase( - args.repo.path, - remoteTrackingBase, - args.localWorktreeGitOptions + const base = remoteTrackingBase + const refresh = await args.timing.time('refresh_base_ref', () => + args.refreshRemoteTrackingBase(args.repo.path, base, args.localWorktreeGitOptions) ) if (!refresh.ok && !hadRemoteRef) { throw new Error( @@ -105,7 +106,9 @@ export async function createRuntimeLocalGitWorktree(args: { !(await hasLocalWorktreeBaseRef(args.repo.path, args.baseBranch, args.localWorktreeGitOptions)) ) { try { - await args.fetchRemote(args.repo.path, 'origin', args.localWorktreeGitOptions) + await args.timing.time('refresh_base_ref', () => + args.fetchRemote(args.repo.path, 'origin', args.localWorktreeGitOptions) + ) } catch {} } const sparseDirectories = args.request.sparseCheckout @@ -139,40 +142,47 @@ export async function createRuntimeLocalGitWorktree(args: { isGeneratedWorktreeCreateName(args.effectiveSanitizedName!) let addResult: AddWorktreeResult try { - const preparedAttempt = - sparseDirectories.length === 0 && !args.checkoutExistingBranch - ? await consumePreparedWorktreeCreate({ - repoPath: args.repo.path, - workspaceRoot: args.workspaceRoot, - worktreePath: args.worktreePath, - branch: args.branchName, - baseBranch: args.baseBranch, - refreshLocalBaseRef: args.settings.refreshLocalBaseRefOnWorktreeCreate, - options: preparedWorktreeOptions - }) - : null - // This path has no create-span recorder, so the miss reason is only observable on the IPC path. - if (preparedAttempt?.status === 'miss' && preparedAttempt.rearm) { - args.rearm.fire = preparedAttempt.rearm - } - if (preparedAttempt?.status === 'hit') { - addResult = preparedAttempt.result - // Deferred, not fired: re-arming is a full `reset --hard`, and the caller still has - // materialization probes and terminals ahead of it. - args.rearm.fire = preparedAttempt.rearm - } else if (sparseDirectories.length > 0) { - addResult = - (await addSparseWorktree( - args.repo.path, - args.worktreePath, - args.branchName, - sparseDirectories, - args.baseBranch, - args.settings.refreshLocalBaseRefOnWorktreeCreate, - addOptions - )) ?? {} - } else { - addResult = + addResult = await args.timing.time('git_worktree_add', async () => { + if (sparseDirectories.length > 0 || args.checkoutExistingBranch) { + args.timing.recordPreparedCheckout({ + status: 'miss', + reason: sparseDirectories.length > 0 ? 'sparse_checkout' : 'checkout_existing_branch' + }) + } else { + const preparedAttempt = await consumePreparedWorktreeCreate({ + repoPath: args.repo.path, + workspaceRoot: args.workspaceRoot, + worktreePath: args.worktreePath, + branch: args.branchName, + baseBranch: args.baseBranch, + refreshLocalBaseRef: args.settings.refreshLocalBaseRefOnWorktreeCreate, + options: preparedWorktreeOptions, + timing: args.timing + }) + if (preparedAttempt.status === 'hit') { + // Deferred, not fired: re-arming is a full `reset --hard`, and the caller still has + // materialization probes and terminals ahead of it. + args.rearm.fire = preparedAttempt.rearm + return preparedAttempt.result + } + if (preparedAttempt.rearm) { + args.rearm.fire = preparedAttempt.rearm + } + } + if (sparseDirectories.length > 0) { + return ( + (await addSparseWorktree( + args.repo.path, + args.worktreePath, + args.branchName, + sparseDirectories, + args.baseBranch, + args.settings.refreshLocalBaseRefOnWorktreeCreate, + addOptions + )) ?? {} + ) + } + return ( (await addWorktree( args.repo.path, args.worktreePath, @@ -182,7 +192,8 @@ export async function createRuntimeLocalGitWorktree(args: { false, addOptions )) ?? {} - } + ) + }) } catch (error) { if (shouldRetireGeneratedName && failedWorktreeCreationNeedsRetirement(error)) { await retireGeneratedWorktreeName( @@ -216,12 +227,19 @@ export async function createRuntimeLocalGitWorktree(args: { args.localWorktreeGitOptions ) : preparedPushTarget - const { created } = await resolveCreatedWorktree( - args.repo.path, - args.worktreePath, - args.branchName, - args.localWorktreeGitOptions + const { created, worktrees, listingComplete } = await args.timing.time( + 'list_created_worktree', + () => + resolveCreatedWorktree( + args.repo.path, + args.worktreePath, + args.branchName, + args.localWorktreeGitOptions + ) ) + if (listingComplete) { + args.timing.recordWorktreeCount(worktrees.length) + } return { remoteTrackingBase, sparseDirectories, diff --git a/src/main/runtime/runtime-local-worktree-create.test.ts b/src/main/runtime/runtime-local-worktree-create.test.ts index c673da99640..c89ff71e996 100644 --- a/src/main/runtime/runtime-local-worktree-create.test.ts +++ b/src/main/runtime/runtime-local-worktree-create.test.ts @@ -82,12 +82,17 @@ vi.mock('../ipc/worktree-symlinks', () => ({ import { createRuntimeLocalManagedWorktree } from './runtime-local-worktree-create' import type { PreparationRearmHolder } from '../worktree-create-preparation' +import { + createWorktreeCreateTimingRecorder, + type WorktreeCreateTimingRecorder +} from '../worktree-create-timing' const worktreePath = resolve('/worktrees', 'app') function createWorktree( request: Partial = {}, - rearm: PreparationRearmHolder = { fire: () => {} } + rearm: PreparationRearmHolder = { fire: () => {} }, + timing: WorktreeCreateTimingRecorder = createWorktreeCreateTimingRecorder() ) { const store = { getSettings: () => ({ @@ -109,7 +114,8 @@ function createWorktree( refreshRemoteTrackingBase: mocks.refresh, fetchRemote: mocks.fetch, onWorktreeMetadataPersisted: () => undefined, - rearm + rearm, + timing }) } @@ -216,6 +222,41 @@ describe('runtime prepared-worktree replenishment', () => { }) }) +describe('runtime create timing', () => { + it('times the add with the prepared-checkout consume inside it, and lists the worktrees', async () => { + mocks.listing.mockResolvedValue({ + created: mocks.created, + worktrees: [mocks.created, mocks.created, mocks.created], + listingComplete: true + }) + const timing = createWorktreeCreateTimingRecorder() + + await createWorktree({}, undefined, timing) + + expect(mocks.consume).toHaveBeenCalledWith(expect.objectContaining({ timing })) + const finished = timing.finish() + expect(finished.phases.map((phase) => phase.phase)).toEqual( + expect.arrayContaining([ + 'resolve_name', + 'git_worktree_add', + 'list_created_worktree', + 'persist_metadata', + 'copy_worktreeinclude' + ]) + ) + expect(finished).toMatchObject({ executionHost: 'local', worktreeCount: 3 }) + }) + + it('records a sparse create as a miss without consulting the prepared checkout', async () => { + const timing = createWorktreeCreateTimingRecorder() + + await createWorktree({ sparseCheckout: { directories: ['src'] } }, undefined, timing) + + expect(mocks.consume).not.toHaveBeenCalled() + expect(timing.finish().preparedCheckout).toEqual({ status: 'miss', reason: 'sparse_checkout' }) + }) +}) + describe('runtime create Git priority', () => { it.each([undefined, 'Ubuntu'])( 'preserves interactive priority and routing on %s', diff --git a/src/main/runtime/runtime-local-worktree-create.ts b/src/main/runtime/runtime-local-worktree-create.ts index 41e7b3734fc..a19a62ee29e 100644 --- a/src/main/runtime/runtime-local-worktree-create.ts +++ b/src/main/runtime/runtime-local-worktree-create.ts @@ -20,6 +20,10 @@ import { resolveRuntimeLocalWorktreeCreateCandidate } from './runtime-local-work import { createRuntimeLocalGitWorktree } from './runtime-local-git-worktree-create' import { materializeRuntimeLocalWorktree } from './runtime-local-worktree-materialization' import type { PreparationRearmHolder } from '../worktree-create-preparation' +import { + localWorktreeCreateExecutionHost, + type WorktreeCreateTimingRecorder +} from '../worktree-create-timing' type RuntimeLocalWorktreeCreateArgs = { request: RuntimeManagedWorktreeCreateArgs @@ -45,6 +49,7 @@ type RuntimeLocalWorktreeCreateArgs = { fetchRemote: (path: string, remote: string, options?: LocalGitExecOptions) => Promise onWorktreeMetadataPersisted: (worktree: Worktree) => T rearm: PreparationRearmHolder + timing: WorktreeCreateTimingRecorder } export function createRuntimeLocalManagedWorktree(args: RuntimeLocalWorktreeCreateArgs) { @@ -57,6 +62,7 @@ async function performRuntimeLocalWorktreeCreate(args: RuntimeLocalWorktreeCr const pathSettings = getWorktreePathSettings(repo, settings, getWorktreeMirrorDistro(store, repo)) const gitExecOptions = getLocalProjectGitExecOptions(store, repo) const worktreeGitOptions = getLocalProjectWorktreeGitOptions(store, repo) + args.timing.recordExecutionHost(localWorktreeCreateExecutionHost(gitExecOptions)) // Username and base resolution are independent read-only probes. Starting // both before awaiting removes one serial git/config round trip from create. const usernamePromise = @@ -88,18 +94,20 @@ async function performRuntimeLocalWorktreeCreate(args: RuntimeLocalWorktreeCr 'Could not resolve a default base ref for this repo. Pass an explicit --base and try again.' ) } - const candidate = await resolveRuntimeLocalWorktreeCreateCandidate({ - request, - repo, - settings, - worktreePathSettings: pathSettings, - workspaceRoot: computeWorkspaceRoot(repo.path, pathSettings), - username, - store, - baseBranch, - localWorktreeGitOptions: worktreeGitOptions, - hostedReviewExecutionContext: args.hostedReviewExecutionContext - }) + const candidate = await args.timing.time('resolve_name', () => + resolveRuntimeLocalWorktreeCreateCandidate({ + request, + repo, + settings, + worktreePathSettings: pathSettings, + workspaceRoot: computeWorkspaceRoot(repo.path, pathSettings), + username, + store, + baseBranch, + localWorktreeGitOptions: worktreeGitOptions, + hostedReviewExecutionContext: args.hostedReviewExecutionContext + }) + ) const git = await createRuntimeLocalGitWorktree({ request, repo, @@ -116,7 +124,8 @@ async function performRuntimeLocalWorktreeCreate(args: RuntimeLocalWorktreeCr hasRemoteTrackingRef: args.hasRemoteTrackingRef, refreshRemoteTrackingBase: args.refreshRemoteTrackingBase, fetchRemote: args.fetchRemote, - rearm: args.rearm + rearm: args.rearm, + timing: args.timing }) const materialized = await materializeRuntimeLocalWorktree({ request, @@ -136,7 +145,8 @@ async function performRuntimeLocalWorktreeCreate(args: RuntimeLocalWorktreeCr effectiveSanitizedName: candidate.effectiveSanitizedName, effectiveCreatedWithAgent: args.createdWithAgent, localWorktreeGitOptions: worktreeGitOptions, - onMetadataPersisted: args.onWorktreeMetadataPersisted + onMetadataPersisted: args.onWorktreeMetadataPersisted, + timing: args.timing }) return { ...materialized, diff --git a/src/main/runtime/runtime-local-worktree-materialization.test.ts b/src/main/runtime/runtime-local-worktree-materialization.test.ts index 85377b7ac40..4966ac862f8 100644 --- a/src/main/runtime/runtime-local-worktree-materialization.test.ts +++ b/src/main/runtime/runtime-local-worktree-materialization.test.ts @@ -21,6 +21,7 @@ vi.mock('../git/worktree-shared-directories', () => ({ })) import { materializeRuntimeLocalWorktree } from './runtime-local-worktree-materialization' +import { createWorktreeCreateTimingRecorder } from '../worktree-create-timing' describe('materializeRuntimeLocalWorktree', () => { it('records lineage immediately after metadata and before filesystem setup', async () => { @@ -65,7 +66,8 @@ describe('materializeRuntimeLocalWorktree', () => { onMetadataPersisted: () => { order.push('metadata') return null - } + }, + timing: createWorktreeCreateTimingRecorder() } as never) ).rejects.toThrow('link failed') diff --git a/src/main/runtime/runtime-local-worktree-materialization.ts b/src/main/runtime/runtime-local-worktree-materialization.ts index 1919f69b780..9347a7e7ffc 100644 --- a/src/main/runtime/runtime-local-worktree-materialization.ts +++ b/src/main/runtime/runtime-local-worktree-materialization.ts @@ -22,6 +22,7 @@ import { resolveWorktreeSharedDirectories } from '../git/worktree-shared-directo import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktree-create-types' import type { RemoteTrackingBase } from './runtime-remote-fetch-controller' import type { RuntimeStore } from './runtime-store-contract' +import type { WorktreeCreateTimingRecorder } from '../worktree-create-timing' export async function materializeRuntimeLocalWorktree(args: { request: RuntimeManagedWorktreeCreateArgs @@ -42,6 +43,7 @@ export async function materializeRuntimeLocalWorktree(args: { effectiveCreatedWithAgent?: TuiAgent localWorktreeGitOptions: LocalGitExecOptions onMetadataPersisted: (worktree: Worktree) => T + timing: WorktreeCreateTimingRecorder }): Promise<{ worktree: Worktree; metadataResult: T; includeCopyWarning?: string }> { const { request, @@ -60,7 +62,8 @@ export async function materializeRuntimeLocalWorktree(args: { displayNameKind, effectiveSanitizedName, effectiveCreatedWithAgent, - localWorktreeGitOptions + localWorktreeGitOptions, + timing } = args const worktreeId = `${repo.id}::${created.path}` const now = Date.now() @@ -71,62 +74,66 @@ export async function materializeRuntimeLocalWorktree(args: { displayNameKind, { requestedName: effectiveRequestedName, sanitizedName: effectiveSanitizedName } ) - const meta = store.setWorktreeMeta(worktreeId, { - instanceId: randomUUID(), - ...getProjectHostSetupWorktreeMeta(store.getProjectHostSetups?.() ?? [], repo), - lastActivityAt: now, - createdAt: now, - orcaCreatedAt: now, - orcaCreationSource: 'runtime', - orcaCreationWorkspaceLayout: getWorktreeCreationLayout(repo, settings), - ...displayNameMeta, - baseRef: metadataBaseRef, - ...(checkoutExistingBranch ? { preserveBranchOnDelete: true } : {}), - ...(configuredPushTarget ? { pushTarget: configuredPushTarget } : {}), - ...(sparseDirectories.length > 0 - ? { - sparseDirectories, - sparseBaseRef: metadataBaseRef, - sparsePresetId: request.sparseCheckout?.presetId - } - : {}), - ...(request.linkedIssue !== undefined ? { linkedIssue: request.linkedIssue } : {}), - ...(request.linkedPR !== undefined ? { linkedPR: request.linkedPR } : {}), - ...(request.linkedLinearIssue !== undefined - ? { linkedLinearIssue: request.linkedLinearIssue } - : {}), - ...(request.linkedLinearIssueWorkspaceId !== undefined - ? { linkedLinearIssueWorkspaceId: request.linkedLinearIssueWorkspaceId } - : {}), - ...(request.linkedLinearIssueOrganizationUrlKey !== undefined - ? { linkedLinearIssueOrganizationUrlKey: request.linkedLinearIssueOrganizationUrlKey } - : {}), - ...(request.linkedGitLabIssue !== undefined - ? { linkedGitLabIssue: request.linkedGitLabIssue } - : {}), - ...(request.linkedGitLabMR !== undefined ? { linkedGitLabMR: request.linkedGitLabMR } : {}), - ...(request.linkedBitbucketPR !== undefined - ? { linkedBitbucketPR: request.linkedBitbucketPR } - : {}), - ...(request.linkedAzureDevOpsPR !== undefined - ? { linkedAzureDevOpsPR: request.linkedAzureDevOpsPR } - : {}), - ...(request.linkedGiteaPR !== undefined ? { linkedGiteaPR: request.linkedGiteaPR } : {}), - ...(request.linkedWorkItem !== undefined ? { linkedWorkItem: request.linkedWorkItem } : {}), - ...(request.linkedTaskSourceContext !== undefined - ? { linkedTaskSourceContext: request.linkedTaskSourceContext } - : {}), - ...(effectiveCreatedWithAgent ? { createdWithAgent: effectiveCreatedWithAgent } : {}), - ...(request.pendingFirstAgentMessageRename === true && effectiveCreatedWithAgent - ? { pendingFirstAgentMessageRename: true } - : {}), - ...(request.automationProvenance ? { automationProvenance: request.automationProvenance } : {}), - ...(request.cliProvenance ? { cliProvenance: request.cliProvenance } : {}), - creatorProvenance: request.creatorProvenance ?? { kind: 'host' }, - ...(request.comment !== undefined ? { comment: request.comment } : {}), - ...(request.manualOrder !== undefined ? { manualOrder: request.manualOrder } : {}), - ...(request.workspaceStatus !== undefined ? { workspaceStatus: request.workspaceStatus } : {}) - }) + const meta = timing.timeSync('persist_metadata', () => + store.setWorktreeMeta(worktreeId, { + instanceId: randomUUID(), + ...getProjectHostSetupWorktreeMeta(store.getProjectHostSetups?.() ?? [], repo), + lastActivityAt: now, + createdAt: now, + orcaCreatedAt: now, + orcaCreationSource: 'runtime', + orcaCreationWorkspaceLayout: getWorktreeCreationLayout(repo, settings), + ...displayNameMeta, + baseRef: metadataBaseRef, + ...(checkoutExistingBranch ? { preserveBranchOnDelete: true } : {}), + ...(configuredPushTarget ? { pushTarget: configuredPushTarget } : {}), + ...(sparseDirectories.length > 0 + ? { + sparseDirectories, + sparseBaseRef: metadataBaseRef, + sparsePresetId: request.sparseCheckout?.presetId + } + : {}), + ...(request.linkedIssue !== undefined ? { linkedIssue: request.linkedIssue } : {}), + ...(request.linkedPR !== undefined ? { linkedPR: request.linkedPR } : {}), + ...(request.linkedLinearIssue !== undefined + ? { linkedLinearIssue: request.linkedLinearIssue } + : {}), + ...(request.linkedLinearIssueWorkspaceId !== undefined + ? { linkedLinearIssueWorkspaceId: request.linkedLinearIssueWorkspaceId } + : {}), + ...(request.linkedLinearIssueOrganizationUrlKey !== undefined + ? { linkedLinearIssueOrganizationUrlKey: request.linkedLinearIssueOrganizationUrlKey } + : {}), + ...(request.linkedGitLabIssue !== undefined + ? { linkedGitLabIssue: request.linkedGitLabIssue } + : {}), + ...(request.linkedGitLabMR !== undefined ? { linkedGitLabMR: request.linkedGitLabMR } : {}), + ...(request.linkedBitbucketPR !== undefined + ? { linkedBitbucketPR: request.linkedBitbucketPR } + : {}), + ...(request.linkedAzureDevOpsPR !== undefined + ? { linkedAzureDevOpsPR: request.linkedAzureDevOpsPR } + : {}), + ...(request.linkedGiteaPR !== undefined ? { linkedGiteaPR: request.linkedGiteaPR } : {}), + ...(request.linkedWorkItem !== undefined ? { linkedWorkItem: request.linkedWorkItem } : {}), + ...(request.linkedTaskSourceContext !== undefined + ? { linkedTaskSourceContext: request.linkedTaskSourceContext } + : {}), + ...(effectiveCreatedWithAgent ? { createdWithAgent: effectiveCreatedWithAgent } : {}), + ...(request.pendingFirstAgentMessageRename === true && effectiveCreatedWithAgent + ? { pendingFirstAgentMessageRename: true } + : {}), + ...(request.automationProvenance + ? { automationProvenance: request.automationProvenance } + : {}), + ...(request.cliProvenance ? { cliProvenance: request.cliProvenance } : {}), + creatorProvenance: request.creatorProvenance ?? { kind: 'host' }, + ...(request.comment !== undefined ? { comment: request.comment } : {}), + ...(request.manualOrder !== undefined ? { manualOrder: request.manualOrder } : {}), + ...(request.workspaceStatus !== undefined ? { workspaceStatus: request.workspaceStatus } : {}) + }) + ) const worktree = { ...mergeWorktree(repo.id, created, meta), hostId: meta.hostId ?? getRepoExecutionHostId(repo) @@ -134,24 +141,30 @@ export async function materializeRuntimeLocalWorktree(args: { const metadataResult = args.onMetadataPersisted(worktree) if ((repo.symlinkPaths ?? []).length > 0) { - await createWorktreeLinkedPaths(repo.path, created.path, repo.symlinkPaths ?? []) + await timing.time('create_symlinks', () => + createWorktreeLinkedPaths(repo.path, created.path, repo.symlinkPaths ?? []) + ) } // These discoveries are read-only; overlap them, but keep the shared-path // mutation ahead of include copies below. const [sharedDirectories, worktreeIncludePaths] = await Promise.all([ - resolveWorktreeSharedDirectories(repo.path, localWorktreeGitOptions), - resolveWorktreeIncludePaths(repo.path, localWorktreeGitOptions) + timing.time('resolve_shared_directories', () => + resolveWorktreeSharedDirectories(repo.path, localWorktreeGitOptions) + ), + timing.time('resolve_worktreeinclude', () => + resolveWorktreeIncludePaths(repo.path, localWorktreeGitOptions) + ) ]) if (sharedDirectories.length > 0) { - await createWorktreeSharedPaths(repo.path, created.path, sharedDirectories) + await timing.time('create_shared_directories', () => + createWorktreeSharedPaths(repo.path, created.path, sharedDirectories) + ) } if (worktreeIncludePaths.length === 0) { return { worktree, metadataResult } } - const skippedIncludePaths = await createWorktreeCopiedPaths( - repo.path, - created.path, - worktreeIncludePaths + const skippedIncludePaths = await timing.time('copy_worktreeinclude', () => + createWorktreeCopiedPaths(repo.path, created.path, worktreeIncludePaths) ) const includeCopyWarning = formatWorktreeIncludeCopyWarning(skippedIncludePaths) if (includeCopyWarning) { diff --git a/src/main/runtime/runtime-remote-worktree-create-request.ts b/src/main/runtime/runtime-remote-worktree-create-request.ts index ac40ec64b8d..0f12caff28c 100644 --- a/src/main/runtime/runtime-remote-worktree-create-request.ts +++ b/src/main/runtime/runtime-remote-worktree-create-request.ts @@ -2,6 +2,7 @@ import type { BrowserWindow } from 'electron' import type { CreateWorktreeResult } from '../../shared/worktree/create-types' import type { Repo } from '../../shared/repo-types' import { createRemoteWorktree } from '../ipc/worktree-remote' +import type { WorktreeCreateTimingRecorder } from '../worktree-create-timing' import type { Store } from '../persistence' import type { RuntimeStore } from './runtime-store-contract' import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktree-create-types' @@ -12,6 +13,8 @@ export type RuntimeRemoteWorktreeCreateArgs = Omit< 'repoSelector' > & { startupFollowup?: WorktreeStartupFollowup + /** The create's timing recorder, owned by the runtime's create events. */ + timing?: WorktreeCreateTimingRecorder } export async function requestRuntimeRemoteWorktree( @@ -66,7 +69,8 @@ export async function requestRuntimeRemoteWorktree( }, repo, store as unknown as Store, - headlessWindow + headlessWindow, + args.timing ) if (args.comment !== undefined) { store.setWorktreeMeta(result.worktree.id, { comment: args.comment }) diff --git a/src/main/workspace-create-event-fields.test.ts b/src/main/workspace-create-event-fields.test.ts new file mode 100644 index 00000000000..eab8bb78a37 --- /dev/null +++ b/src/main/workspace-create-event-fields.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it } from 'vitest' +import { eventSchemas } from '../shared/telemetry-event-registry' +import { createWorktreeCreateTimingRecorder } from './worktree-create-timing' +import { + bucketRepoFileCount, + bucketWorktreeCount, + workspaceCreateFailureFields, + workspaceCreateTimingFields +} from './workspace-create-event-fields' + +const quiet = { otherCreates: 0, preparations: 0 } + +describe('workspaceCreateTimingFields', () => { + it('maps a local create with a prepared-checkout hit', () => { + const fields = workspaceCreateTimingFields( + { + totalDurationMs: 1_000.4, + phases: [ + { phase: 'refresh_base_ref', startedAtMs: 0, durationMs: 200.6 }, + { phase: 'git_worktree_add', startedAtMs: 200, durationMs: 500 }, + { phase: 'prepared_checkout_claim', startedAtMs: 200, durationMs: 4 }, + { phase: 'prepared_checkout_wait', startedAtMs: 204, durationMs: 300 }, + { phase: 'list_created_worktree', startedAtMs: 700, durationMs: 100 } + ], + preparedCheckout: { + status: 'hit', + reset: 'base_moved', + origin: 'rearm', + buildMs: 30_000.4, + idleMs: 2_500 + }, + executionHost: 'local', + worktreeCount: 812 + }, + { + entryPoint: 'runtime', + concurrency: { otherCreates: 1, preparations: 2 }, + repoFacts: { postCheckoutHook: 'absent', indexEntryCount: 42_000 } + } + ) + + expect(fields).toEqual({ + total_ms: 1_000, + unattributed_ms: 200, + refresh_base_ref_ms: 201, + git_worktree_add_ms: 500, + prepared_checkout_claim_ms: 4, + prepared_checkout_wait_ms: 300, + list_created_worktree_ms: 100, + prepared_checkout: 'hit', + prepared_checkout_reset: 'base_moved', + prepared_checkout_origin: 'rearm', + prepared_checkout_build_ms: 30_000, + prepared_checkout_idle_ms: 2_500, + create_entry_point: 'runtime', + execution_host: 'local', + worktree_count_bucket: '301-1000', + concurrent_creates: 1, + concurrent_preparations: 2, + repo_file_count_bucket: '10k-100k', + post_checkout_hook: 'absent' + }) + expect( + eventSchemas.workspace_created.safeParse({ + source: 'sidebar', + from_existing_branch: false, + ...fields + }).success + ).toBe(true) + }) + + it('maps a miss with its reason and omits what the create did not learn', () => { + const fields = workspaceCreateTimingFields( + { + totalDurationMs: 50, + phases: [], + preparedCheckout: { status: 'miss', reason: 'none_armed' } + }, + { entryPoint: 'app', concurrency: quiet } + ) + + expect(fields).toEqual({ + total_ms: 50, + unattributed_ms: 50, + prepared_checkout: 'miss', + prepared_checkout_miss_reason: 'none_armed', + create_entry_point: 'app', + concurrent_creates: 0, + concurrent_preparations: 0 + }) + }) + + it('sends the hook but no file count when the repo has no usable index', () => { + const fields = workspaceCreateTimingFields( + { totalDurationMs: 1, phases: [] }, + { entryPoint: 'app', concurrency: quiet, repoFacts: { postCheckoutHook: 'unknown' } } + ) + expect(fields.post_checkout_hook).toBe('unknown') + expect(fields).not.toHaveProperty('repo_file_count_bucket') + }) + + it('takes the worktree count from the repo read when the create did not list worktrees', () => { + const fields = workspaceCreateTimingFields( + { totalDurationMs: 1, phases: [] }, + { + entryPoint: 'app', + concurrency: quiet, + repoFacts: { postCheckoutHook: 'absent', worktreeCount: 7 } + } + ) + expect(fields.worktree_count_bucket).toBe('6-20') + }) + + it('sums a repeated phase and drops names outside the closed vocabulary', () => { + const fields = workspaceCreateTimingFields( + { + totalDurationMs: 100, + phases: [ + { phase: 'refresh_base_ref', startedAtMs: 0, durationMs: 10 }, + { phase: 'refresh_base_ref', startedAtMs: 10, durationMs: 15 }, + { phase: '/Users/alice/repo', startedAtMs: 25, durationMs: 5 } + ] + }, + { entryPoint: 'app', concurrency: quiet } + ) + + expect(fields.refresh_base_ref_ms).toBe(25) + expect(Object.keys(fields).some((key) => key.includes('alice'))).toBe(false) + }) +}) + +describe('workspaceCreateFailureFields', () => { + it('names the failing phase and the elapsed time', async () => { + let now = 0 + const recorder = createWorktreeCreateTimingRecorder(() => now) + recorder.recordExecutionHost('ssh') + const error = await recorder + .time('git_worktree_add', async () => { + now = 4_200 + throw new Error('fatal: could not create work tree dir /Users/alice/x') + }) + .catch((caught: unknown) => caught) + + const fields = workspaceCreateFailureFields(recorder, { + entryPoint: 'app', + concurrency: { otherCreates: 3, preparations: 1 }, + error + }) + + expect(fields).toEqual({ + failed_phase: 'git_worktree_add', + total_ms: 4_200, + create_entry_point: 'app', + execution_host: 'ssh', + concurrent_creates: 3, + concurrent_preparations: 1 + }) + expect( + eventSchemas.workspace_create_failed.safeParse({ + source: 'sidebar', + error_class: 'git_failed', + ...fields + }).success + ).toBe(true) + }) + + it('carries the prepared-checkout outcome and wait of a create that failed after it', async () => { + let now = 0 + const recorder = createWorktreeCreateTimingRecorder(() => now) + recorder.recordExecutionHost('local') + await recorder.time('prepared_checkout_wait', async () => { + now = 200_400 + }) + recorder.recordPreparedCheckout({ status: 'miss', reason: 'finalize_failed' }) + const error = await recorder + .time('git_worktree_add', async () => { + throw new Error('fatal: a branch named feature already exists') + }) + .catch((caught: unknown) => caught) + + const fields = workspaceCreateFailureFields(recorder, { + entryPoint: 'runtime', + concurrency: quiet, + error + }) + + expect(fields).toMatchObject({ + failed_phase: 'git_worktree_add', + prepared_checkout: 'miss', + prepared_checkout_miss_reason: 'finalize_failed', + prepared_checkout_wait_ms: 200_400, + create_entry_point: 'runtime' + }) + expect( + eventSchemas.workspace_create_failed.safeParse({ + source: 'unknown', + error_class: 'git_failed', + ...fields + }).success + ).toBe(true) + }) + + it('reports untimed when the create failed outside every phase', () => { + const recorder = createWorktreeCreateTimingRecorder(() => 0) + expect( + workspaceCreateFailureFields(recorder, { + entryPoint: 'app', + concurrency: quiet, + error: new Error('x') + }).failed_phase + ).toBe('untimed') + }) +}) + +describe('bucketWorktreeCount', () => { + it.each([ + [0, '1'], + [1, '1'], + [2, '2-5'], + [5, '2-5'], + [6, '6-20'], + [21, '21-100'], + [101, '101-300'], + [301, '301-1000'], + [1000, '301-1000'], + [1001, '1001+'] + ] as const)('%i worktrees -> %s', (count, bucket) => { + expect(bucketWorktreeCount(count)).toBe(bucket) + }) +}) + +describe('bucketRepoFileCount', () => { + it.each([ + [0, '<1k'], + [999, '<1k'], + [1_000, '1k-10k'], + [10_000, '10k-100k'], + [100_000, '100k-500k'], + [500_000, '500k+'] + ] as const)('%i files -> %s', (count, bucket) => { + expect(bucketRepoFileCount(count)).toBe(bucket) + }) +}) diff --git a/src/main/workspace-create-event-fields.ts b/src/main/workspace-create-event-fields.ts new file mode 100644 index 00000000000..921ff9e2c77 --- /dev/null +++ b/src/main/workspace-create-event-fields.ts @@ -0,0 +1,160 @@ +import type { z } from 'zod' +import type { WorktreeCreateTiming } from '../shared/worktree/create-types' +import { + WORKTREE_CREATE_PHASES, + type WorkspaceCreateEntryPoint, + type WorktreeCreatePhase +} from '../shared/worktree/create-timing-vocabulary' +import type { + REPO_FILE_COUNT_BUCKETS, + WORKTREE_COUNT_BUCKETS, + workspaceCreateFailedProperties, + workspaceCreatedTimingProperties +} from '../shared/telemetry-workspace-create-schemas' +import type { WorktreeCreateTimingRecorder } from './worktree-create-timing' +import type { WorktreeCreateConcurrency } from './worktree-create-concurrency' +import type { CreateEventRepoFacts } from './git/create-event-repo-probe' +import { worktreeCreateUnattributedMs } from './observability/instrumentation' + +type OptionalEventFields

> = { [K in keyof P]?: z.infer } +export type WorkspaceCreateTimingFields = OptionalEventFields< + typeof workspaceCreatedTimingProperties +> +export type WorkspaceCreateFailureFields = OptionalEventFields< + typeof workspaceCreateFailedProperties +> +type WorktreeCountBucket = (typeof WORKTREE_COUNT_BUCKETS)[number] +type RepoFileCountBucket = (typeof REPO_FILE_COUNT_BUCKETS)[number] + +/** What the create's owner knows beyond the timing the create recorded. */ +export type WorkspaceCreateEventContext = { + entryPoint: WorkspaceCreateEntryPoint + concurrency: WorktreeCreateConcurrency +} + +const KNOWN_PHASES: ReadonlySet = new Set(WORKTREE_CREATE_PHASES) + +function isKnownPhase(phase: string): phase is WorktreeCreatePhase { + return KNOWN_PHASES.has(phase) +} + +export function bucketWorktreeCount(count: number): WorktreeCountBucket { + if (count <= 1) { + return '1' + } + if (count <= 5) { + return '2-5' + } + if (count <= 20) { + return '6-20' + } + if (count <= 100) { + return '21-100' + } + if (count <= 300) { + return '101-300' + } + return count <= 1000 ? '301-1000' : '1001+' +} + +export function bucketRepoFileCount(count: number): RepoFileCountBucket { + if (count < 1_000) { + return '<1k' + } + if (count < 10_000) { + return '1k-10k' + } + if (count < 100_000) { + return '10k-100k' + } + return count < 500_000 ? '100k-500k' : '500k+' +} + +function phaseTotals(phases: WorktreeCreateTiming['phases']): Map { + const totals = new Map() + for (const phase of phases) { + // Unknown names are dropped rather than forwarded: only the closed vocabulary may leave. + if (isKnownPhase(phase.phase)) { + totals.set(phase.phase, (totals.get(phase.phase) ?? 0) + phase.durationMs) + } + } + return totals +} + +function phaseDurationFields(phases: WorktreeCreateTiming['phases']): WorkspaceCreateTimingFields { + const fields: WorkspaceCreateTimingFields = {} + for (const [phase, durationMs] of phaseTotals(phases)) { + fields[`${phase}_ms`] = Math.round(durationMs) + } + return fields +} + +function preparedCheckoutFields( + prepared: WorktreeCreateTiming['preparedCheckout'] +): WorkspaceCreateFailureFields { + if (prepared?.status === 'hit') { + return { + prepared_checkout: 'hit', + prepared_checkout_reset: prepared.reset, + prepared_checkout_origin: prepared.origin, + prepared_checkout_build_ms: Math.round(prepared.buildMs), + prepared_checkout_idle_ms: Math.round(prepared.idleMs) + } + } + return prepared + ? { prepared_checkout: 'miss', prepared_checkout_miss_reason: prepared.reason } + : {} +} + +function contextFields( + timing: WorktreeCreateTiming, + context: WorkspaceCreateEventContext +): WorkspaceCreateFailureFields { + return { + create_entry_point: context.entryPoint, + ...(timing.executionHost ? { execution_host: timing.executionHost } : {}), + concurrent_creates: context.concurrency.otherCreates, + concurrent_preparations: context.concurrency.preparations + } +} + +function worktreeCountFields(count: number | undefined): WorkspaceCreateTimingFields { + return count === undefined ? {} : { worktree_count_bucket: bucketWorktreeCount(count) } +} + +/** Event fields for a finished create, built only from what the create already measured. */ +export function workspaceCreateTimingFields( + timing: WorktreeCreateTiming, + context: WorkspaceCreateEventContext & { repoFacts?: CreateEventRepoFacts } +): WorkspaceCreateTimingFields { + const { repoFacts } = context + return { + total_ms: Math.round(timing.totalDurationMs), + unattributed_ms: worktreeCreateUnattributedMs(timing), + ...phaseDurationFields(timing.phases), + ...preparedCheckoutFields(timing.preparedCheckout), + ...contextFields(timing, context), + ...worktreeCountFields(repoFacts?.worktreeCount ?? timing.worktreeCount), + ...(repoFacts?.indexEntryCount !== undefined + ? { repo_file_count_bucket: bucketRepoFileCount(repoFacts.indexEntryCount) } + : {}), + ...(repoFacts ? { post_checkout_hook: repoFacts.postCheckoutHook } : {}) + } +} + +/** Event fields for a failed create: where it died, how long it had run, and what the prepared + * checkout did before it. */ +export function workspaceCreateFailureFields( + recorder: Pick, + context: WorkspaceCreateEventContext & { error: unknown } +): WorkspaceCreateFailureFields { + const timing = recorder.finish() + const waitMs = phaseTotals(timing.phases).get('prepared_checkout_wait') + return { + failed_phase: recorder.failedPhase(context.error) ?? 'untimed', + total_ms: Math.round(timing.totalDurationMs), + ...preparedCheckoutFields(timing.preparedCheckout), + ...(waitMs !== undefined ? { prepared_checkout_wait_ms: Math.round(waitMs) } : {}), + ...contextFields(timing, context) + } +} diff --git a/src/main/workspace-create-telemetry.ts b/src/main/workspace-create-telemetry.ts new file mode 100644 index 00000000000..a1dcc4d9f7d --- /dev/null +++ b/src/main/workspace-create-telemetry.ts @@ -0,0 +1,160 @@ +import type { WorktreeCreateTiming } from '../shared/worktree/create-types' +import type { WorkspaceCreateEntryPoint } from '../shared/worktree/create-timing-vocabulary' +import { workspaceSourceSchema } from '../shared/telemetry-events' +import type { EventProps, WorkspaceSource } from '../shared/telemetry-events' +import { isTelemetryEnabled, track } from './telemetry/client' +import { getCohortAtEmit } from './telemetry/cohort-classifier' +import { classifyWorkspaceCreateError } from './ipc/workspace-create-error-classifier' +import { probeCreateEventRepoFacts } from './git/create-event-repo-probe' +import { + createWorktreeCreateTimingRecorder, + type WorktreeCreateTimingRecorder +} from './worktree-create-timing' +import { beginWorktreeCreate } from './worktree-create-concurrency' +import { + workspaceCreateFailureFields, + workspaceCreateTimingFields, + type WorkspaceCreateEventContext +} from './workspace-create-event-fields' + +/** One create's `workspace_created` / `workspace_create_failed` event: exactly one is sent. */ +export type WorkspaceCreateTelemetry = { + /** The recorder the create times its phases into. */ + readonly timing: WorktreeCreateTimingRecorder + /** `timing` is the create's own finished timing when it returned one. */ + succeeded(timing?: WorktreeCreateTiming): void + failed(error: unknown): void +} + +export type WorkspaceCreateTelemetryArgs = { + /** The caller-supplied value, validated here; anything else is sent as `unknown`. */ + source: unknown + entryPoint: WorkspaceCreateEntryPoint + repoPath: string + fromExistingBranch: boolean + /** Folder workspaces register a directory: no timing, no disk competition, event unchanged. */ + isFolder: boolean +} + +/** + * Starts the events for one create. Both create entry points (the app's IPC handler and the + * runtime API) begin here, so a create is counted once and both send the same fields. + */ +export function beginWorkspaceCreateTelemetry( + args: WorkspaceCreateTelemetryArgs +): WorkspaceCreateTelemetry { + const sourceParse = workspaceSourceSchema.safeParse(args.source) + const source: WorkspaceSource = sourceParse.success ? sourceParse.data : 'unknown' + const inFlight = args.isFolder ? null : beginWorktreeCreate() + const timing = createWorktreeCreateTimingRecorder(undefined, inFlight ?? undefined) + let settled = false + const settleOnce = (send: () => void): void => { + if (settled) { + return + } + settled = true + try { + send() + } catch (error) { + // Bookkeeping must never change the outcome of the create it describes. + console.warn('[worktree-create] create event could not be sent', error) + } finally { + inFlight?.end() + } + } + + return { + timing, + succeeded(createTiming) { + settleOnce(() => { + const props: EventProps<'workspace_created'> = { + source, + from_existing_branch: !args.isFolder && args.fromExistingBranch, + ...getCohortAtEmit() + } + if (!inFlight) { + track('workspace_created', props) + return + } + void trackWorkspaceCreated(props, args.repoPath, createTiming ?? timing.finish(), { + entryPoint: args.entryPoint, + concurrency: inFlight.end() + }).catch((error: unknown) => { + console.warn('[worktree-create] create event could not be sent', error) + }) + }) + }, + failed(error) { + settleOnce(() => { + track('workspace_create_failed', { + source, + error_class: classifyWorkspaceCreateError(error), + ...getCohortAtEmit(), + ...(inFlight + ? workspaceCreateFailureFields(timing, { + entryPoint: args.entryPoint, + concurrency: inFlight.end(), + error + }) + : {}) + }) + }) + } + } +} + +/** Lets a runtime create start its events once it is known to be a Git create. */ +export type RuntimeWorkspaceCreateEvents = { + begin(repoPath: string): WorktreeCreateTimingRecorder +} + +/** + * Runs a runtime create and settles its events before returning, so a re-arm the caller fires + * afterwards is not counted against it. Folder workspaces and requests rejected before `begin` + * send nothing, as before. + */ +export async function trackRuntimeWorkspaceCreate( + request: { telemetrySource?: unknown; baseBranch?: string }, + perform: (events: RuntimeWorkspaceCreateEvents) => Promise +): Promise { + const session: { current?: WorkspaceCreateTelemetry } = {} + try { + const result = await perform({ + begin(repoPath) { + session.current = beginWorkspaceCreateTelemetry({ + source: request.telemetrySource, + entryPoint: 'runtime', + repoPath, + fromExistingBranch: + typeof request.baseBranch === 'string' && request.baseBranch.length > 0, + isFolder: false + }) + return session.current.timing + } + }) + session.current?.succeeded() + return result + } catch (error) { + session.current?.failed(error) + throw error + } +} + +/** Runs after the create has returned, so the repo probe never adds to create latency. */ +async function trackWorkspaceCreated( + props: EventProps<'workspace_created'>, + repoPath: string, + timing: WorktreeCreateTiming, + context: WorkspaceCreateEventContext +): Promise { + // SSH would need a remote round trip, so only local and WSL repos are probed; with telemetry + // off the repo's .git is not read at all. + const repoFacts = + isTelemetryEnabled() && (timing.executionHost === 'local' || timing.executionHost === 'wsl') + ? await probeCreateEventRepoFacts(repoPath) + : undefined + track('workspace_created', { + ...props, + ...workspaceCreateTimingFields(timing, { ...context, ...(repoFacts ? { repoFacts } : {}) }) + }) +} diff --git a/src/main/worktree-create-concurrency.test.ts b/src/main/worktree-create-concurrency.test.ts new file mode 100644 index 00000000000..0df09b9552d --- /dev/null +++ b/src/main/worktree-create-concurrency.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + _resetWorktreeCreateConcurrencyForTests, + beginPreparationWork, + beginWorktreeCreate, + trackPreparationWork +} from './worktree-create-concurrency' + +describe('worktree create concurrency', () => { + afterEach(() => { + _resetWorktreeCreateConcurrencyForTests() + }) + + it('reports zero for a create that ran alone', () => { + expect(beginWorktreeCreate().end()).toEqual({ otherCreates: 0, preparations: 0 }) + }) + + it('reports the most other creates seen at once, including ones that started later', () => { + const first = beginWorktreeCreate() + const second = beginWorktreeCreate() + const third = beginWorktreeCreate() + expect(second.end().otherCreates).toBe(2) + expect(third.end().otherCreates).toBe(2) + // Peak, not count at the end: `first` once overlapped two others. + expect(first.end().otherCreates).toBe(2) + }) + + it('stops counting a create once it ends, and a later end returns the same counts', () => { + const first = beginWorktreeCreate() + const counts = first.end() + beginPreparationWork() + expect(first.end()).toBe(counts) + expect(beginWorktreeCreate().end().otherCreates).toBe(0) + }) + + it('counts preparation work running at the start and work that starts later, as a peak', () => { + const running = beginPreparationWork() + const create = beginWorktreeCreate() + running.end() + const later = beginPreparationWork() + const another = beginPreparationWork() + later.end() + another.end() + expect(create.end().preparations).toBe(2) + }) + + it('leaves out the prepared checkout the create adopted, wherever its peak fell', () => { + const adopted = beginPreparationWork() + const create = beginWorktreeCreate() + const other = beginPreparationWork() + other.end() + create.adoptPreparation(adopted) + adopted.end() + expect(create.end().preparations).toBe(1) + }) + + it('reports zero when the only work seen was the adopted prepared checkout', () => { + const create = beginWorktreeCreate() + const adopted = beginPreparationWork() + create.adoptPreparation(adopted) + expect(create.end().preparations).toBe(0) + }) + + it('still counts work that ran after the adopted prepared checkout finished', () => { + const create = beginWorktreeCreate() + const adopted = beginPreparationWork() + create.adoptPreparation(adopted) + adopted.end() + beginPreparationWork().end() + expect(create.end().preparations).toBe(1) + }) + + it('ignores work that starts after the create ended', () => { + const create = beginWorktreeCreate() + create.end() + beginPreparationWork() + expect(create.end().preparations).toBe(0) + }) + + it('counts tracked work until it settles, including on failure', async () => { + const observer = beginWorktreeCreate() + await trackPreparationWork(Promise.reject(new Error('discard failed'))).catch(() => undefined) + const create = beginWorktreeCreate() + expect(create.end().preparations).toBe(0) + expect(observer.end().preparations).toBe(1) + }) +}) diff --git a/src/main/worktree-create-concurrency.ts b/src/main/worktree-create-concurrency.ts new file mode 100644 index 00000000000..da3b88b7711 --- /dev/null +++ b/src/main/worktree-create-concurrency.ts @@ -0,0 +1,109 @@ +// In-memory counts of worktree creates and prepared-checkout disk work running in this process, so +// a slow create can be read against what competed with it for the disk. + +/** One prepared-checkout build or discard; its identity lets the create that used it leave it out. */ +export type PreparationWork = { end(): void } + +type InFlightCreate = { + peakOtherCreates: number + peakPreparations: number + /** Per work item seen: the peak with that item left out. */ + peakPreparationsWithout: Map + adopted: PreparationWork | undefined +} + +export type WorktreeCreateConcurrency = { + /** Most other creates seen running alongside this one. */ + otherCreates: number + /** Most preparation work seen running at once, excluding the prepared checkout this create used. */ + preparations: number +} + +export type WorktreeCreateInFlightHandle = { + /** Leaves `work` out of this create's count: it is the prepared checkout this create waits on. */ + adoptPreparation(work: PreparationWork): void + /** Ends this create's membership; later calls return the same counts. */ + end(): WorktreeCreateConcurrency +} + +const creates = new Set() +const preparations = new Set() + +function observePreparations(create: InFlightCreate): void { + const count = preparations.size + for (const [work, peak] of create.peakPreparationsWithout) { + create.peakPreparationsWithout.set( + work, + Math.max(peak, count - (preparations.has(work) ? 1 : 0)) + ) + } + for (const work of preparations) { + if (!create.peakPreparationsWithout.has(work)) { + // Before it appeared, leaving it out changed nothing, so the plain peak so far applies. + create.peakPreparationsWithout.set(work, Math.max(create.peakPreparations, count - 1)) + } + } + create.peakPreparations = Math.max(create.peakPreparations, count) +} + +export function beginWorktreeCreate(): WorktreeCreateInFlightHandle { + const entry: InFlightCreate = { + peakOtherCreates: creates.size, + peakPreparations: 0, + peakPreparationsWithout: new Map(), + adopted: undefined + } + for (const other of creates) { + other.peakOtherCreates = Math.max(other.peakOtherCreates, creates.size) + } + observePreparations(entry) + creates.add(entry) + let result: WorktreeCreateConcurrency | undefined + return { + adoptPreparation(work) { + entry.adopted = work + }, + end() { + if (!result) { + creates.delete(entry) + result = { + otherCreates: entry.peakOtherCreates, + preparations: entry.adopted + ? (entry.peakPreparationsWithout.get(entry.adopted) ?? entry.peakPreparations) + : entry.peakPreparations + } + } + return result + } + } +} + +export function beginPreparationWork(): PreparationWork { + let ended = false + const work: PreparationWork = { + end() { + if (ended) { + return + } + ended = true + // Peaks only rise when work starts, so ending needs no observation. + preparations.delete(work) + } + } + preparations.add(work) + for (const create of creates) { + observePreparations(create) + } + return work +} + +/** Counts `operation` as preparation work until it settles. */ +export function trackPreparationWork(operation: Promise): Promise { + const work = beginPreparationWork() + return operation.finally(() => work.end()) +} + +export function _resetWorktreeCreateConcurrencyForTests(): void { + creates.clear() + preparations.clear() +} diff --git a/src/main/worktree-create-preparation-activity.test.ts b/src/main/worktree-create-preparation-activity.test.ts new file mode 100644 index 00000000000..b896767d6b8 --- /dev/null +++ b/src/main/worktree-create-preparation-activity.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createPreparationActivity } from './worktree-create-preparation-activity' +import { + _resetWorktreeCreateConcurrencyForTests, + beginWorktreeCreate +} from './worktree-create-concurrency' + +let now = 0 + +beforeEach(() => { + now = 1_000 + vi.spyOn(performance, 'now').mockImplementation(() => now) +}) + +afterEach(() => { + _resetWorktreeCreateConcurrencyForTests() + vi.restoreAllMocks() +}) + +describe('createPreparationActivity', () => { + it('reports the origin, including a re-arm the prefetch then asked for', () => { + expect(createPreparationActivity('explicit').origin()).toBe('prefetch') + expect(createPreparationActivity('automatic').origin()).toBe('rearm') + const asked = createPreparationActivity('automatic') + asked.requestedByPrefetch() + expect(asked.origin()).toBe('rearm_then_prefetch') + }) + + it('keeps one piece of work for a tip refresh queued before the build finished', async () => { + const activity = createPreparationActivity('explicit') + const build = Promise.withResolvers() + activity.track(build.promise) + const refresh = Promise.withResolvers() + const chained = build.promise.then(() => refresh.promise) + activity.track(chained) + const observer = beginWorktreeCreate() + + now = 3_000 + build.resolve() + await build.promise + now = 5_000 + refresh.resolve() + await chained + + // Build is the build alone; idle runs from the refreshed checkout's ready. + expect(activity.timesAt(9_000)).toEqual({ buildMs: 2_000, idleMs: 4_000 }) + expect(observer.end().preparations).toBe(1) + }) + + it('keeps the build time of the first build when a later refresh runs as new work', async () => { + const activity = createPreparationActivity('automatic') + const build = Promise.withResolvers() + activity.track(build.promise) + now = 41_000 + build.resolve() + await build.promise + const observer = beginWorktreeCreate() + + now = 101_000 + const refresh = Promise.withResolvers() + activity.track(refresh.promise) + now = 103_000 + refresh.resolve() + await refresh.promise + + // A 40 s build stays 40 s; idle runs from the refresh's ready, not the build's. + expect(activity.timesAt(113_000)).toEqual({ buildMs: 40_000, idleMs: 10_000 }) + expect(observer.end().preparations).toBe(1) + }) + + it('reports no idle time while a refresh is still running at the claim', async () => { + const activity = createPreparationActivity('explicit') + const build = Promise.resolve() + activity.track(build) + now = 2_000 + await build + const refresh = Promise.withResolvers() + activity.track(refresh.promise) + expect(activity.timesAt(5_000)).toEqual({ buildMs: 1_000, idleMs: 0 }) + }) + + it('reports no idle time when the claim came before the work finished', async () => { + const activity = createPreparationActivity('automatic') + const build = Promise.withResolvers() + activity.track(build.promise) + now = 4_000 + build.resolve() + await build.promise + expect(activity.timesAt(2_000)).toEqual({ buildMs: 3_000, idleMs: 0 }) + }) +}) diff --git a/src/main/worktree-create-preparation-activity.ts b/src/main/worktree-create-preparation-activity.ts new file mode 100644 index 00000000000..de17ff6cd32 --- /dev/null +++ b/src/main/worktree-create-preparation-activity.ts @@ -0,0 +1,80 @@ +import type { PreparedCheckoutOrigin } from '../shared/worktree/create-types' +import { beginPreparationWork, type PreparationWork } from './worktree-create-concurrency' + +/** What a create's event reports about the prepared checkout it used: who asked for it, and the + * disk work of its build and tip refreshes, and their timing. Recording only; never gates the pool. */ +export type PreparationActivity = { + /** Counts the latest build or refresh as disk work competing with creates. */ + readonly work: PreparationWork + /** An explicit prefetch asked for this checkout too. */ + requestedByPrefetch(): void + /** Covers `ready`, the checkout's latest build or tip refresh, until it settles. */ + track(ready: Promise): void + origin(): PreparedCheckoutOrigin + /** First build's duration, and how long the checkout sat ready before `claimedAt`. */ + timesAt(claimedAt: number): { buildMs: number; idleMs: number } +} + +export function createPreparationActivity(kind: 'explicit' | 'automatic'): PreparationActivity { + let work = beginPreparationWork() + let workSettled = false + // Build: from the first build's start (including any wait for the base fetch it is built on) + // to its first ready; a later tip refresh does not restart it. + const buildStartedAt = performance.now() + let buildFinishedAt: number | undefined + // Idle: measured from the latest ready, build or refresh, to the claim. + let latestReadyAt: number | undefined + let latest: Promise | undefined + let prefetchRequested = false + + return { + get work() { + return work + }, + requestedByPrefetch() { + prefetchRequested = true + }, + track(ready) { + // A refresh queued after the previous work settled is new disk work. + if (workSettled) { + work = beginPreparationWork() + workSettled = false + } + latest = ready + latestReadyAt = undefined + const current = work + const settle = (succeeded: boolean): void => { + const now = performance.now() + if (succeeded) { + buildFinishedAt ??= now + } + // A refresh chained onto this work extends it; only the latest settle ends it. + if (latest !== ready) { + return + } + if (succeeded) { + latestReadyAt = now + } + workSettled = true + current.end() + } + void ready.then( + () => settle(true), + () => settle(false) + ) + }, + origin() { + if (kind === 'explicit') { + return 'prefetch' + } + return prefetchRequested ? 'rearm_then_prefetch' : 'rearm' + }, + timesAt(claimedAt) { + // A create that waited for the work reports no idle time. + return { + buildMs: Math.max(0, (buildFinishedAt ?? claimedAt) - buildStartedAt), + idleMs: Math.max(0, claimedAt - (latestReadyAt ?? claimedAt)) + } + } + } +} diff --git a/src/main/worktree-create-preparation-claim-registry.ts b/src/main/worktree-create-preparation-claim-registry.ts new file mode 100644 index 00000000000..90067b31764 --- /dev/null +++ b/src/main/worktree-create-preparation-claim-registry.ts @@ -0,0 +1,78 @@ +// Creates that hold a prepared checkout, and the preparations deferred until each one releases it. +import { preparationEntryKey, preparationPathKey } from './worktree-create-preparation-claim' +import type { PreparationEntry, StartPreparationArgs } from './worktree-create-preparation-pool' + +export type DeferredPreparation = { + args: StartPreparationArgs + kind: 'explicit' | 'automatic' +} + +export type PreparationClaim = { + entry: PreparationEntry + requestedKey: string + pendingPreparations: Map +} + +const claims = new Set() + +export function registerClaim(entry: PreparationEntry, requestedKey: string): PreparationClaim { + const claim = { entry, requestedKey, pendingPreparations: new Map() } + claims.add(claim) + return claim +} + +export function hasClaims(): boolean { + return claims.size > 0 +} + +export function isClaimed(entry: PreparationEntry): boolean { + return [...claims].some((claim) => claim.entry === entry) +} + +function matchingClaim(args: StartPreparationArgs): PreparationClaim | undefined { + const key = preparationEntryKey( + preparationPathKey(args.repoPath), + preparationPathKey(args.workspaceRoot), + args.canonicalBase, + args.options.wslDistro ?? '' + ) + return [...claims] + .toReversed() + .find((claim) => claim.entry.key === key || claim.requestedKey === key) +} + +/** Preserve one request per canonical key, with explicit prefetch taking precedence. */ +export function deferPreparationForClaim( + args: StartPreparationArgs, + kind: DeferredPreparation['kind'] +): boolean { + const matching = matchingClaim(args) + if (!matching) { + return false + } + const key = preparationEntryKey( + preparationPathKey(args.repoPath), + preparationPathKey(args.workspaceRoot), + args.canonicalBase, + args.options.wslDistro ?? '' + ) + if (kind === 'explicit' || !matching.pendingPreparations.has(key)) { + matching.pendingPreparations.set(key, { args, kind }) + } + return true +} + +/** A second release is inert, including after a test reset. */ +export function releasePreparationClaim(claim: PreparationClaim): { + released: boolean + pendingPreparations: DeferredPreparation[] +} { + if (!claims.delete(claim)) { + return { released: false, pendingPreparations: [] } + } + return { released: true, pendingPreparations: [...claim.pendingPreparations.values()] } +} + +export function resetClaimsForTests(): void { + claims.clear() +} diff --git a/src/main/worktree-create-preparation-pool.ts b/src/main/worktree-create-preparation-pool.ts index 280be5af895..d84834d3ff6 100644 --- a/src/main/worktree-create-preparation-pool.ts +++ b/src/main/worktree-create-preparation-pool.ts @@ -12,6 +12,17 @@ import { prepareWorktreeCreateCheckout } from './git/worktree-create-preparation import { WorktreePreparationLockOwnershipError } from './git/worktree-preparation-lock' import { queuePreparedWorktreeTipRefresh } from './worktree-preparation-refresh-queue' import { toHostFilesystemPath } from './host-tree-removal' +import { createPreparationActivity } from './worktree-create-preparation-activity' +import type { PreparationActivity } from './worktree-create-preparation-activity' +import { + deferPreparationForClaim, + hasClaims, + isClaimed, + registerClaim, + resetClaimsForTests, + type DeferredPreparation, + type PreparationClaim +} from './worktree-create-preparation-claim-registry' import { preparationEntryKey, preparationPathKey } from './worktree-create-preparation-claim' import { startStalePreparationCleanup, @@ -25,6 +36,12 @@ import { trackPreparationDiscard } from './worktree-preparation-discard-retry' +export { + releasePreparationClaim, + type DeferredPreparation, + type PreparationClaim +} from './worktree-create-preparation-claim-registry' + export const WORKTREE_CREATE_PREPARATION_TTL_MS = 5 * 60_000 export const WORKTREE_CREATE_PREPARATION_LIMIT = 3 @@ -45,6 +62,8 @@ export type PreparationEntry = { expiration: NodeJS.Timeout controller: AbortController checkoutStarted: boolean + /** Origin, disk work and timing reported by the create that uses it. */ + activity: PreparationActivity } export type StartPreparationArgs = { @@ -56,22 +75,11 @@ export type StartPreparationArgs = { beforeMaterialization?: Promise } -export type DeferredPreparation = { - args: StartPreparationArgs - kind: 'explicit' | 'automatic' -} - const preparations = new Map() -export type PreparationClaim = { - entry: PreparationEntry - requestedKey: string - pendingPreparations: Map -} -const claims = new Set() /** A prepared checkout is a create that is either in flight or imminent. */ export function hasPendingPreparations(): boolean { - return preparations.size > 0 || claims.size > 0 || hasPendingStalePreparationCleanup() + return preparations.size > 0 || hasClaims() || hasPendingStalePreparationCleanup() } function discardEntryInBackground(entry: PreparationEntry): void { @@ -142,53 +150,7 @@ export function takePreparation( requestedCanonicalBase, entry.wslDistro ) - const claim = { entry, requestedKey, pendingPreparations: new Map() } - claims.add(claim) - return claim -} - -function matchingClaim(args: StartPreparationArgs): PreparationClaim | undefined { - const key = preparationEntryKey( - preparationPathKey(args.repoPath), - preparationPathKey(args.workspaceRoot), - args.canonicalBase, - args.options.wslDistro ?? '' - ) - return [...claims] - .toReversed() - .find((claim) => claim.entry.key === key || claim.requestedKey === key) -} - -/** Preserve one request per canonical key, with explicit prefetch taking precedence. */ -function deferPreparationForClaim( - args: StartPreparationArgs, - kind: DeferredPreparation['kind'] -): boolean { - const matching = matchingClaim(args) - if (!matching) { - return false - } - const key = preparationEntryKey( - preparationPathKey(args.repoPath), - preparationPathKey(args.workspaceRoot), - args.canonicalBase, - args.options.wslDistro ?? '' - ) - if (kind === 'explicit' || !matching.pendingPreparations.has(key)) { - matching.pendingPreparations.set(key, { args, kind }) - } - return true -} - -/** A second release is inert, including after a test reset. */ -export function releasePreparationClaim(claim: PreparationClaim): { - released: boolean - pendingPreparations: DeferredPreparation[] -} { - if (!claims.delete(claim)) { - return { released: false, pendingPreparations: [] } - } - return { released: true, pendingPreparations: [...claim.pendingPreparations.values()] } + return registerClaim(entry, requestedKey) } export function startPreparation( @@ -202,6 +164,9 @@ export function startPreparation( args.options.wslDistro ?? '' ) if (existing) { + if (kind === 'explicit') { + existing.activity.requestedByPrefetch() + } return args.beforeMaterialization ? refreshPreparationTip(existing, args.beforeMaterialization) : existing.ready @@ -209,18 +174,18 @@ export function startPreparation( if (deferPreparationForClaim(args, kind)) { return Promise.resolve() } - return worktreePreparationGit.run(() => startBackgroundPreparation(args)) + return worktreePreparationGit.run(() => startBackgroundPreparation({ ...args, kind })) } function refreshPreparationTip( entry: PreparationEntry, beforeMaterialization: Promise ): Promise { - return queuePreparedWorktreeTipRefresh( + const ready = queuePreparedWorktreeTipRefresh( entry, () => { const available = preparations.get(entry.key) === entry - if (!available && ![...claims].some((claim) => claim.entry === entry)) { + if (!available && !isClaimed(entry)) { return } if (available) { @@ -231,6 +196,8 @@ function refreshPreparationTip( }, beforeMaterialization ) + entry.activity.track(ready) + return ready } function startBackgroundPreparation({ @@ -239,8 +206,9 @@ function startBackgroundPreparation({ baseBranch, canonicalBase, options, - beforeMaterialization -}: StartPreparationArgs): Promise { + beforeMaterialization, + kind +}: StartPreparationArgs & { kind: DeferredPreparation['kind'] }): Promise { const repoPathKey = preparationPathKey(repoPath) const workspaceRootKey = preparationPathKey(workspaceRoot) const wslDistro = options.wslDistro ?? '' @@ -273,6 +241,7 @@ function startBackgroundPreparation({ expiration, controller, checkoutStarted: false, + activity: createPreparationActivity(kind), ready: Promise.resolve() } entry.ready = (async () => { @@ -303,6 +272,7 @@ function startBackgroundPreparation({ } })() preparations.set(key, entry) + entry.activity.track(entry.ready) void entry.ready.catch(() => { if (preparations.get(key) === entry) { preparations.delete(key) @@ -315,7 +285,7 @@ function startBackgroundPreparation({ export async function _resetPreparationPoolForTests(): Promise { const entries = [...preparations.values()] preparations.clear() - claims.clear() + resetClaimsForTests() await resetStalePreparationCleanupForTests() await Promise.all( entries.map(async (entry) => { diff --git a/src/main/worktree-create-preparation-reservation.ts b/src/main/worktree-create-preparation-reservation.ts new file mode 100644 index 00000000000..137e5f31477 --- /dev/null +++ b/src/main/worktree-create-preparation-reservation.ts @@ -0,0 +1,96 @@ +import type { PreparedCheckoutMissReason } from '../shared/worktree/create-types' +import type { AddWorktreeOptions } from './git/worktree' +import { measureRetargetDivergence } from './git/worktree-base-divergence' +import { resolveLocalWorktreeBaseRef } from './git/worktree-base-ref-probe' +import { preparationPathKey, selectPreparationForCreate } from './worktree-create-preparation-claim' +import { + listPreparations, + takePreparation, + type PreparationClaim, + type PreparationEntry +} from './worktree-create-preparation-pool' + +export function canonicalBaseRef( + repoPath: string, + baseBranch: string, + options: AddWorktreeOptions +): Promise { + return resolveLocalWorktreeBaseRef(repoPath, baseBranch, { + ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}), + ...(options.admissionTier ? { admissionTier: options.admissionTier } : {}) + }) +} + +export type ReservedPreparation = { + entry: PreparationEntry + reservation: PreparationClaim + retargeted: boolean + canonicalBase: string +} + +/** Selection through take, so the take stays in the same synchronous run as the last selection. */ +export async function reservePreparation( + request: { repoPath: string; workspaceRoot: string; baseBranch: string }, + options: AddWorktreeOptions +): Promise< + | ({ status: 'reserved' } & ReservedPreparation) + | { status: 'miss'; reason: PreparedCheckoutMissReason } +> { + const key = { + repoPathKey: preparationPathKey(request.repoPath), + workspaceRootKey: preparationPathKey(request.workspaceRoot), + wslDistro: options.wslDistro ?? '', + baseBranch: request.baseBranch + } + let selection = selectPreparationForCreate(listPreparations(), { ...key, canonicalBase: null }) + if (selection.kind === 'needs-canonical-base') { + // The probe is the only await here, and the pool is re-read after it, so the select-and-take + // below stays one synchronous run and no other create can hold the same entry. + const canonicalBase = await canonicalBaseRef(request.repoPath, request.baseBranch, options) + selection = selectPreparationForCreate(listPreparations(), { ...key, canonicalBase }) + } + if (selection.kind !== 'exact' && selection.kind !== 'retarget') { + return { + status: 'miss', + reason: selection.kind === 'miss' ? selection.reason : 'base_mismatch' + } + } + if (selection.kind === 'retarget') { + const candidate = selection.candidate + const { canonicalBase } = selection + const divergence = await measureRetargetDivergence( + request.repoPath, + candidate.canonicalBase, + canonicalBase, + { + ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}), + ...(options.admissionTier ? { admissionTier: options.admissionTier } : {}), + // Why forward it: a cancelled create must stop these probes now, not at the deadline. + ...(options.signal ? { signal: options.signal } : {}) + } + ) + if (divergence !== 'within') { + return { + status: 'miss', + reason: divergence === 'exceeded' ? 'retarget_too_divergent' : 'retarget_unverifiable' + } + } + // Re-select after the walk: the pool may have gained an exact match or lost this entry. A + // different retarget candidate is left for the next create rather than claimed unverified. + selection = selectPreparationForCreate(listPreparations(), { ...key, canonicalBase }) + if (selection.kind === 'miss' || selection.kind === 'needs-canonical-base') { + return { status: 'miss', reason: 'base_mismatch' } + } + if (selection.kind === 'retarget' && selection.candidate !== candidate) { + return { status: 'miss', reason: 'base_mismatch' } + } + } + const entry = selection.candidate + return { + status: 'reserved', + entry, + reservation: takePreparation(entry, selection.canonicalBase), + retargeted: selection.kind === 'retarget', + canonicalBase: selection.canonicalBase + } +} diff --git a/src/main/worktree-create-preparation-timing.test.ts b/src/main/worktree-create-preparation-timing.test.ts index bd78f506201..3de0e64ceab 100644 --- a/src/main/worktree-create-preparation-timing.test.ts +++ b/src/main/worktree-create-preparation-timing.test.ts @@ -6,7 +6,8 @@ const mocks = vi.hoisted(() => ({ prepare: vi.fn(), finalize: vi.fn(), discard: vi.fn(), - listWorktreeGraph: vi.fn() + listWorktreeGraph: vi.fn(), + resolveBaseRef: vi.fn() })) vi.mock('node:fs/promises', () => ({ mkdir: mocks.mkdir })) @@ -17,7 +18,9 @@ vi.mock('./git/worktree-create-preparation', () => ({ discardPreparedWorktree: mocks.discard, unlockPreparedWorktree: vi.fn() })) -vi.mock('./git/worktree-base-ref-probe', () => ({ resolveLocalWorktreeBaseRef: vi.fn() })) +vi.mock('./git/worktree-base-ref-probe', () => ({ + resolveLocalWorktreeBaseRef: mocks.resolveBaseRef +})) vi.mock('./git/worktree-base-divergence', () => ({ measureRetargetDivergence: vi.fn() })) vi.mock('./project-runtime-git-options', () => ({ getLocalProjectWorktreeGitOptions: vi.fn(), @@ -34,6 +37,10 @@ import { } from './worktree-create-preparation' import { startPreparation } from './worktree-create-preparation-pool' import { createWorktreeCreateTimingRecorder } from './worktree-create-timing' +import { + _resetWorktreeCreateConcurrencyForTests, + beginWorktreeCreate +} from './worktree-create-concurrency' const request = { repoPath: '/repo', @@ -53,16 +60,23 @@ function prepare() { }) } +/** Lets the claim's own phase settle before the test clock moves on to the wait. */ +function settleClaim(): Promise { + return new Promise((resolve) => setImmediate(resolve)) +} + beforeEach(() => { mocks.mkdir.mockReset().mockResolvedValue(undefined) mocks.prepare.mockReset().mockResolvedValue(undefined) mocks.finalize.mockReset().mockResolvedValue({}) mocks.discard.mockReset().mockResolvedValue(undefined) mocks.listWorktreeGraph.mockReset().mockResolvedValue([]) + mocks.resolveBaseRef.mockReset() }) afterEach(async () => { await _resetWorktreeCreatePreparationsForTests() + _resetWorktreeCreateConcurrencyForTests() vi.restoreAllMocks() }) @@ -88,17 +102,26 @@ describe('prepared checkout create timing', () => { const create = timing.time('git_worktree_add', () => consumePreparedWorktreeCreate({ ...request, timing }) ) + await settleClaim() now = 150 checkout.resolve() await finalizeStarted.promise now = 180 finalize.resolve({}) - expect(await create).toMatchObject({ status: 'hit', retargeted: false }) + expect(await create).toMatchObject({ status: 'hit', retargeted: false, reset: 'none' }) await preparation expect(timing.finish()).toEqual({ totalDurationMs: 140, + preparedCheckout: { + status: 'hit', + reset: 'none', + origin: 'prefetch', + buildMs: expect.any(Number), + idleMs: 0 + }, phases: [ + { phase: 'prepared_checkout_claim', startedAtMs: 0, durationMs: 0 }, { phase: 'prepared_checkout_wait', startedAtMs: 0, durationMs: 110 }, { phase: 'prepared_checkout_finalize', startedAtMs: 110, durationMs: 30 }, { phase: 'git_worktree_add', startedAtMs: 0, durationMs: 140 } @@ -118,6 +141,7 @@ describe('prepared checkout create timing', () => { let now = 0 const timing = createWorktreeCreateTimingRecorder(() => now) const create = consumePreparedWorktreeCreate({ ...request, timing }) + await settleClaim() now = 250 checkout.reject(new Error('checkout failed')) @@ -128,12 +152,13 @@ describe('prepared checkout create timing', () => { }) await preparation expect(timing.finish().phases).toEqual([ + { phase: 'prepared_checkout_claim', startedAtMs: 0, durationMs: 0 }, { phase: 'prepared_checkout_wait', startedAtMs: 0, durationMs: 250 } ]) expect(mocks.finalize).not.toHaveBeenCalled() }) - it('records a failed finalization before its fallback cleanup', async () => { + it('times the cleanup of a failed finalization apart from the finalization', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}) await prepare() let now = 0 @@ -152,17 +177,152 @@ describe('prepared checkout create timing', () => { rearm: expect.any(Function) }) expect(timing.finish().phases).toEqual([ + { phase: 'prepared_checkout_claim', startedAtMs: 0, durationMs: 0 }, { phase: 'prepared_checkout_wait', startedAtMs: 0, durationMs: 0 }, - { phase: 'prepared_checkout_finalize', startedAtMs: 0, durationMs: 80 } + { phase: 'prepared_checkout_finalize', startedAtMs: 0, durationMs: 80 }, + { phase: 'prepared_checkout_discard', startedAtMs: 80, durationMs: 20 } ]) }) - it('does not report a preparation wait when no checkout was armed', async () => { + it('reports only the claim, and the miss, when no checkout was armed', async () => { const timing = createWorktreeCreateTimingRecorder(() => 0) expect(await consumePreparedWorktreeCreate({ ...request, timing })).toEqual({ status: 'miss', reason: 'none_armed' }) - expect(timing.finish().phases).toEqual([]) + expect(timing.finish()).toMatchObject({ + phases: [{ phase: 'prepared_checkout_claim', startedAtMs: 0, durationMs: 0 }], + preparedCheckout: { status: 'miss', reason: 'none_armed' } + }) + }) + + it('times a miss probe as the claim, not as the plain add after it', async () => { + await startPreparation({ + repoPath: request.repoPath, + workspaceRoot: request.workspaceRoot, + baseBranch: 'origin/release', + canonicalBase: 'refs/remotes/origin/release', + options: {} + }) + let now = 0 + const timing = createWorktreeCreateTimingRecorder(() => now) + mocks.resolveBaseRef.mockImplementation(async () => { + now = 400 + return 'refs/remotes/origin/main' + }) + + const attempt = await timing.time('git_worktree_add', async () => { + const result = await consumePreparedWorktreeCreate({ ...request, timing }) + now = 500 + return result + }) + + expect(attempt).toMatchObject({ status: 'miss', reason: 'base_mismatch' }) + expect(timing.finish().phases).toEqual([ + { phase: 'prepared_checkout_claim', startedAtMs: 0, durationMs: 400 }, + { phase: 'git_worktree_add', startedAtMs: 0, durationMs: 500 } + ]) + }) + + it('reports the reset a hit needed and who armed it', async () => { + await startPreparation( + { + repoPath: request.repoPath, + workspaceRoot: request.workspaceRoot, + baseBranch: request.baseBranch, + canonicalBase: 'refs/remotes/origin/main', + options: {} + }, + 'automatic' + ) + mocks.finalize.mockResolvedValue({ preparedHeadReset: true }) + const timing = createWorktreeCreateTimingRecorder(() => 0) + + const attempt = await consumePreparedWorktreeCreate({ ...request, timing }) + + expect(attempt).toMatchObject({ status: 'hit', reset: 'base_moved', origin: 'rearm' }) + expect(attempt.status === 'hit' && attempt.result).toEqual({}) + expect(timing.finish().preparedCheckout).toMatchObject({ + status: 'hit', + reset: 'base_moved', + origin: 'rearm' + }) + }) + + it('reports a re-arm the new-worktree UI then asked for as well', async () => { + const args = { + repoPath: request.repoPath, + workspaceRoot: request.workspaceRoot, + baseBranch: request.baseBranch, + canonicalBase: 'refs/remotes/origin/main', + options: {} + } + await startPreparation(args, 'automatic') + await startPreparation(args, 'explicit') + expect(mocks.prepare).toHaveBeenCalledOnce() + + const attempt = await consumePreparedWorktreeCreate({ ...request }) + + expect(attempt).toMatchObject({ status: 'hit', origin: 'rearm_then_prefetch' }) + }) + + it('reports how long the build took and how long it sat ready before the claim', async () => { + let now = 1_000 + vi.spyOn(performance, 'now').mockImplementation(() => now) + const checkout = Promise.withResolvers() + mocks.prepare.mockReturnValue(checkout.promise) + const preparation = prepare() + now = 4_000 + checkout.resolve() + await preparation + now = 9_000 + + const attempt = await consumePreparedWorktreeCreate({ ...request }) + + expect(attempt).toMatchObject({ status: 'hit', buildMs: 3_000, idleMs: 5_000 }) + }) + + it('reports no idle time when the create waited for the build', async () => { + let now = 1_000 + vi.spyOn(performance, 'now').mockImplementation(() => now) + const checkoutStarted = Promise.withResolvers() + const checkout = Promise.withResolvers() + mocks.prepare.mockImplementation(() => { + checkoutStarted.resolve() + return checkout.promise + }) + const preparation = prepare() + await checkoutStarted.promise + now = 2_000 + const create = consumePreparedWorktreeCreate({ ...request }) + await settleClaim() + now = 6_000 + checkout.resolve() + await preparation + + expect(await create).toMatchObject({ status: 'hit', buildMs: 5_000, idleMs: 0 }) + }) + + it('counts a building prepared checkout against other creates but not the one that used it', async () => { + const checkoutStarted = Promise.withResolvers() + const checkout = Promise.withResolvers() + mocks.prepare.mockImplementation(() => { + checkoutStarted.resolve() + return checkout.promise + }) + const bystander = beginWorktreeCreate() + const preparation = prepare() + await checkoutStarted.promise + const inFlight = beginWorktreeCreate() + const timing = createWorktreeCreateTimingRecorder(undefined, inFlight) + + const create = consumePreparedWorktreeCreate({ ...request, timing }) + await settleClaim() + checkout.resolve() + await preparation + + expect(await create).toMatchObject({ status: 'hit', origin: 'prefetch' }) + expect(inFlight.end().preparations).toBe(0) + expect(bystander.end().preparations).toBe(1) }) }) diff --git a/src/main/worktree-create-preparation.test.ts b/src/main/worktree-create-preparation.test.ts index 052181da228..cff625c14b1 100644 --- a/src/main/worktree-create-preparation.test.ts +++ b/src/main/worktree-create-preparation.test.ts @@ -131,6 +131,11 @@ describe('worktree create preparation registry', () => { ).resolves.toEqual({ status: 'hit', retargeted: true, + // The mocked finalize found the checkout already at the requested commit. + reset: 'none', + buildMs: expect.any(Number), + idleMs: expect.any(Number), + origin: 'prefetch', result: {}, rearm: expect.any(Function) }) @@ -213,6 +218,10 @@ describe('worktree create preparation registry', () => { ).resolves.toEqual({ status: 'hit', retargeted: false, + reset: 'none', + buildMs: expect.any(Number), + idleMs: expect.any(Number), + origin: 'prefetch', result: {}, rearm: expect.any(Function) }) @@ -543,6 +552,11 @@ describe('worktree create preparation registry', () => { ).resolves.toEqual({ status: 'hit', retargeted: false, + reset: 'none', + buildMs: expect.any(Number), + idleMs: expect.any(Number), + // Built by the burst re-arm after the second create; the prefetch after it asked for it too. + origin: 'rearm_then_prefetch', result: {}, rearm: expect.any(Function) }) diff --git a/src/main/worktree-create-preparation.ts b/src/main/worktree-create-preparation.ts index 0bca1160d8e..5ca98be283b 100644 --- a/src/main/worktree-create-preparation.ts +++ b/src/main/worktree-create-preparation.ts @@ -5,23 +5,28 @@ import type { Store } from './persistence' import type { Repo } from '../shared/repo-types' import { isFolderRepo } from '../shared/repo-kind' import { isWindowsAbsolutePathLike } from '../shared/cross-platform-path' -import type { PreparedCheckoutMissReason } from '../shared/worktree/create-types' +import type { + PreparedCheckoutMissReason, + PreparedCheckoutOrigin, + PreparedCheckoutReset +} from '../shared/worktree/create-types' +import type { WorktreeCreatePhase } from '../shared/worktree/create-timing-vocabulary' import type { AddWorktreeOptions, AddWorktreeResult } from './git/worktree' -import { measureRetargetDivergence } from './git/worktree-base-divergence' -import { resolveLocalWorktreeBaseRef } from './git/worktree-base-ref-probe' import { WorktreePreparationLockOwnershipError } from './git/worktree-preparation-lock' -import { preparationPathKey, selectPreparationForCreate } from './worktree-create-preparation-claim' import { _resetPreparationPoolForTests, hasPendingPreparations, - listPreparations, releasePreparationClaim, startPreparation, - takePreparation, type DeferredPreparation, type PreparationClaim, type PreparationEntry } from './worktree-create-preparation-pool' +import { + canonicalBaseRef, + reservePreparation, + type ReservedPreparation +} from './worktree-create-preparation-reservation' import { discardPreparedWorktree, finalizePreparedWorktree @@ -56,6 +61,10 @@ export type PreparedWorktreeCreateAttempt = | { status: 'hit' retargeted: boolean + reset: PreparedCheckoutReset + origin: PreparedCheckoutOrigin + buildMs: number + idleMs: number result: AddWorktreeResult /** Run after materialization/startup completes, before returning the create result. */ rearm: () => void @@ -70,18 +79,18 @@ type ConsumePreparedWorktreeArgs = { baseBranch: string refreshLocalBaseRef?: boolean options?: AddWorktreeOptions - timing?: Pick + timing?: Pick< + WorktreeCreateTimingRecorder, + 'time' | 'recordPreparedCheckout' | 'recordAdoptedPreparation' + > } -function canonicalBaseRef( - repoPath: string, - baseBranch: string, - options: AddWorktreeOptions -): Promise { - return resolveLocalWorktreeBaseRef(repoPath, baseBranch, { - ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}), - ...(options.admissionTier ? { admissionTier: options.admissionTier } : {}) - }) +function timePhase( + args: ConsumePreparedWorktreeArgs, + phase: WorktreeCreatePhase, + operation: () => Promise +): Promise { + return args.timing ? args.timing.time(phase, operation) : operation() } export function prepareWorktreeCreateForRepo( @@ -125,84 +134,26 @@ async function prepareWorktreeCreateInBackground( } type ClaimedPreparation = - | { - status: 'claimed' - entry: PreparationEntry - reservation: PreparationClaim - retargeted: boolean - canonicalBase: string - } + | ({ status: 'claimed'; claimedAt: number } & ReservedPreparation) | { status: 'miss'; reason: PreparedCheckoutMissReason; rearm?: () => void } async function claimPreparedWorktree( args: ConsumePreparedWorktreeArgs, options: AddWorktreeOptions ): Promise { - const request = { - repoPathKey: preparationPathKey(args.repoPath), - workspaceRootKey: preparationPathKey(args.workspaceRoot), - wslDistro: options.wslDistro ?? '', - baseBranch: args.baseBranch + // Timed on its own so a miss's probes and drift check are not read as the plain add's cost. + const reserved = await timePhase(args, 'prepared_checkout_claim', () => + reservePreparation(args, options) + ) + if (reserved.status === 'miss') { + return reserved } - let selection = selectPreparationForCreate(listPreparations(), { - ...request, - canonicalBase: null - }) - if (selection.kind === 'needs-canonical-base') { - // The probe is the only await here, and the pool is re-read after it, so the select-and-take - // below stays one synchronous run and no other create can hold the same entry. - const canonicalBase = await canonicalBaseRef(args.repoPath, args.baseBranch, options) - selection = selectPreparationForCreate(listPreparations(), { ...request, canonicalBase }) - } - if (selection.kind !== 'exact' && selection.kind !== 'retarget') { - return { - status: 'miss', - reason: selection.kind === 'miss' ? selection.reason : 'base_mismatch' - } - } - if (selection.kind === 'retarget') { - const candidate = selection.candidate - const { canonicalBase } = selection - const divergence = await measureRetargetDivergence( - args.repoPath, - candidate.canonicalBase, - canonicalBase, - { - ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}), - ...(options.admissionTier ? { admissionTier: options.admissionTier } : {}), - // Why forward it: a cancelled create must stop these probes now, not at the deadline. - ...(options.signal ? { signal: options.signal } : {}) - } - ) - if (divergence !== 'within') { - return { - status: 'miss', - reason: divergence === 'exceeded' ? 'retarget_too_divergent' : 'retarget_unverifiable' - } - } - // Re-select after the walk: the pool may have gained an exact match or lost this entry. A - // different retarget candidate is left for the next create rather than claimed unverified. - selection = selectPreparationForCreate(listPreparations(), { ...request, canonicalBase }) - if (selection.kind === 'miss' || selection.kind === 'needs-canonical-base') { - return { status: 'miss', reason: 'base_mismatch' } - } - if (selection.kind === 'retarget' && selection.candidate !== candidate) { - return { status: 'miss', reason: 'base_mismatch' } - } - } - const entry = selection.candidate - const reservation = takePreparation(entry, selection.canonicalBase) + const { entry, reservation } = reserved + args.timing?.recordAdoptedPreparation(entry.activity.work) + const claimedAt = performance.now() try { - await (args.timing - ? args.timing.time('prepared_checkout_wait', () => entry.ready) - : entry.ready) - return { - status: 'claimed', - entry, - reservation, - retargeted: selection.kind === 'retarget', - canonicalBase: selection.canonicalBase - } + await timePhase(args, 'prepared_checkout_wait', () => entry.ready) + return { ...reserved, status: 'claimed', claimedAt } } catch { return { status: 'miss', reason: 'prepare_failed', rearm: releaseClaimAfterCreate(reservation) } } @@ -267,6 +218,31 @@ function deferRearmPreparation( export async function consumePreparedWorktreeCreate( args: ConsumePreparedWorktreeArgs +): Promise { + const attempt = await attemptPreparedWorktreeCreate(args) + args.timing?.recordPreparedCheckout( + attempt.status === 'hit' + ? { + status: 'hit', + reset: attempt.reset, + origin: attempt.origin, + buildMs: attempt.buildMs, + idleMs: attempt.idleMs + } + : { status: 'miss', reason: attempt.reason } + ) + return attempt +} + +function preparedCheckoutReset(retargeted: boolean, headReset: boolean): PreparedCheckoutReset { + if (!headReset) { + return 'none' + } + return retargeted ? 'retargeted' : 'base_moved' +} + +async function attemptPreparedWorktreeCreate( + args: ConsumePreparedWorktreeArgs ): Promise { const options = args.options ?? {} const claim = await claimPreparedWorktree(args, options) @@ -275,37 +251,45 @@ export async function consumePreparedWorktreeCreate( } const { entry, reservation } = claim try { - const parentDir = isWindowsAbsolutePathLike(args.worktreePath) - ? win32.dirname(args.worktreePath) - : posix.dirname(args.worktreePath) - await mkdir(toHostFilesystemPath(parentDir), { recursive: true }) // Finalize resolves the requested base itself and resets the prepared checkout onto that // commit, so a retargeted claim is handed over at the requested commit or not at all. - const finalize = (): Promise => - finalizePreparedWorktree( - args.repoPath, - entry.preparedPath, - args.worktreePath, - args.branch, - args.baseBranch, - args.refreshLocalBaseRef, - options, - entry.lockReason - ) - const result = args.timing - ? await args.timing.time('prepared_checkout_finalize', finalize) - : await finalize() + const { preparedHeadReset, ...result } = await timePhase( + args, + 'prepared_checkout_finalize', + async () => { + const parentDir = isWindowsAbsolutePathLike(args.worktreePath) + ? win32.dirname(args.worktreePath) + : posix.dirname(args.worktreePath) + await mkdir(toHostFilesystemPath(parentDir), { recursive: true }) + return finalizePreparedWorktree( + args.repoPath, + entry.preparedPath, + args.worktreePath, + args.branch, + args.baseBranch, + args.refreshLocalBaseRef, + options, + entry.lockReason + ) + } + ) // Consuming the only prepared checkout leaves the next create cold. Re-arm for a user who is // creating in a burst; the TTL and the preparation limit still bound an unused replacement. const rearm = deferRearmPreparation(entry, reservation, args.baseBranch, claim.canonicalBase) - return { status: 'hit', retargeted: claim.retargeted, result, rearm } + return { + status: 'hit', + retargeted: claim.retargeted, + reset: preparedCheckoutReset(claim.retargeted, preparedHeadReset), + origin: entry.activity.origin(), + ...entry.activity.timesAt(claim.claimedAt), + result, + rearm + } } catch (error) { + // Another owner holds the preparation's lock, so it is not ours to remove. if (!(error instanceof WorktreePreparationLockOwnershipError)) { - await discardPreparedWorktree( - args.repoPath, - entry.preparedPath, - options, - entry.lockReason + await timePhase(args, 'prepared_checkout_discard', () => + discardPreparedWorktree(args.repoPath, entry.preparedPath, options, entry.lockReason) ).catch(() => {}) } console.warn( diff --git a/src/main/worktree-create-timing.test.ts b/src/main/worktree-create-timing.test.ts index 6ef4d23ff42..5fbb0663f4e 100644 --- a/src/main/worktree-create-timing.test.ts +++ b/src/main/worktree-create-timing.test.ts @@ -1,12 +1,17 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { createWorktreeCreateTimingRecorder } from './worktree-create-timing' +import { + _resetWorktreeCreateConcurrencyForTests, + beginPreparationWork, + beginWorktreeCreate +} from './worktree-create-concurrency' describe('createWorktreeCreateTimingRecorder', () => { it('records ordered phase timings and total duration', async () => { const samples = [100, 105, 112, 130, 144, 155] const recorder = createWorktreeCreateTimingRecorder(() => samples.shift() ?? 155) - const syncResult = recorder.timeSync('resolve_branch', () => 'branch') + const syncResult = recorder.timeSync('resolve_name', () => 'branch') const asyncResult = await recorder.time('git_worktree_add', async () => 'created') expect(syncResult).toBe('branch') @@ -14,9 +19,166 @@ describe('createWorktreeCreateTimingRecorder', () => { expect(recorder.finish()).toEqual({ totalDurationMs: 55, phases: [ - { phase: 'resolve_branch', startedAtMs: 5, durationMs: 7 }, + { phase: 'resolve_name', startedAtMs: 5, durationMs: 7 }, { phase: 'git_worktree_add', startedAtMs: 30, durationMs: 14 } ] }) }) + + it('carries the execution host and worktree count only once recorded', () => { + const recorder = createWorktreeCreateTimingRecorder(() => 0) + expect(recorder.finish()).not.toHaveProperty('executionHost') + expect(recorder.finish()).not.toHaveProperty('worktreeCount') + + recorder.recordExecutionHost('local') + recorder.recordExecutionHost('wsl') + recorder.recordWorktreeCount(0) + + expect(recorder.finish()).toMatchObject({ executionHost: 'wsl', worktreeCount: 0 }) + }) + + describe('failedPhase', () => { + function captureRejection(promise: Promise): Promise { + return promise.then( + () => { + throw new Error('expected a rejection') + }, + (error: unknown) => error + ) + } + + it('names the phase whose operation threw', async () => { + const recorder = createWorktreeCreateTimingRecorder(() => 0) + await recorder.time('resolve_name', async () => undefined) + const error = await captureRejection( + recorder.time('git_worktree_add', async () => { + throw new Error('boom') + }) + ) + + expect(recorder.failedPhase(error)).toBe('git_worktree_add') + // The phase that threw is still timed. + expect(recorder.finish().phases.map((phase) => phase.phase)).toEqual([ + 'resolve_name', + 'git_worktree_add' + ]) + }) + + it('is undefined for an error no phase threw', async () => { + const recorder = createWorktreeCreateTimingRecorder(() => 0) + await recorder.time('git_worktree_add', async () => undefined) + expect(recorder.failedPhase(new Error('outside'))).toBeUndefined() + expect(recorder.failedPhase('not an object')).toBeUndefined() + }) + + it('names the outermost phase an error propagated through', async () => { + const recorder = createWorktreeCreateTimingRecorder(() => 0) + const error = await captureRejection( + recorder.time('git_worktree_add', () => + recorder.time('prepared_checkout_wait', async () => { + throw new Error('prepare failed') + }) + ) + ) + + expect(recorder.failedPhase(error)).toBe('git_worktree_add') + }) + + it('reports untimed for a later throw after an inner failure was caught', async () => { + const recorder = createWorktreeCreateTimingRecorder(() => 0) + await recorder.time('git_worktree_add', async () => { + await recorder + .time('prepared_checkout_wait', async () => { + throw new Error('prepare failed') + }) + .catch(() => undefined) + }) + + expect(recorder.failedPhase(new Error('later, outside every phase'))).toBeUndefined() + }) + + it('names the rejecting phase when a concurrent sibling settles later', async () => { + const recorder = createWorktreeCreateTimingRecorder(() => 0) + let resolveSibling: () => void = () => {} + const sibling = recorder.time( + 'resolve_worktreeinclude', + () => + new Promise((resolve) => { + resolveSibling = resolve + }) + ) + const error = await captureRejection( + Promise.all([ + recorder.time('resolve_shared_directories', async () => { + throw new Error('shared dirs failed') + }), + sibling + ]) + ) + resolveSibling() + await sibling + + expect(recorder.failedPhase(error)).toBe('resolve_shared_directories') + }) + + it('follows the cause chain of an error wrapped outside the phase', async () => { + const recorder = createWorktreeCreateTimingRecorder(() => 0) + const inner = await captureRejection( + recorder.time('git_worktree_add', async () => { + throw new Error('old relay') + }) + ) + + expect(recorder.failedPhase(new Error('wrapped', { cause: inner }))).toBe('git_worktree_add') + }) + + it('stops walking a cyclic cause chain', () => { + const recorder = createWorktreeCreateTimingRecorder(() => 0) + const first = new Error('first') + const second = new Error('second', { cause: first }) + first.cause = second + + expect(recorder.failedPhase(first)).toBeUndefined() + }) + + it('names a sync phase that threw', () => { + const recorder = createWorktreeCreateTimingRecorder(() => 0) + let error: unknown + try { + recorder.timeSync('persist_metadata', () => { + throw new Error('disk full') + }) + } catch (caught) { + error = caught + } + + expect(recorder.failedPhase(error)).toBe('persist_metadata') + }) + }) + + describe('concurrency window', () => { + afterEach(() => { + _resetWorktreeCreateConcurrencyForTests() + }) + + it('closes at finish, so work the create starts afterwards is not counted', () => { + const inFlight = beginWorktreeCreate() + const recorder = createWorktreeCreateTimingRecorder(() => 0, inFlight) + const during = beginPreparationWork() + during.end() + recorder.finish() + beginPreparationWork() + beginPreparationWork() + expect(inFlight.end().preparations).toBe(1) + }) + + it('leaves out the prepared checkout the create adopted', () => { + const adopted = beginPreparationWork() + const inFlight = beginWorktreeCreate() + const recorder = createWorktreeCreateTimingRecorder(() => 0, inFlight) + recorder.recordAdoptedPreparation(adopted) + recorder.finish() + expect(inFlight.end().preparations).toBe(0) + }) + }) }) diff --git a/src/main/worktree-create-timing.ts b/src/main/worktree-create-timing.ts index bc1e49f4842..d04fb6640e8 100644 --- a/src/main/worktree-create-timing.ts +++ b/src/main/worktree-create-timing.ts @@ -1,18 +1,41 @@ import type { PreparedCheckoutOutcome, + WorktreeCreateExecutionHost, WorktreeCreateTiming, WorktreeCreateTimingPhase } from '../shared/worktree/create-types' +import type { WorktreeCreatePhase } from '../shared/worktree/create-timing-vocabulary' +import type { PreparationWork, WorktreeCreateInFlightHandle } from './worktree-create-concurrency' +import { wslDistroForCommand } from './git/command-runner/git-command-resolution' type TimingClock = () => number +const MAX_CAUSE_DEPTH = 5 + export type WorktreeCreateTimingRecorder = { - time(phase: string, operation: () => Promise): Promise - timeSync(phase: string, operation: () => T): T + time(phase: WorktreeCreatePhase, operation: () => Promise): Promise + timeSync(phase: WorktreeCreatePhase, operation: () => T): T recordPreparedCheckout(outcome: PreparedCheckoutOutcome): void + /** The prepared checkout this create claimed, so its build is not counted as competing work. */ + recordAdoptedPreparation(work: PreparationWork): void + recordExecutionHost(host: WorktreeCreateExecutionHost): void + recordWorktreeCount(count: number): void + /** The outermost phase this error (or one in its cause chain) propagated out of; undefined when none did. */ + failedPhase(error: unknown): WorktreeCreatePhase | undefined + /** Also closes the concurrency window, so work this create starts afterwards (its own re-arm) + * is not counted against it. */ finish(): WorktreeCreateTiming } +/** A local repo's create host by the Git routing rule: a \\wsl.localhost repo runs Git in WSL even + * without a WSL project runtime. */ +export function localWorktreeCreateExecutionHost(gitExecOptions: { + cwd?: string + wslDistro?: string +}): WorktreeCreateExecutionHost { + return wslDistroForCommand(gitExecOptions.cwd, gitExecOptions.wslDistro) ? 'wsl' : 'local' +} + function defaultClock(): number { return performance.now() } @@ -22,7 +45,7 @@ function clampDuration(value: number): number { } function createPhase( - phase: string, + phase: WorktreeCreatePhase, operationStartedAt: number, operationEndedAt: number, rootStartedAt: number @@ -35,29 +58,46 @@ function createPhase( } export function createWorktreeCreateTimingRecorder( - clock: TimingClock = defaultClock + clock: TimingClock = defaultClock, + inFlight?: WorktreeCreateInFlightHandle ): WorktreeCreateTimingRecorder { const startedAt = clock() const phases: WorktreeCreateTimingPhase[] = [] let preparedCheckout: PreparedCheckoutOutcome | undefined + let executionHost: WorktreeCreateExecutionHost | undefined + let worktreeCount: number | undefined + // Keyed by the thrown value, so a caught failure or a concurrent sibling cannot be misattributed. + const phaseByError = new WeakMap() - const recordPhase = (phase: string, operationStartedAt: number): void => { + const recordPhase = (phase: WorktreeCreatePhase, operationStartedAt: number): void => { phases.push(createPhase(phase, operationStartedAt, clock(), startedAt)) } + const recordFailure = (phase: WorktreeCreatePhase, error: unknown): void => { + // Outer phases settle after inner ones, so overwriting leaves the outermost that rethrew. + if (typeof error === 'object' && error !== null) { + phaseByError.set(error, phase) + } + } return { - async time(phase: string, operation: () => Promise): Promise { + async time(phase: WorktreeCreatePhase, operation: () => Promise): Promise { const operationStartedAt = clock() try { return await operation() + } catch (error) { + recordFailure(phase, error) + throw error } finally { recordPhase(phase, operationStartedAt) } }, - timeSync(phase: string, operation: () => T): T { + timeSync(phase: WorktreeCreatePhase, operation: () => T): T { const operationStartedAt = clock() try { return operation() + } catch (error) { + recordFailure(phase, error) + throw error } finally { recordPhase(phase, operationStartedAt) } @@ -65,11 +105,38 @@ export function createWorktreeCreateTimingRecorder( recordPreparedCheckout(outcome: PreparedCheckoutOutcome): void { preparedCheckout = outcome }, + recordAdoptedPreparation(work: PreparationWork): void { + inFlight?.adoptPreparation(work) + }, + recordExecutionHost(host: WorktreeCreateExecutionHost): void { + executionHost = host + }, + recordWorktreeCount(count: number): void { + worktreeCount = count + }, + failedPhase(error: unknown) { + // Bounded so a cyclic cause chain cannot loop. + let current = error + for (let depth = 0; depth < MAX_CAUSE_DEPTH; depth += 1) { + if (typeof current !== 'object' || current === null) { + return undefined + } + const phase = phaseByError.get(current) + if (phase) { + return phase + } + current = current instanceof Error ? current.cause : undefined + } + return undefined + }, finish() { + inFlight?.end() return { totalDurationMs: clampDuration(clock() - startedAt), phases: [...phases], - ...(preparedCheckout ? { preparedCheckout } : {}) + ...(preparedCheckout ? { preparedCheckout } : {}), + ...(executionHost ? { executionHost } : {}), + ...(worktreeCount !== undefined ? { worktreeCount } : {}) } } } diff --git a/src/main/worktree-preparation-discard-retry.ts b/src/main/worktree-preparation-discard-retry.ts index 69ea2c4edd3..c84895d18f3 100644 --- a/src/main/worktree-preparation-discard-retry.ts +++ b/src/main/worktree-preparation-discard-retry.ts @@ -2,6 +2,7 @@ import type { AddWorktreeOptions } from './git/worktree' import { discardPreparedWorktree } from './git/worktree-create-preparation' import { WorktreePreparationLockOwnershipError } from './git/worktree-preparation-lock' import { isOrphanedWorktreeError } from './ipc/worktree-logic' +import { trackPreparationWork } from './worktree-create-concurrency' import type { PreparationEntry } from './worktree-create-preparation-pool' // Stale cleanup only reclaims preparations whose owner pid is dead, so a discard that fails inside @@ -85,7 +86,8 @@ export function discardPreparationWithRetry( ): Promise { // Claim the record so an overlapping retry pass cannot run the same discard twice. pendingDiscards.delete(pendingKey(target)) - const discard = runDiscard(target, attempts) + // Counted as preparation work: removing a full tree competes with creates for the disk. + const discard = trackPreparationWork(runDiscard(target, attempts)) trackPreparationDiscard(discard) return discard } diff --git a/src/main/worktree-preparation-tip-refresh.test.ts b/src/main/worktree-preparation-tip-refresh.test.ts index cc22314a8b8..3604d04fb57 100644 --- a/src/main/worktree-preparation-tip-refresh.test.ts +++ b/src/main/worktree-preparation-tip-refresh.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import { flushBackgroundWork, mocks, repo, store } from './__mocks__/worktree-create-preparation' import { WorktreePreparationLockOwnershipError } from './git/worktree-preparation-lock' import { @@ -7,6 +7,7 @@ import { } from './worktree-create-preparation' import { listPreparations, + startPreparation, WORKTREE_CREATE_PREPARATION_TTL_MS } from './worktree-create-preparation-pool' @@ -223,3 +224,44 @@ describe('prepared checkout tip refresh', () => { } }) }) + +describe('prepared checkout timing across a tip refresh', () => { + it('keeps the re-arm build time and measures idle time from the refreshed ready', async () => { + let now = 0 + const clock = vi.spyOn(performance, 'now').mockImplementation(() => now) + onTestFinished(() => clock.mockRestore()) + const build = Promise.withResolvers() + mocks.prepareCheckout.mockReturnValueOnce(build.promise) + const armed = startPreparation( + { + repoPath: repo.path, + workspaceRoot: '/workspace', + baseBranch: 'origin/main', + canonicalBase: 'refs/remotes/origin/main', + options: {} + }, + 'automatic' + ) + await vi.waitFor(() => expect(mocks.prepareCheckout).toHaveBeenCalled()) + now = 40_000 + build.resolve() + await armed + + // The dialog opens a minute later and refreshes the spare once a 2 s fetch settles. + now = 100_000 + const fetch = Promise.withResolvers() + const refreshed = prepareWorktreeCreateForRepo(store, repo, 'origin/main', fetch.promise) + await flushBackgroundWork() + now = 102_000 + fetch.resolve() + await refreshed + now = 112_000 + + await expect(consume()).resolves.toMatchObject({ + status: 'hit', + origin: 'rearm_then_prefetch', + buildMs: 40_000, + idleMs: 10_000 + }) + }) +}) diff --git a/src/shared/telemetry-app-event-schemas.ts b/src/shared/telemetry-app-event-schemas.ts index 888eb389046..6d6c3e88282 100644 --- a/src/shared/telemetry-app-event-schemas.ts +++ b/src/shared/telemetry-app-event-schemas.ts @@ -20,6 +20,7 @@ import { requestKindSchema, workspaceSourceSchema } from './telemetry-property-schemas' +import { workspaceCreatedTimingProperties } from './telemetry-workspace-create-schemas' // ── Per-event schemas ─────────────────────────────────────────────────── @@ -103,7 +104,8 @@ export const workspaceCreatedSchema = z .object({ source: workspaceSourceSchema, from_existing_branch: z.boolean(), - nth_repo_added: nthRepoAddedSchema + nth_repo_added: nthRepoAddedSchema, + ...workspaceCreatedTimingProperties }) .strict() diff --git a/src/shared/telemetry-repository-event-schemas.ts b/src/shared/telemetry-repository-event-schemas.ts index f7be0dcff31..f0adde31b30 100644 --- a/src/shared/telemetry-repository-event-schemas.ts +++ b/src/shared/telemetry-repository-event-schemas.ts @@ -10,6 +10,7 @@ import { bucketNestedRepoTelemetryCount } from './nested-repo-telemetry' import { nthRepoAddedSchema } from './telemetry-app-event-schemas' +import { workspaceCreateFailedProperties } from './telemetry-workspace-create-schemas' import { addRepoDefaultCheckoutHandoffReasonSchema, addRepoDefaultCheckoutHandoffResultSchema, @@ -62,7 +63,8 @@ export const workspaceCreateFailedSchema = z .object({ source: workspaceSourceSchema, error_class: workspaceCreateErrorClassSchema, - nth_repo_added: nthRepoAddedSchema + nth_repo_added: nthRepoAddedSchema, + ...workspaceCreateFailedProperties }) .strict() diff --git a/src/shared/telemetry-workspace-create-schemas.test.ts b/src/shared/telemetry-workspace-create-schemas.test.ts new file mode 100644 index 00000000000..d7ad8277139 --- /dev/null +++ b/src/shared/telemetry-workspace-create-schemas.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest' +import { eventSchemas } from './telemetry-event-registry' +import { WORKTREE_CREATE_PHASES } from './worktree/create-timing-vocabulary' + +const created = eventSchemas.workspace_created +const failed = eventSchemas.workspace_create_failed + +const fullCreatedPayload = { + source: 'command_palette', + from_existing_branch: false, + nth_repo_added: 3, + total_ms: 8_412, + unattributed_ms: 311, + refresh_base_ref_ms: 2_040, + git_worktree_add_ms: 4_900, + prepared_checkout_claim_ms: 12, + prepared_checkout_wait_ms: 3_100, + prepared_checkout_finalize_ms: 120, + list_created_worktree_ms: 640, + spawn_startup_terminal_ms: 90, + prepared_checkout: 'hit', + prepared_checkout_reset: 'retargeted', + prepared_checkout_origin: 'rearm_then_prefetch', + prepared_checkout_build_ms: 41_000, + prepared_checkout_idle_ms: 0, + create_entry_point: 'runtime', + execution_host: 'wsl', + worktree_count_bucket: '301-1000', + repo_file_count_bucket: '100k-500k', + concurrent_creates: 2, + concurrent_preparations: 1, + post_checkout_hook: 'present' +} + +describe('workspace_created timing fields', () => { + it('still accepts the payload that predates the timing fields', () => { + expect(created.safeParse({ source: 'sidebar', from_existing_branch: true }).success).toBe(true) + }) + + it('accepts a full payload', () => { + expect(created.safeParse(fullCreatedPayload).success).toBe(true) + }) + + it('accepts a duration for every timed phase', () => { + const phases = Object.fromEntries(WORKTREE_CREATE_PHASES.map((phase) => [`${phase}_ms`, 1])) + expect(created.safeParse({ ...fullCreatedPayload, ...phases }).success).toBe(true) + }) + + it('accepts a miss with its reason', () => { + const parsed = created.safeParse({ + source: 'sidebar', + from_existing_branch: false, + prepared_checkout: 'miss', + prepared_checkout_miss_reason: 'none_armed' + }) + expect(parsed.success).toBe(true) + }) + + it.each([ + ['repo_path', '/Users/alice/secret-repo'], + ['branch', 'alice/feature'], + ['base_ref', 'origin/main'], + ['hook_content', '#!/bin/sh\ngit lfs post-checkout'], + ['fetch_ms', 10] + ])('rejects the unknown key %s via .strict()', (key, value) => { + expect(created.safeParse({ ...fullCreatedPayload, [key]: value }).success).toBe(false) + }) + + it.each([ + ['total_ms', '8412'], + ['total_ms', -1], + ['git_worktree_add_ms', 1.5], + ['concurrent_creates', -1], + ['concurrent_preparations', 0.5], + ['prepared_checkout', 'maybe'], + ['prepared_checkout_reset', 'hard'], + ['prepared_checkout_origin', 'dialog'], + ['create_entry_point', 'cli'], + ['repo_file_count_bucket', '12000'], + ['prepared_checkout_idle_ms', -5], + ['prepared_checkout_miss_reason', 'the branch alice/feature was missing'], + ['execution_host', 'docker'], + ['worktree_count_bucket', '812'], + ['post_checkout_hook', 'lfs'] + ])('rejects %s = %j', (key, value) => { + expect(created.safeParse({ ...fullCreatedPayload, [key]: value }).success).toBe(false) + }) +}) + +describe('workspace_create_failed timing fields', () => { + it('still accepts the payload that predates the timing fields', () => { + expect(failed.safeParse({ source: 'sidebar', error_class: 'git_failed' }).success).toBe(true) + }) + + it('accepts the failing phase and elapsed time', () => { + const parsed = failed.safeParse({ + source: 'sidebar', + error_class: 'git_failed', + failed_phase: 'git_worktree_add', + total_ms: 120_000, + create_entry_point: 'app', + execution_host: 'ssh', + concurrent_creates: 0, + concurrent_preparations: 0 + }) + expect(parsed.success).toBe(true) + }) + + it('accepts the prepared-checkout outcome and wait of the failed create', () => { + const parsed = failed.safeParse({ + source: 'sidebar', + error_class: 'git_failed', + failed_phase: 'git_worktree_add', + prepared_checkout: 'miss', + prepared_checkout_miss_reason: 'finalize_failed', + prepared_checkout_wait_ms: 209_000 + }) + expect(parsed.success).toBe(true) + }) + + it('accepts a failure outside every timed phase', () => { + const parsed = failed.safeParse({ + source: 'sidebar', + error_class: 'unknown', + failed_phase: 'untimed' + }) + expect(parsed.success).toBe(true) + }) + + it('rejects a phase outside the closed vocabulary', () => { + const parsed = failed.safeParse({ + source: 'sidebar', + error_class: 'git_failed', + failed_phase: 'fatal: could not create work tree' + }) + expect(parsed.success).toBe(false) + }) + + it.each([ + ['post_checkout_hook', 'present'], + ['repo_file_count_bucket', '<1k'], + ['git_worktree_add_ms', 10] + ])('rejects the success-only field %s via .strict()', (key, value) => { + const parsed = failed.safeParse({ source: 'sidebar', error_class: 'git_failed', [key]: value }) + expect(parsed.success).toBe(false) + }) +}) diff --git a/src/shared/telemetry-workspace-create-schemas.ts b/src/shared/telemetry-workspace-create-schemas.ts new file mode 100644 index 00000000000..f06f07b0bd1 --- /dev/null +++ b/src/shared/telemetry-workspace-create-schemas.ts @@ -0,0 +1,121 @@ +import { z } from 'zod' +import { + PREPARED_CHECKOUT_MISS_REASONS, + PREPARED_CHECKOUT_ORIGINS, + PREPARED_CHECKOUT_RESETS, + WORKSPACE_CREATE_ENTRY_POINTS, + WORKTREE_CREATE_EXECUTION_HOSTS, + WORKTREE_CREATE_PHASES, + type WorktreeCreatePhase +} from './worktree/create-timing-vocabulary' + +// Why numbers and closed enums only: these fields explain where create time went on real +// machines; a path, branch, ref or error string would identify the repo, so none may appear. +// Every field is optional: folder workspaces and creates outside the timed paths omit them. + +const durationMsSchema = z.number().int().nonnegative().optional() + +export const WORKTREE_COUNT_BUCKETS = [ + '1', + '2-5', + '6-20', + '21-100', + '101-300', + '301-1000', + '1001+' +] as const + +/** Files the main repo tracks (its index entry count); checkout cost grows with it, and these split + * small projects, typical apps, kernel-sized repos, large monorepos and the very largest. */ +export const REPO_FILE_COUNT_BUCKETS = ['<1k', '1k-10k', '10k-100k', '100k-500k', '500k+'] as const + +export const POST_CHECKOUT_HOOK_PRESENCE_VALUES = [ + 'present', + 'absent', + 'custom_hooks_path', + 'unknown' +] as const + +const phaseDurationProperties = { + resolve_name_ms: durationMsSchema, + refresh_base_ref_ms: durationMsSchema, + git_worktree_add_ms: durationMsSchema, + prepared_checkout_claim_ms: durationMsSchema, + prepared_checkout_wait_ms: durationMsSchema, + prepared_checkout_finalize_ms: durationMsSchema, + prepared_checkout_discard_ms: durationMsSchema, + list_created_worktree_ms: durationMsSchema, + persist_metadata_ms: durationMsSchema, + create_symlinks_ms: durationMsSchema, + resolve_shared_directories_ms: durationMsSchema, + resolve_worktreeinclude_ms: durationMsSchema, + create_shared_directories_ms: durationMsSchema, + copy_worktreeinclude_ms: durationMsSchema, + prepare_setup_ms: durationMsSchema, + spawn_startup_terminal_ms: durationMsSchema +} + +// Compile-time: one duration key per timed phase, no more and no fewer. +type _PhaseKeys = `${WorktreeCreatePhase}_ms` +type _PhaseDurationKeys = keyof typeof phaseDurationProperties +type _PhaseDurationSync = [_PhaseKeys] extends [_PhaseDurationKeys] + ? [_PhaseDurationKeys] extends [_PhaseKeys] + ? true + : never + : never +const _phaseDurationSyncCheck: _PhaseDurationSync = true +void _phaseDurationSyncCheck + +const countSchema = z.number().int().nonnegative().optional() + +/** Fields both events carry: which entry point ran, where Git ran, and what competed for the disk. */ +const workspaceCreateContextProperties = { + create_entry_point: z.enum(WORKSPACE_CREATE_ENTRY_POINTS).optional(), + execution_host: z.enum(WORKTREE_CREATE_EXECUTION_HOSTS).optional(), + /** Most other worktree creates running in this app at once during this one. */ + concurrent_creates: countSchema, + /** Most prepared-checkout builds and discards running at once during this create, not counting + * the prepared checkout this create used. */ + concurrent_preparations: countSchema +} + +/** The prepared-checkout outcome, sent on success and failure alike. */ +const preparedCheckoutProperties = { + prepared_checkout: z.enum(['hit', 'miss']).optional(), + /** Hit only: the reset the prepared checkout needed before it was handed over. */ + prepared_checkout_reset: z.enum(PREPARED_CHECKOUT_RESETS).optional(), + /** Hit only: the new-worktree UI's prefetch, the automatic burst replacement, or that + * replacement which the prefetch then asked for too. */ + prepared_checkout_origin: z.enum(PREPARED_CHECKOUT_ORIGINS).optional(), + /** Hit only: from the start of the prepared checkout's build to it being ready. */ + prepared_checkout_build_ms: durationMsSchema, + /** Hit only: how long it sat ready before this create claimed it; 0 when the create waited. */ + prepared_checkout_idle_ms: durationMsSchema, + prepared_checkout_miss_reason: z.enum(PREPARED_CHECKOUT_MISS_REASONS).optional() +} + +export const workspaceCreatedTimingProperties = { + total_ms: durationMsSchema, + /** Wall-clock time no timed phase covers. */ + unattributed_ms: durationMsSchema, + // The prepared_checkout_* phases run inside git_worktree_add, so the plain checkout is the + // add minus them. + ...phaseDurationProperties, + ...preparedCheckoutProperties, + ...workspaceCreateContextProperties, + worktree_count_bucket: z.enum(WORKTREE_COUNT_BUCKETS).optional(), + repo_file_count_bucket: z.enum(REPO_FILE_COUNT_BUCKETS).optional(), + post_checkout_hook: z.enum(POST_CHECKOUT_HOOK_PRESENCE_VALUES).optional() +} + +/** `untimed` = the create failed outside every timed phase. */ +export const WORKSPACE_CREATE_FAILED_PHASE_VALUES = [...WORKTREE_CREATE_PHASES, 'untimed'] as const + +export const workspaceCreateFailedProperties = { + failed_phase: z.enum(WORKSPACE_CREATE_FAILED_PHASE_VALUES).optional(), + /** Elapsed time from the start of the create to the failure. */ + total_ms: durationMsSchema, + ...preparedCheckoutProperties, + prepared_checkout_wait_ms: durationMsSchema, + ...workspaceCreateContextProperties +} diff --git a/src/shared/worktree/create-timing-vocabulary.ts b/src/shared/worktree/create-timing-vocabulary.ts new file mode 100644 index 00000000000..48283050eb6 --- /dev/null +++ b/src/shared/worktree/create-timing-vocabulary.ts @@ -0,0 +1,74 @@ +/** Every phase a worktree create times. Closed so phase names can become span attributes and + * event fields without ever carrying a branch name, a ref, or a path. */ +export const WORKTREE_CREATE_PHASES = [ + 'resolve_name', + 'refresh_base_ref', + 'git_worktree_add', + /** Picking and reserving a prepared checkout: base probe and drift check included. */ + 'prepared_checkout_claim', + 'prepared_checkout_wait', + 'prepared_checkout_finalize', + /** Removing a prepared checkout whose finalize failed, before the plain add runs. */ + 'prepared_checkout_discard', + 'list_created_worktree', + 'persist_metadata', + 'create_symlinks', + 'resolve_shared_directories', + 'resolve_worktreeinclude', + 'create_shared_directories', + 'copy_worktreeinclude', + 'prepare_setup', + 'spawn_startup_terminal' +] as const + +export type WorktreeCreatePhase = (typeof WORKTREE_CREATE_PHASES)[number] + +/** Closed vocabulary: these values reach span attributes, so none of them may ever + * be derived from a branch name, a ref, or a path. */ +export const PREPARED_CHECKOUT_MISS_REASONS = [ + 'none_armed', + /** Preparations exist, but none for this repo — it was never warmed, or the pool's size cap + * evicted it for another repo. Distinguished from `none_armed` because it is the signal that + * the cap is thrashing for a multi-project user. */ + 'repo_mismatch', + 'base_mismatch', + 'retarget_too_divergent', + /** The drift check returned no answer. Distinct from `retarget_too_divergent` because that one + * is the bound working as intended, while this one means a possibly cheap retarget was skipped + * anyway. Deliberately a mixed bucket — a blown deadline, a cancelled create, and an ordinary + * Git failure such as a missing ref all land here — so treat a rise as "look at why", not as a + * direct readout of the budget being too small. */ + 'retarget_unverifiable', + 'workspace_root_mismatch', + 'wsl_distro_mismatch', + 'prepare_failed', + 'finalize_failed', + 'checkout_existing_branch', + 'sparse_checkout' +] as const + +export type PreparedCheckoutMissReason = (typeof PREPARED_CHECKOUT_MISS_REASONS)[number] + +/** The reset a prepared-checkout hit needed: none, the same ref having moved since it was + * prepared, or a different ref in the same base family. */ +export const PREPARED_CHECKOUT_RESETS = ['none', 'base_moved', 'retargeted'] as const + +export type PreparedCheckoutReset = (typeof PREPARED_CHECKOUT_RESETS)[number] + +/** Who asked for the prepared checkout a hit used: the new-worktree UI's prefetch, the automatic + * replacement armed after an earlier create in a burst, or that replacement which the UI's + * prefetch then asked for too (the re-arm only gave it a head start). */ +export const PREPARED_CHECKOUT_ORIGINS = ['prefetch', 'rearm', 'rearm_then_prefetch'] as const + +export type PreparedCheckoutOrigin = (typeof PREPARED_CHECKOUT_ORIGINS)[number] + +/** Where the create's Git ran: the host itself, a WSL distro on it, or an SSH remote. */ +export const WORKTREE_CREATE_EXECUTION_HOSTS = ['local', 'wsl', 'ssh'] as const + +export type WorktreeCreateExecutionHost = (typeof WORKTREE_CREATE_EXECUTION_HOSTS)[number] + +/** Which create entry point ran: Orca's own window (`app`) or the runtime API that the CLI, + * agents, the phone app, paired clients and orchestration call (`runtime`). */ +export const WORKSPACE_CREATE_ENTRY_POINTS = ['app', 'runtime'] as const + +export type WorkspaceCreateEntryPoint = (typeof WORKSPACE_CREATE_ENTRY_POINTS)[number] diff --git a/src/shared/worktree/create-types.ts b/src/shared/worktree/create-types.ts index 5032df3924c..c15b73628b6 100644 --- a/src/shared/worktree/create-types.ts +++ b/src/shared/worktree/create-types.ts @@ -19,12 +19,25 @@ import type { WorktreeSetupLaunch, WorktreeStartupLaunch } from './launch-types' +import type { + PreparedCheckoutMissReason, + PreparedCheckoutOrigin, + PreparedCheckoutReset, + WorktreeCreateExecutionHost +} from './create-timing-vocabulary' import type { LocalBaseRefRefreshResult, LocalBaseRefUpdateSuggestion, WorktreeBaseStatusEvent } from './base-ref-drift-types' +export type { + PreparedCheckoutMissReason, + PreparedCheckoutOrigin, + PreparedCheckoutReset, + WorktreeCreateExecutionHost +} + export type SetupDecision = 'inherit' | 'run' | 'skip' export type WorktreeCreateTimingPhase = { @@ -33,40 +46,26 @@ export type WorktreeCreateTimingPhase = { durationMs: number } -/** Closed vocabulary: these values reach span attributes, so none of them may ever - * be derived from a branch name, a ref, or a path. */ -export type PreparedCheckoutMissReason = - | 'none_armed' - /** Preparations exist, but none for this repo — it was never warmed, or the pool's size cap - * evicted it for another repo. Distinguished from `none_armed` because it is the signal that - * the cap is thrashing for a multi-project user. */ - | 'repo_mismatch' - | 'base_mismatch' - | 'retarget_too_divergent' - /** The drift check returned no answer. Distinct from `retarget_too_divergent` because that one - * is the bound working as intended, while this one means a possibly cheap retarget was skipped - * anyway. Deliberately a mixed bucket — a blown deadline, a cancelled create, and an ordinary - * Git failure such as a missing ref all land here — so treat a rise as "look at why", not as a - * direct readout of the budget being too small. */ - | 'retarget_unverifiable' - | 'workspace_root_mismatch' - | 'wsl_distro_mismatch' - | 'prepare_failed' - | 'finalize_failed' - | 'checkout_existing_branch' - | 'sparse_checkout' - /** Whether a create reused a prewarmed checkout, and when it did not, which part of - * the claim key disagreed. `retargeted` marks a hit that had to reset the prepared - * checkout onto a different ref in the same base family. */ + * the claim key disagreed. A hit says what reset it needed, who armed it, how long it took + * to build and how long it then sat ready before this create claimed it. */ export type PreparedCheckoutOutcome = - | { status: 'hit'; retargeted: boolean } + | { + status: 'hit' + reset: PreparedCheckoutReset + origin: PreparedCheckoutOrigin + buildMs: number + idleMs: number + } | { status: 'miss'; reason: PreparedCheckoutMissReason } export type WorktreeCreateTiming = { totalDurationMs: number phases: WorktreeCreateTimingPhase[] preparedCheckout?: PreparedCheckoutOutcome + executionHost?: WorktreeCreateExecutionHost + /** Worktrees Git listed after the create, main checkout included; absent when the listing failed. */ + worktreeCount?: number } export type CreateSparseCheckoutRequest = { From d3e592365ef90c668a301c631ec7bbd397b850a4 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:05:00 -0400 Subject: [PATCH 11/21] ci(release): skip the orcad template for tags that predate it (#24872) A patch cut from a base older than #24155 has no orcad template source, so the template job could never pass and every desktop build waited on it. --- .github/workflows/release-cut.yml | 43 +++++++++++++++++-- .github/workflows/release-mac-build.yml | 2 + .../orcad-template-release-workflow.test.mjs | 38 ++++++++++++++++ 3 files changed, 79 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 4228b31fd21..f1334df6a9b 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -79,6 +79,7 @@ jobs: source_ref: ${{ steps.resolve.outputs.ref }} source_sha: ${{ steps.resolve.outputs.sha }} source_short_sha: ${{ steps.resolve.outputs.short_sha }} + ships_orcad_template: ${{ steps.orcad-template-support.outputs.ships }} steps: # Why inlined (not m-s-abeer/update-gha-summary-with-workflow-inputs): # this job runs with contents:write and secret scope, so avoid executing @@ -783,6 +784,21 @@ jobs: git push origin "$TAG" fi + # Why: a patch cut from a base older than #24155 has no orcad template to build or ship. + - name: Detect whether the tag ships the orcad template + id: orcad-template-support + if: steps.tag.outputs.tag != '' || steps.version.outputs.recovered_tag != '' + env: + TAG: ${{ steps.tag.outputs.tag || steps.version.outputs.recovered_tag }} + run: | + set -euo pipefail + if git cat-file -e "refs/tags/${TAG}^{commit}:config/scripts/packaged-orcad-template.cjs" 2>/dev/null; then + echo "ships=true" >>"$GITHUB_OUTPUT" + else + echo "ships=false" >>"$GITHUB_OUTPUT" + echo "::notice::$TAG predates the orcad template; skipping its build and download." + fi + - name: Release E2E signal summary if: always() run: | @@ -1154,7 +1170,7 @@ jobs: # signing quota, so it runs beside the release gates instead of behind them. orcad-template: needs: cut - if: needs.cut.outputs.should_release == 'true' + if: needs.cut.outputs.should_release == 'true' && needs.cut.outputs.ships_orcad_template == 'true' permissions: contents: read uses: ./.github/workflows/node-server-tests.yml @@ -1203,7 +1219,16 @@ jobs: - orcad-template - release-preflight - relay-windows-process-tree - if: needs.cut.outputs.should_release == 'true' + # Why not the implicit success(): a tag without the orcad template skips that job on purpose. + if: >- + !cancelled() && + needs.cut.result == 'success' && + needs.cut.outputs.should_release == 'true' && + needs.create-release.result == 'success' && + needs.release-preflight.result == 'success' && + needs.relay-windows-process-tree.result == 'success' && + (needs.orcad-template.result == 'success' || + (needs.orcad-template.result == 'skipped' && needs.cut.outputs.ships_orcad_template == 'false')) env: # beforePack and afterPack fail the package when the template is absent. ORCA_REQUIRE_ORCAD_TEMPLATE: '1' @@ -1463,6 +1488,7 @@ jobs: # After the app build so nothing that cleans out/ can drop it; electron-builder ships it. - name: Download the orcad deployment template + if: needs.cut.outputs.ships_orcad_template == 'true' uses: actions/download-artifact@v8 with: name: orcad-template @@ -1806,7 +1832,7 @@ jobs: # each file against the template manifest, so it must record the signed bytes. - name: Reseal the orcad template over its signed binaries id: reseal-orcad-template - if: matrix.platform == 'win' && github.run_attempt == 1 && steps.restore-signed-inner.outcome == 'success' + if: matrix.platform == 'win' && github.run_attempt == 1 && steps.restore-signed-inner.outcome == 'success' && needs.cut.outputs.ships_orcad_template == 'true' run: node config/scripts/packaged-orcad-template.cjs --reseal-signed dist/win-unpacked inner-signing-list.txt # The uninstaller must return signed before rebuilding the installer. @@ -2306,7 +2332,16 @@ jobs: - release-preflight # release-mac-build.yml downloads the relay addons from this run. - relay-windows-process-tree - if: needs.cut.outputs.should_release == 'true' + # Why not the implicit success(): a tag without the orcad template skips that job on purpose. + if: >- + !cancelled() && + needs.cut.result == 'success' && + needs.cut.outputs.should_release == 'true' && + needs.create-release.result == 'success' && + needs.release-preflight.result == 'success' && + needs.relay-windows-process-tree.result == 'success' && + (needs.orcad-template.result == 'success' || + (needs.orcad-template.result == 'skipped' && needs.cut.outputs.ships_orcad_template == 'false')) # Why: SignPath requires every job in this signing workflow to be # GitHub-hosted. The actual mac build runs in release-mac-build.yml so # Blacksmith stays outside Windows artifact provenance. diff --git a/.github/workflows/release-mac-build.yml b/.github/workflows/release-mac-build.yml index b8555ffbdeb..80694364bae 100644 --- a/.github/workflows/release-mac-build.yml +++ b/.github/workflows/release-mac-build.yml @@ -158,6 +158,8 @@ jobs: # Design D2: the parent release-cut run merged it from every node-server lane at this tag. - name: Download the orcad deployment template from the release run + # Why: release-cut skips the template for a tag that predates it. + if: hashFiles('config/scripts/packaged-orcad-template.cjs') != '' uses: actions/download-artifact@v8 with: name: orcad-template diff --git a/config/scripts/orcad-template-release-workflow.test.mjs b/config/scripts/orcad-template-release-workflow.test.mjs index eee8d451fc3..4ac9e3e27d7 100644 --- a/config/scripts/orcad-template-release-workflow.test.mjs +++ b/config/scripts/orcad-template-release-workflow.test.mjs @@ -123,6 +123,44 @@ describe('orcad template release wiring (design D2)', () => { expect(releaseMac.permissions.actions).toBe('read') }) + it('skips the template only for a tag that predates it', () => { + const cutSteps = releaseCut.jobs.cut.steps + const push = stepIndex(cutSteps, (step) => step.name === 'Push tag') + const detect = stepIndex(cutSteps, (step) => step.id === 'orcad-template-support') + expect(detect).toBeGreaterThan(push) + expect(cutSteps[detect].run).toContain(':config/scripts/packaged-orcad-template.cjs"') + expect(releaseCut.jobs.cut.outputs.ships_orcad_template).toBe( + '${{ steps.orcad-template-support.outputs.ships }}' + ) + expect(releaseCut.jobs['orcad-template'].if).toContain( + "needs.cut.outputs.ships_orcad_template == 'true'" + ) + + for (const name of ['build', 'build-mac']) { + const condition = releaseCut.jobs[name].if + // Every other dependency still has to succeed, as under the implicit success(). + for (const need of releaseCut.jobs[name].needs.filter((need) => need !== 'orcad-template')) { + expect(condition).toContain(`needs.${need}.result == 'success'`) + } + expect(condition).toContain("needs.orcad-template.result == 'success'") + expect(condition).toContain( + "(needs.orcad-template.result == 'skipped' && needs.cut.outputs.ships_orcad_template == 'false')" + ) + } + + const buildSteps = releaseCut.jobs.build.steps + for (const step of [ + buildSteps.find((step) => step.name === 'Download the orcad deployment template'), + buildSteps.find((step) => step.id === 'reseal-orcad-template') + ]) { + expect(step.if).toContain("needs.cut.outputs.ships_orcad_template == 'true'") + } + const macDownload = releaseMac.jobs['build-mac'].steps.find( + (step) => step.with?.name === 'orcad-template' + ) + expect(macDownload.if).toBe("hashFiles('config/scripts/packaged-orcad-template.cjs') != ''") + }) + it('signs only Windows template binaries and reseals the manifest before the installer rebuild', () => { const steps = releaseCut.jobs.build.steps const stage = steps.find((step) => step.id === 'stage-inner') From 44a2ee54bbdf5183c8fb438faaf70b1c8c6238aa Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:29:47 -0700 Subject: [PATCH 12/21] fix(runtime): make OMP 18.4 workers ready at the composer and keep stale-title clears display-only (#24295) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(runtime): require OMP composer readiness and retain native title evidence Keep timer-cleared titles in display state so they cannot settle an idle wait. Require OMP's captured empty composer through the existing screen rules. Co-authored-by: drakeo338 Co-authored-by: Ahmed Nagy Co-authored-by: Saurav M Hiremath * fix(runtime): veto OMP setup by screen and keep stale-title clears display-only OMP readiness no longer keys on the composer's thinking-effort hint, which OMP retires after a few uses, hides once a conversation exists and renders differently per glyph preset. Instead OMP's setup wizard (alternate screen or a "Setup step N of M" heading) vetoes every ready lane, and OMP's own `π >` idle title counts for the omp identity only once quiet on a screen read clear of the wizard. Other OMP titles take the general path again, so the post-turn `π -` title, 17.x `π:` titles and first-party blocked waits behave as before. `π >` is no longer explicit idle for every identity. The stale-working-title clear stays out of runtime records, but display readers (worktree ps, phone status and titles, terminal list, orchestration pointer, orphan adoption and reveal titles) now project records through one display accessor, so they show the cleared title and status again. A title the clear retired can no longer prove an agent is present; the foreground process must, which also covers the retained Cursor spinner, so the Cursor presence exclusion is reverted. * test(runtime): refuse OMP's setup splash, which has no step heading * test(runtime): a genuine title retires the stale-working display clear * test(daemon): record the OMP captures' inherited shrink cursor divergence * fix(runtime): settle OMP's idle title by its age and veto only its alternate screen OMP 18.4 re-asserts bracketed paste every second once a terminal answers its capability probe, which Orca's terminals do, so an idle OMP pane never went quiet and worker-start still timed out. Its idle title now counts once it has stood a quiescence window on a screen read clear of the setup wizard. The setup veto now reads only the alternate screen: the wizard always runs there, and a 'Setup step N of M' line on the normal screen is an answer's own text. * test(runtime): let the grid ingest OMP repaints before the title arrives * fix(runtime): keep the stale-title clear's process checks and record lifetime The stale-working-title clear is now an explicit branch instead of an early return. It still skips the title/status evidence readiness and delivery read, but runs what main's clear did to re-derive process state: the foreground-agent re-probe (an exited shell-launched agent loses its identity) and the completion-race exit check behind a fresh done hook. The clear now lives on the PTY record, so an SSH relay drop and reattach keeps the cleared display, and a recreated tracker is seeded from it. Presence judges a retired title by display ordering, so a renderer pane title older than the clear cannot prove an exited agent is present. * docs(runtime): OMP's idle title precedes its setup wizard by its startup tail * fix(runtime): judge agent presence by the displayed title while a clear stands A title the stale-working timer cleared used to veto presence outright, so a live, busy agent whose title cleared to its name and whose process Orca cannot recognize read as no agent, and the answer depended on whether a renderer pane was mounted. Presence and the terminal status query now read the display projection instead, which is what they read before the clear stopped rewriting records: a cwd spinner still clears to a neutral title, so the foreground process decides for an agent that exited behind it. * fix(runtime): compare a cleared title with a lifecycle cleared the same way The stale-working clear no longer writes the prompt lifecycle, so the blocked-dialog check compared a cleared title with a still-working lifecycle and ignored a trust or update dialog painted after the clear: terminal status said idle, the guarded send accepted, and a mounted pane's interactive wait went missing. The lifecycle is now projected through the clear wherever a cleared title is compared with it, as main recorded both together. That comparison can only report a wait on the user, never idle. * fix(runtime): verify prompts behind a stale-title clear against main's baseline Prompt-submission verification read the native lifecycle while a stale-working clear stood, so output after a swallowed Enter behind a stuck spinner counted as delivered, and the first spinner frame after the clear was not a new turn. Its starting status now goes through the same lifecycle projection the blocked-dialog check uses, and the first working status after a clear starts a new turn, as main recorded it. Readiness, delivery and the mailbox still read only native evidence. The blocked-dialog comment now says the projected pair reproduces main's verdict. --------- Co-authored-by: drakeo338 Co-authored-by: Ahmed Nagy Co-authored-by: Saurav M Hiremath --- .../serialize-grid-transcript-replay.test.ts | 7 +- .../omp-18-composer-narrow.meta.json | 15 + .../__fixtures__/omp-18-composer-narrow.txt | 70 ++++ .../__fixtures__/omp-18-composer.meta.json | 36 ++ .../runtime/__fixtures__/omp-18-composer.txt | 78 ++++ .../__fixtures__/omp-18-setup.meta.json | 15 + .../runtime/__fixtures__/omp-18-setup.txt | 84 ++++ ...nscript--omp-18-composer-narrow@agent.json | 15 + ...cript--omp-18-composer-narrow@unknown.json | 15 + .../transcript--omp-18-composer@agent.json | 19 + .../transcript--omp-18-composer@unknown.json | 19 + .../transcript--omp-18-setup@agent.json | 17 + .../transcript--omp-18-setup@unknown.json | 17 + ...nscript--omp-native-title-win32@agent.json | 8 +- ...cript--omp-native-title-win32@unknown.json | 8 +- .../agent-state-rules-engine.test.ts | 2 + .../agent-transcript-replay-test-harness.ts | 2 +- .../runtime/codex-quiet-ready-screen.test.ts | 8 + .../runtime/omp-readiness-transcripts.test.ts | 231 +++++++++++ src/main/runtime/omp-terminal-readiness.ts | 17 + ...e-adopt-terminal-orphans-from-inventory.ts | 2 + .../orca-runtime-apply-tracked-pty-title.ts | 43 +- ...orca-runtime-build-pty-terminal-summary.ts | 2 +- ...-runtime-emit-daemon-pty-transient-fact.ts | 27 +- .../runtime/orca-runtime-focus-terminal.ts | 2 +- ...a-runtime-get-terminal-interactive-wait.ts | 11 +- ...e-get-unpersisted-tracked-title-for-pty.ts | 5 +- .../runtime/orca-runtime-get-worktree-ps.ts | 1 + ...act-persisted-terminal-surface-identity.ts | 2 +- ...me-maybe-hydrate-headless-from-renderer.ts | 2 + ...e-prune-mobile-session-tab-group-layout.ts | 24 +- ...lish-pty-backed-mobile-session-terminal.ts | 2 +- ...ime-record-agent-prompt-lifecycle-state.ts | 9 +- ...-authoritative-terminal-wait-permission.ts | 23 ++ .../orca-runtime-resolve-exit-waiters.ts | 11 +- src/main/runtime/orca-runtime-runtime-id.ts | 6 +- ...ntime-serialize-agent-prompt-submission.ts | 21 +- .../orca-runtime-stop-requested-pty-ids.ts | 6 +- .../orca-runtime-visible-snapshot-preview.ts | 10 +- ...runtime-write-orchestration-pointer-pty.ts | 2 +- ...time-mobile-session-projection-contract.ts | 3 + .../runtime-mobile-session-projection.ts | 23 +- .../runtime-terminal-agent-presence.ts | 24 +- .../runtime-terminal-agent-status-query.ts | 39 +- .../runtime/runtime-terminal-idle-polls.ts | 8 +- .../runtime-terminal-orphan-adoption.ts | 5 +- ...untime-terminal-orphan-session-adoption.ts | 7 +- .../runtime/runtime-terminal-state-records.ts | 4 + src/main/runtime/runtime-terminal-wait.ts | 7 +- .../runtime/runtime-worktree-ps-activity.ts | 20 +- .../runtime-worktree-status-projection.ts | 101 ++++- src/main/runtime/screen-input-veto.ts | 23 ++ .../stale-title-completion-recovery.test.ts | 70 ++++ .../runtime/stale-title-display-clear.test.ts | 170 ++++++++ .../stale-title-display-surfaces.test.ts | 376 ++++++++++++++++++ .../structured-worker-agent-presence.test.ts | 3 +- src/main/runtime/tui-idle-evidence-source.ts | 148 +++++++ src/main/runtime/tui-idle-evidence.test.ts | 62 +++ src/main/runtime/tui-idle-evidence.ts | 158 ++------ src/main/runtime/tui-idle-hook-lane.test.ts | 2 + 60 files changed, 1922 insertions(+), 225 deletions(-) create mode 100644 src/main/runtime/__fixtures__/omp-18-composer-narrow.meta.json create mode 100644 src/main/runtime/__fixtures__/omp-18-composer-narrow.txt create mode 100644 src/main/runtime/__fixtures__/omp-18-composer.meta.json create mode 100644 src/main/runtime/__fixtures__/omp-18-composer.txt create mode 100644 src/main/runtime/__fixtures__/omp-18-setup.meta.json create mode 100644 src/main/runtime/__fixtures__/omp-18-setup.txt create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--omp-18-composer-narrow@agent.json create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--omp-18-composer-narrow@unknown.json create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--omp-18-composer@agent.json create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--omp-18-composer@unknown.json create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--omp-18-setup@agent.json create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--omp-18-setup@unknown.json create mode 100644 src/main/runtime/omp-readiness-transcripts.test.ts create mode 100644 src/main/runtime/omp-terminal-readiness.ts create mode 100644 src/main/runtime/screen-input-veto.ts create mode 100644 src/main/runtime/stale-title-completion-recovery.test.ts create mode 100644 src/main/runtime/stale-title-display-clear.test.ts create mode 100644 src/main/runtime/stale-title-display-surfaces.test.ts create mode 100644 src/main/runtime/tui-idle-evidence-source.ts diff --git a/src/main/daemon/serialize-grid-transcript-replay.test.ts b/src/main/daemon/serialize-grid-transcript-replay.test.ts index 28f0eed4459..b055aef804d 100644 --- a/src/main/daemon/serialize-grid-transcript-replay.test.ts +++ b/src/main/daemon/serialize-grid-transcript-replay.test.ts @@ -99,7 +99,12 @@ const KNOWN_PREEXISTING_I2_FAILURES: Record = { 'prime-agent-0-9-8-slash-menu': 34, 'prime-agent-0-9-8-tool-turn': 40, 'prime-agent-0-9-8-trace-question': 4, - 'prime-agent-0-9-8-turn-ended': 24 + 'prime-agent-0-9-8-turn-ended': 24, + // OMP 18.4.5 captures, serializer untouched: after a shrink the restored cursor sits one column + // short, as for Qoder. The unscrubbed captures diverge identically, so the scrub is not the cause. + 'omp-18-composer': 2, + 'omp-18-composer-narrow': 7, + 'omp-18-setup': 6 } // Exact resize checkpoints and full GridDiff hashes from base 6835b9b4e3ea, not this branch. diff --git a/src/main/runtime/__fixtures__/omp-18-composer-narrow.meta.json b/src/main/runtime/__fixtures__/omp-18-composer-narrow.meta.json new file mode 100644 index 00000000000..6c8c03b0465 --- /dev/null +++ b/src/main/runtime/__fixtures__/omp-18-composer-narrow.meta.json @@ -0,0 +1,15 @@ +{ + "capturedAt": "2026-10-01T05:58:52.162Z", + "platform": "darwin", + "command": [ + "/bin/zsh", + "-f", + "-c", + "cd && exec /dist/cli.js --no-session --no-extensions --no-skills --no-rules --no-lsp --no-prewalk" + ], + "cols": 60, + "rows": 24, + "note": "OMP 18.4.5; isolated HOME; no credentials; previously skipped setup; narrow 60x24 composer; updates disabled; command paths redacted; HOME/XDG/config isolated, no provider credentials; startup.checkUpdate=false, marketplace.autoUpdate=off; identifying cwd labels scrubbed with same-length replacements", + "exitCode": 129, + "sends": [] +} diff --git a/src/main/runtime/__fixtures__/omp-18-composer-narrow.txt b/src/main/runtime/__fixtures__/omp-18-composer-narrow.txt new file mode 100644 index 00000000000..f2f013a4f6b --- /dev/null +++ b/src/main/runtime/__fixtures__/omp-18-composer-narrow.txt @@ -0,0 +1,70 @@ +[?25l[?25l[?7l +╭─── omp v18.4.5 ────────────────────────────────────────╮ +│ │ Tips │ +│ Welcome back! │ # for prompt actions │ +│ │ / for commands │ +│ ████████████ │ ! to run bash │ +│ ██ ██ │ $ to run python │ +│ ██ ██ │ ────────────────────────────────── │ +│ ▒▒ ██ │ Recent sessions │ +│ ██ │ No recent sessions │ +│ │ │ +│  │ │ +│  │ │ +│ │ │ +╰───────────────────┴────────────────────────────────────╯ + Tip: Run `omp auth-broker serve` once and every machine +  pulls live tokens over the wire — refresh keys never +  leave the host; `omp auth-gateway` fronts it as a +  drop-in proxy any OpenAI-compatible client can hit + + + +╰─   [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?2004h[?1l>[?u]11;?_25a1;s\_tsp;q;{"q":"hello","v":[1],"app":"omp"}\[?2031h[?2026$p[?2048$p[?2031$p[?2004$p[?1010$p[?1011$p[?2;1;0S[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[>4;2m[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ────────────────────────────────── │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ────────────────────────────────── │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h]0;π >]0;π > capture-cwd[0 q[?25l[?7l +╭─── omp v18.4.5 ────────────────────────────────────────╮ +│ │ Tips │ +│ Welcome back! │ # for prompt actions │ +│ │ / for commands │ +│ ████████████ │ ! to run bash │ +│ ██ ██ │ $ to run python │ +│ ██ ██ │ ────────────────────────────────── │ +│ ▒▒ ██ │ Recent sessions │ +│ ██ │ No recent sessions │ +│ │ │ +│ Unknown │ │ +│ Unknown │ │ +│ │ │ +╰───────────────────┴────────────────────────────────────╯ + Tip: Run `omp auth-broker serve` once and every machine +  pulls live tokens over the wire — refresh keys never +  leave the host; `omp auth-gateway` fronts it as a +  drop-in proxy any OpenAI-compatible client can hit + + + π > ⬢ no-model > 📁 …XXXXXXXXXXX/capture-cwd > ◫ 6.4K/? ⟲ … +╰─  ⇧⇥ to change thinking effort [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │  environment variable. Then use /model to select a model.    π > ⬢ no-model > 📁 …XXXXXXXXXXX/capture-cwd > ◫ 6.4K/? ⟲ … ╰─  ⇧⇥ to change thinking effort [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ────────────────────────────────── │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ [?25h[?7h[?25l[?7l│ ██ ██ │ $ to run python │ │ ██ ██ │ ────────────────────────────────── │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ────────────────────────────────── │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ██ ██ │ $ to run python │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ [?25h[?7h[?25l[?7l│ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ [?25h[?7h[?25l[?7l│ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ [?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l│ ██ ██ │ ────────────────────────────────── │ [?25h[?7h[?25l[?7l│ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ [?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l│ ██ ██ │ ────────────────────────────────── │ [?25h[?7h[?25l[?7l│ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ [?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l +╭─── omp v18.4.5 ────────────────────────────────────────╮ +│ │ Tips │ +│ Welcome back! │ # for prompt actions │ +│ │ / for commands │ +│ ████████████ │ ! to run bash │ +│ ██ ██ │ $ to run python │ +│ ██ ██ │ ────────────────────────────────── │ +│ ▒▒ ██ │ Recent sessions │ +│ ██ │ No recent sessions │ +│ │ │ +│ Unknown │ │ +│ Unknown │ │ +│ │ │ +╰───────────────────┴────────────────────────────────────╯ + Tip: Run `omp auth-broker serve` once and every machine +  pulls live tokens over the wire — refresh keys never +  leave the host; `omp auth-gateway` fronts it as a +  drop-in proxy any OpenAI-compatible client can hit + + + Warning: No models available. Use /login or set an API key  + environment variable. Then use /model to select a model.  + + π > ⬢ no-model > 📁 …XXXXXXXXXXX/capture-cwd > ◫ 6.4K/? ⟲ … +╰─  ⇧⇥ to change thinking effort [?25h[?7h[?25l[?7l[?25h[?7h \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/omp-18-composer.meta.json b/src/main/runtime/__fixtures__/omp-18-composer.meta.json new file mode 100644 index 00000000000..8b68489410d --- /dev/null +++ b/src/main/runtime/__fixtures__/omp-18-composer.meta.json @@ -0,0 +1,36 @@ +{ + "capturedAt": "2026-10-01T05:52:51.152Z", + "platform": "darwin", + "command": [ + "/bin/zsh", + "-f", + "-c", + "cd && exec /dist/cli.js --no-session --no-extensions --no-skills --no-rules --no-lsp --no-prewalk" + ], + "cols": 120, + "rows": 40, + "note": "OMP 18.4.5; isolated HOME; no credentials; all five optional setup steps skipped with Esc; updates disabled; command paths redacted; HOME/XDG/config isolated, no provider credentials; startup.checkUpdate=false, marketplace.autoUpdate=off; identifying cwd labels scrubbed with same-length replacements", + "exitCode": 129, + "sends": [ + { + "atMs": 12000, + "text": "Esc" + }, + { + "atMs": 14000, + "text": "Esc" + }, + { + "atMs": 16000, + "text": "Esc" + }, + { + "atMs": 18000, + "text": "Esc" + }, + { + "atMs": 20000, + "text": "Esc" + } + ] +} diff --git a/src/main/runtime/__fixtures__/omp-18-composer.txt b/src/main/runtime/__fixtures__/omp-18-composer.txt new file mode 100644 index 00000000000..e7e7b97965a --- /dev/null +++ b/src/main/runtime/__fixtures__/omp-18-composer.txt @@ -0,0 +1,78 @@ +[?25l[?25l[?7l +╭─── omp v18.4.5 ──────────────────────────────────────────────────────────────────────────────────╮ +│ │ Tips │ +│ Welcome back! │ # for prompt actions │ +│ │ / for commands │ +│ ████████████ │ ! to run bash │ +│ ██ ██ │ $ to run python │ +│ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ +│ ▒▒ ██ │ Recent sessions │ +│ ██ │ No recent sessions │ +│ │ │ +│  │ │ +│  │ │ +│ │ │ +╰──────────────────────────┴───────────────────────────────────────────────────────────────────────╯ + Tip: Find out which model you emotionally abuse the most with `omp stats` + + + +╰─   [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?2004h[?1l>[?u]11;?_25a1;s\_tsp;q;{"q":"hello","v":[1],"app":"omp"}\[?2031h[?2026$p[?2048$p[?2031$p[?2004$p[?1010$p[?1011$p[?2;1;0S[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[>4;2m[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h]0;π >]0;π > capture-cwd[0 q[?25l[?7l +╭─── omp v18.4.5 ──────────────────────────────────────────────────────────────────────────────────╮ +│ │ Tips │ +│ Welcome back! │ # for prompt actions │ +│ │ / for commands │ +│ ████████████ │ ! to run bash │ +│ ██ ██ │ $ to run python │ +│ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ +│ ▒▒ ██ │ Recent sessions │ +│ ██ │ No recent sessions │ +│ │ │ +│ Unknown │ │ +│ Unknown │ │ +│ │ │ +╰──────────────────────────┴───────────────────────────────────────────────────────────────────────╯ + Tip: Find out which model you emotionally abuse the most with `omp stats` + + + π > ⬢ no-model > 📁 …6-09-28/XXXXXXXXXXXXXXXXXXX/capture-cwd > ◫ 6.4K/? ⟲ ▶──────────────────────────────────────────── +╰─  ⇧⇥ to change thinking effort [?25h[?7h[?25l[?1049h[>4;2m[?1000h[?1003h[?1006h[?25l[?25l[?7l✦ · ✧ ✦  + ✧  + · ✧ ✧ ·  + ✦ ✦ · ✧  +· ✦ ✦ ✧  +  + ████████████████████████ ✦  + · ████████████████████████ ✦  + ✦ ✦ ████ ████ ✦  + ████ ████ · ✧  + · ✧ ████ · ████ ✧  + · ✧ ████ ████ ✧  + ✦ ▒▒▒▒ ████ ✦  + ▒▒▒▒ ████ ✧  + ✧ ████  + ████  + ░ ░▒▒▒▒▓▓▓▓▒▒▒▒░░░ ░▒▓▓███████▓████████▓▒░ ░░▒▒▒▒▒▓▓▒▓▒▒▒▒░░ ░  + ░ ░░▒▒▒▒▓▓▓▒▒▒░░░ ░▒▓████████████████▓▒▒░ ░░░░▒▒▒▒▒▓▒▓▒▒▒▒░░ ░░  + ░░▒▒▒▓▓▓▒▒▒░░░ ░░▒▒▓█████████████▓▒▒░░ ░░░░░▒▒▒▒▒▒▓▒▒▒▒░ ░ ░  + ░░▒▒▒▒▓▓▒▒▒░░ ░░░░▒▒▓████████████▓▒▒░░ ░░░░░░▒▒▒▒▒▒▒▒▒▒▒░░ ░ ░░░  + ░░▒▒▒▒▒▒▒▒░ ░░░░▒▒▒▓▓▓▓███████▓▒▒░░░░ ░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░ ░░ ░  + ░░▒▒▒▒▒░░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒▓▓▓▒▒▒░░ ░ ░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░ ░░░  + ░░░▒▒▒▒░░░░░ ░░░░░▒▒░░▒░░░░░░░░▒░░ ░░░░░░░░░░░░▒▒▒▒▒▒▒░░ ░░ ░  + ░ ░░░▒▒▒▒▒▒░░░░░░░░░░▒░░░ ░░░░▒░░░░░░░▒▒▒▒▒▒▒▒░░ ░ ░░  + ░ ░ ░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░ ░░▒▒░░░░░░░▒░░▒▒▒▒▒░░ ░░  + ░ ░░▒▒▓▓▓▓▓▒▒▒▒░░░░░░ ░░▒▒▒▒▒░▒░░░░▒░▒▒▒▒▒░░░ ░  + ░░▒▒▓▓▓▓▓▒▒▒▒░░░░░░░ ░░░▒▒▒▒▒▒▒▒░░▒░░▒▒▒░░░░░░  + ░░▒▒▓▓▓▓▓▓▒▒▒░░░░░░░░░░░ ░░ ░▒▒▒▒▒▒▒▒▒▒▒░░░▒░░░▒▒░░░░░  + ░░▒▒▒▓▒▓▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒▒░░░░▒▒▒░░▒░░░  + ░░░░▒▒▒▒▒▒▒░░░░░▒▒▒▒▓▓▓▓▓▓▓███▓▓▓▓▒▒░▒░░░▒░▒░░░░░  + ░░░░░░░░░░░░░▒▒▓▓▓▓██████▓▓▓▒▒▒░░░░░░░░░░░░  + ░ ░░░░░░░░▒▒▒▒▓▓▓████▓▓▓▒▒░░░░░░░░░░░░░░  + ░░ ░ ░ ░░░░░▒▒▒▒▒▓▓▓▓▓▒▒▒░░░ ░ ░  + ░░░░░░░ ░░░░░░░▒▒▒▒▒▒░░░  + ░ ░░ ░░░░░ ░░░ ░░░ ░░░░  + ░ ░░ ░ ░  + ░ ░  + ░░░ ░ ░ ░ ░░ ░ ░ ░░  + ░ ░░░░░░░ ░░░░░ ░░░░ ░ ░ ░░░░░░ ░  + ░░░░░░░░░░░ ░░ press ⏎ to skip ░░░░░░░░░ ░ ░░ ░  [?7h[?25l[?7l ████████████████████████ ✦   · ████████████████████████ ✦   ✦ ✦ ████ ████ ✦   ████ ████ · ✧   · ✧ ████ · ████ ✧   · ✧ ████ ████ ✧   ✦ ▒▒▒▒ ████ ✦   ✧ ████   ████   ░ ░░▒▒▒▒▓▓▓▓▒▒▒▒░░░ ▒▒▓▓██████▓▓████████▓▒░░ ░░▒▒▒▒▒▓▒▓▒▒▒▒▒░░   ░ ░░▒▒▒▒▒▓▒▒▒▒░░░ ░▒▓████████████████▓▒▒░ ░░░▒▒▒▒▒▓▒▓▒▒▒▒░░ ░   ░░▒▒▒▓▓▒▒▒▒░░░ ░▒▒▓██████████████▓▓▒░░ ░░░░░▒▒▒▒▒▓▒▒▒▒░░ ░   ░░▒▒▒▒▓▓▒▒▒░░ ░░░▒▒▓████████████▓▒▒░░░░ ░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒░ ░░░   ░▒▒▒▒▒▒▒▒▒░ ░░░░▒▒▒▓▓▓████████▓▓▒░░░░ ░░░░░░░░░▒░▒▒▒▒▒▒▒▒░░ ░   ░░▒▒▒▒▒▒░░ ░ ░░░░▒▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒░░░░ ░ ░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░ ░░   ░░░▒▒▒▒░░░░ ░░░░▒▒░▒▒░░░░░░▒▒▒░░ ░░░░░░░░░░░░▒▒▒▒▒▒▒▒░░ ░░ ░   ░ ░░░░▒▒▒▒░░░░░░░░░░░░▒░░░░ ░░░░▒░░░░░░░░▒░▒▒▒▒▒░░ ░ ░░   ░ ░ ░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░ ░░▒░░░░░░░░░░░▒▒▒▒▒░░░ ░░   ░ ░░▒▒▓▓▒▒▒▒▒░▒░░░░░░ ░░░▒▒▒▒░▒░░░░░░▒▒▒▒▒░░░░ ░   ░▒▒▒▓▓▓▓▓▒▒▒▒░░░░░░░ ░░▒▒▒▒▒▒▒▒░░░░░░▒▒░░░░░░░   ░░▒▒▓▓▓▓▓▒▒▒▒░░░░░░░░░░ ░░▒▒▒▒▒▒▒▒▒▒░░░░░░░▒▒░░░░░░   ░░░▒▒▒▓▒▓▒▒▒░░░░░░░░▒░░▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒░░░░▒▒▒░░▒░░░░   ░░░░▒▒▒▒▒▒▒░░░░░▒▒▒▒▓▓▓▓▓▒▓▓▓▓▓▓▓▓▒▒░▒░░░░░▒▒░░░░   ░░░░░░░░░░░░░░▒▒▓▓▓▓██▓███▓▓▓▒▒▒▒░░░░░░░░░░░   ░░░░░░░░▒▒▒▓▓▓██████▓▓▒▒▒░░░░░░░░░░░░░   ░░ ░ ░░░░░░▒▒▒▒▓▓▓▓▓▓▓▒▒▒░░ ░░░░   ░ ░░░ ░░░░░░▒▒▒▒▒▒▒▒░░ ░   ░░ ░░░░░ ░░░ ░░░ ░░░░░   ░ ░░░ ░ ░   ░   ░ ░ ░░   ░ ░░░ ░ ░░░░░ ░░ ░ ░ ░ ░ ░░   ░░░░░░░░░░░ ░░ press ⏎ to skip ░░░░░░░░░ ░░ ░  [?7h[?25l[?7l ████████████████████████ ✦   · ████████████████████████ ✦   · ✧ ████ · ████ ✧   · ✧ ████ ████ ✧   ✦ ▒▒▒▒ ████ ✦   ▒▒▒▒ ████ ✧   ░ ░░▒▒▒▒▒▓▒▒▒▒▒▒░░░ ░▒▒▓███████▓▒▓████████▓▒░ ░░░▒▒▒▒▓▒▓▒▓▒▒▒░░   ░░▒▒▒▒▒▒▓▒▒▒▒░░░ ░░▒▓█████████████████▓▒░ ░░░░▒▒▒▒▒▒▓▒▒▒▒░░   ░░░▒▒▒▓▓▒▒▒▒░░░ ░▒▒▓███████████████▓▒▒░░ ░░░░░▒▒▒▒▒▓▒▒▒▒░░ ░   ░░░▒▒▓▒▓▓▒▒▒░░ ░░░▒▒▓████████████▓▓▒░░░░ ░░░░░░░▒▒▒▒▒▒▒▒▒▒▒░░ ░   ░░▒▒▒▒▒▒▒▒░░ ░░░░▒▒▒▓▓▓████████▓▓▒▒░░░ ░░░░░░░░░░░▒▒▒▒▒▒▒▒▒░ ░   ░░░░▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▒▒▓▓▓▓██▓▓▒▒░░░ ░ ░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒░░ ░ ░   ░░░▒▒▒▒░░░ ░░░▒▒▒▒▒░░░░░▒▒▒▒▒░░ ░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒░ ░░ ░   ░░░░▒▒▒▒░░░░░░░░░░░░▒░░░░ ░ ░░░░░░░░░░░░░▒░▒▒▒▒▒░░ ░░   ░ ░░░░▒▒▒▒▒▒▒░░░░░░░░░░░░ ░░░░░░░░░░░░░▒▒▒▒▒░░░░   ░ ░░░▒▒▒▒▒▒▒▒▒░▒░░░░░░ ░░▒▒░▒░░░░░░░░▒▒▒▒▒░░░░ ░   ░▒▒▒▓▓▒▓▒▒▒▒▒░░░░░ ░░▒▒▒▒▒▒▒░░░░░░▒▒░░░░░░░   ░░▒▒▒▓▓▓▓▓▒▒▒▒░░░░░░░ ░ ░░▒▒▒▒▒▒▒▒▒░░░░░░░▒▒░░░░░░   ░░░▒▒▓▓▒▓▒▒▒░░░░░░░░░░░▒░░░░▒▒▒▓▓▓▓▓▒▒▒░░░░░░░░░▒▒░░░░   ░░░▒▒▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▓▒▓▒▓▓▓▓▓▓▓▓▒▒▒▒░░░░░▒▒░░░░ ░   ░░░░░░░░░░░░░░░░▒▒▓▓▓▓█▓▓▓██▓▓▓▓▒▒▒░░░░░░░░░░░ ░   ░░░░░░░░░░▒▒▒▓▓▓██████▓▓▓▒▒▒░░░░░░░░░░░░░   ░░ ░░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▒▒░░░░░ ░░░░   ░ ░░░ ░░░░░░▒▒▒▒▒▒▒▒▒░░ ░   ░░ ░░░░░ ░░░░ ░░░░░░░░░░░   ░░░ ░   ░ ░ ░   ░ ░ ░ ░ ░░ ░ ░ ░ ░ ░ ░░   ░ ░░░░░░░░░░░ ░░ press ⏎ to skip ░░░░░░░░ ░░ ░ ░  [?7h[?25l[?7l ████████████████████████ ✦   · ████████████████████████ ✦   ▒▒▒▒ ████ ✧   ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░▒▒▓██████▓▓▒▓████████▓▒░░ ░░░▒▒▒▒▓▒▓▒▓▒▒▒░░   ░░▒▒▒▒▒▒▓▒▒▒▒░░░ ░▒▓▓████████▓████████▓▒▒░ ░░░▒▒▒▒▒▒▓▒▒▒▒▒░░   ░░▒▒▓▒▓▓▒▒▒▒░░░ ░░▒▒▓███████████████▓▓▒▒░ ░░░░░▒▒▒▒▒▓▒▒▒▒▒░░   ░░▒▒▒▓▓▓▓▒▒░░░ ░░▒▒▓▓█████████████▓▓▒░░░ ░ ░░░░░▒░▒▒▒▒▒▒▒▒▒░░ ░   ░░░▒▒▒▒▒▒▒▒░░ ░░░░▒▒▓▓███████████▓▒▒░░░ ░ ░░░░░░░░░▒▒▒▒▒▒▒▒▒░ ░   ░░░▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▒▓▓██████▓▓▒░░░ ░ ░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒░░ ░   ░░░▒▒▒▒▒░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▓▒▒░░ ░░░░░░░░░░░░░░░▒▒▒▒▒▒░░ ░░ ░   ░░░░░▒▒░░░░░ ░░░░░░░▒▒░░░░ ░░░░ ░░░░░░░░░░░░░░░▒▒▒▒▒▒░░ ░░   ░░░░▒▒▒▒░▒░░░░░░░░░░░░░ ░░░░░░░░░░░░░▒▒▒▒▒▒░░░░   ░░░▒▒▒▒▒▒▒░░░▒░░░░░░ ░░░▒░▒░░░░░░░░░▒▒▒▒░░░░░   ░░▒▒▒▓▓▒▒▒▒▒░▒░░░░░ ░░▒▒▒▒▒▒▒░░░░░░░▒░░░░░░░   ░░▒▒▒▓▓▓▓▓▒▒▒▒░░░░░░░ ░░░░▒▒▒▒▒▒▒░░░░░░░▒▒░░░░░░░   ░░▒▒▒▓▓▒▓▒▒▒░░░░░░░░░░░░░░░░░▒▒▒▒▓▓▓▒▒▒░░░░░░░░░▒▒▒░░░   ░░░▒▒▒▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒░░░░░▒▒░░░░░░   ░░░░░▒▒▒░░░░░░░░░▒▒▒▓▓▓▓▓▓▓██▓▓▓▓▒▒▒░░░░░░░░░░░░░   ░░░░░░░░░░░▒▒▒▓▓▓██▓███▓▓▓▓▒▒░░░░░░░░░░░░░   ░ ░ ░░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▒▒▒░░░░ ░ ░░░░   ░ ░░░ ░░░░░▒▒▒▒▒▒▓▒▒▒▒░░ ░   ░░ ░░░░░ ░░░░ ░░░░░▒░░▒░░   ░░░ ░ ░   ░     ░ ░ ░░ ░ ░ ░ ░░   ░ ░░░░░░░░░░░ ░░ press ⏎ to skip ░░░░░░░░ ░░ ░  [?7h[?25l[?7l ████████████████████████ ✦   · ████████████████████████ ✦   ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░ ░▒▓▓█████▓▓▒▒▒████████▓▒▒░ ░░▒▒▒▒▒▒▒▒▓▒▒▒░░░   ░░▒▒▒▒▒▒▓▒▒▒▒░░░ ░▒▓▓███████▓▓████████▓▓▒░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░   ░░▒▒▓▒▓▓▒▒▒▒░░░ ░░▒▒▓████████████████▓▒▒░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒░░   ░░▒▒▒▓▓▓▓▒░░░░ ░░▒▒▓██████████████▓▓▒░░░ ░░░░░▒░▒▒▒▒▒▒▒▒▒░░ ░   ░░▒▒▒▒▓▒▒▒▒░ ░░░░▒▒▓▓████████████▓▒▒▒░░ ░░ ░░░░░░░░░▒▒▒▒▒▒▒▒▒░░   ░░░▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▓▓▓███████▓▒▒░░░░ ░░░░░░░░░░░░░▒░▒▒▒▒▒▒░░ ░   ░░░▒▒▒▒▒░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▓▓▓▒▒░░░░ ░░░░░░░░░░░░░░░░▒▒▒▒▒░░░ ░   ░░▒░░▒░░░░ ░░░░░░▒▒░░░░░░░░░▒░░ ░░░░░░░░░░░░░░░░▒▒▒▒▒░░░ ░░   ░░░░▒▒▒▒░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░▒▒▒▒░░░░   ░░░▒▒▒▒▒▒▒░░░░░░░░░░ ░░▒░░░░░░░░░░░░▒▒▒░▒░░░   ░░▒▒▒▓▒▒▒▒▒▒░▒░░░░░ ░░░▒▒▒░░░░░░░░░░░░░░░░░   ░░▒▒▒▓▓▒▒▒▒▒▒░░░░░░░░ ░░░░▒▒▒▒▒▒░░░░░░░░▒░▒░░░░░   ░░▒▒▒▓▓▒▓▒▒░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒░░░   ░░░▒▒▒▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒▒░░░░░░▒░░░░░░   ░░░░░▒▒▒░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░   ░░░░░░░░░░░░▒▒▒▓▓▓▓█▓▓██▓▓▓▓▒▒░░░░░░░░░░░░░   ░ ░░░░░░░▒▒▒▓▓▓█▓▓▓▓▓▒▒▒▒░░░ ░ ░░░░░░   ░░ ░░░░░▒▒▒▒▓▓▓▓▒▒▒▒░░░ ░░░░   ░░ ░ ░░ ░░░░░░░░░░▒▒▒▒░░░   ░░░ ░ ░ ░ ░░   ░ ░ ░ ░ ░░   ░ ░░░░ ░░░░░░ ░░ press ⏎ to skip ░░░░░░░ ░  [?7h[?25l[?7l ████████████████████████ ✦   · ████████████████████████ ✦   ✦ ✦ ████ ████ ✦   ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▓█████▓▓▒▒▒▒▓▓███████▓▒░░ ░░░▒▒▒▒▒▒▒▓▒▒▒░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░▒▓████████▓▒▓████████▓▒░░ ░░░▒░▒▒▒▒▒▒▒▒▒▒▒░   ░░▒▒▓▒▓▓▒▒▒▒░░░ ░▒▒▒▓████████████████▓▒▒░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒░░   ░░▒▒▒▓▒▒▒▒░░░ ░░▒▒▓███████████████▓▒▒░░░░ ░░░░░░░▒▒▒▒▒▒▒▒▒▒░   ░░▒▒▓▒▓▒▒▒░░ ░░░░▒▒▓▓████████████▓▒▒▒░░░░░ ░░░░░░░░░▒░▒▒▒▒▒▒▒▒░░   ░░▒▒▒▓▒▒▒░░ ░░░░▒▒▒▒▓▓▓████████▓▒▒▒░░░ ░░░░░░░░░░░░░░░▒▒▒▒▒▒▒░░ ░   ░░░▒▒▒▒▒▒░░░ ░░░▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▒▒░░░░ ░░░░░░░░░░░░░░░░▒▒▒▒▒░░░ ░   ░░░░▒░░▒░░░ ░░░░░▒▒░░░░░░░▒▒▒░░ ░░░░░░░░░░░░░░░░▒▒▒▒▒░░░ ░   ░░░░▒░▒░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░▒▒▒░░░░░   ░░░░▒▒▒▒▒▒▒░░░░░░░░░░ ░░░░░░░░░░░░░░░▒░▒░▒░░░   ░░▒▒▒▒▒▒▒▒▒▒░░░░░░░ ░░░░░▒░░░░░░░░░░░░░░░░░░   ░░▒▒▒▓▓▒▒▒▒▒▒░░░░░ ░ ░░░░▒▒▒▒▒░░░░░░░░▒░░░░░░░░   ░░▒▒▒▓▓▒▓▒▒░░░░░░░░░░ ░ ░░░▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒░░░   ░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░▒▒░▒▒▒░▒░▒▒▒▓▒▓▓▓▒▒▒▒░░░░░░▒░░░░░░░   ░░░░▒▒▒▒▒░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░▒▒▒▓▓▓▓▓▓▓██▓▓▓▓▒▒▒▒░░░░░░░░░░░░   ░░ ░░░░░░░▒▒▒▓▓▓█▓▓▓▓▓▓▒▒▒░░░ ░ ░░░░░░   ░░ ░░░░░▒▒▒▓▓▓▓▓▓▓▒▒░░░ ░ ░░░░░   ░░ ░ ░░ ░░░░░░░░░▒▒▒▒▒▒▒░░░   ░░░ ░ ░░ ░░░░░     ░ ░   ░ ░░░░ ░ ░ ░░ press ⏎ to skip ░░░ ░ ░  [?7h[?25l[?7l ████████████████████████ ✦   · ████████████████████████ ✦   ✦ ✦ ████ ████ ✦   ████ ████ · ✧   · ✧ ████ · ████ ✧   · ✧ ████ ████ ✧   ✦ ▒▒▒▒ ████ ✦   ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░▒▓▓████▓▓▒▒░░▒▓▓███████▓▒▒░ ░░▒▒▒▒▒▒▒▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░▒▓███████▓▒▒▓▓███████▓▒▒░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒░   ░░▒▒▒▒▒▒▒▒░▒░░░ ░▒▒▓▓████████████████▓▓▒▒░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒░░   ░░▒▒▒▓▒▒▒▒░░░ ░░▒▓▓███████████████▓▒▒▒░░░ ░░░░░░░▒░▒▒▒▒▒▒▒▒░░   ░░▒▒▓▒▓▒▒▒░░ ░░░░▒▓▓█████████████▓▓▒▒░░░░░ ░░░░░░░░░░░▒▒▒▒▒▒▒▒░░   ░░░▒▒▒▓▒▒▒░░ ░░░░▒▒▒▒▓▓██████████▓▒▒▒░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒░░░   ░░░▒▒▒▒▒▒░░ ░░░▒▒▒▒▒▒▒▓▓▓▓███▓▓▒▒░░░ ░░░░░░░░░░░░░░░░▒▒▒▒▒░░░░ ░   ░░░▒▒▒░▒░░░ ░░░░░▒▒░░░░░░▒▒▒▒░░░░ ░░░░░░░░░░░░░░░░░▒▒▒▒▒░░   ░░░░▒░░░░░░░░░░░░░░░░░░░░ ░ ░░░░░░░░░░░░░░░░▒▒▒░░░░░   ░░░░░▒▒▒▒░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░▒░▒░░░   ░░░▒▒▒▒▒▒▒▒▒▒░░░░░░░ ░░░░▒░░░░░░░░░░░░░░░▒░░   ░░▒▒▒▓▓▒▒▒▒▒▒░░░░░ ░ ░░░▒▒▒▒▒░░░░░░░░░░░░▒░░░░   ░░▒▒▒▒▓▓▒▒▒▒░░░░░░░░░░ ░░▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒░░░░   ░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░▒░░░░░▒▒▒▒▒▓▓▓▒▒▒▒░░░░░░░░░▒▒░░░   ░░░▒░▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▓▒▒▒▓▓▓▓▓▓▓▒▒░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░   ░░░░░░░░░░░▒▒▒▒▓▓▓█▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░   ░ ░░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒░░░ ░ ░░░░░   ░░ ░ ░░░░░░░▒▒▒▒▒▒▒▒▒░░░   ░░░ ░ ░░░░░░░░░░   ░   ░ ░ ░ ░░ press ⏎ to skip ░░ ░ ░  [?7h[?25l[?7l ·   ✧ ✦ ✧   · ✧   ✧   ✦   ✧ · · ✧   ✦ ████████████████████████ ·   ✦ ████████████████████████   · ████✧ ████   ✦ ✦ · ████ ████   ✧ ████ ████   ████· ████   ✦ · ✧ ▒▒▒▒ ████ ✦ ✦ ·   ✧ ▒▒▒▒ ████ ✦ ✦   ✦ · ████   ████   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░ ░ ░░░▒▓▓█▓█▓▓▒▒▒░░▒▒▓████████▓▒░ ░░░▒▒▒▒▒▒▒▒▒▒▒░░   ░░░▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░▒▒▓█████▓▓▓▒▒▒▓████████▓▒░░ ░░░░▒▒▒▒▒▒▒▒▒▒░░   ░░░▒▒▒▒▒▒▒▒░▒░░░░ ░░▒▒▓▓███████▓▓▓███████▓▓▒░░ ░░░░░░▒▒▒▒▒▒▒▒▒░░   ░░▒▒▒▒▓▒▒▒▒░░░ ░░▒▒▓▓████████████████▓▒▒░░░ ░░░░░░░▒░▒▒▒▒▒▒▒▒▒░   ░▒▒▒▓▒▒▒▒░░░ ░░░░▒▓▓█████████████▓▓▒▒▒░░░░ ░░░░░░░░░░░▒░▒▒▒▒▒▒░░░   ░░▒▒▒▓▓▒▒░░░ ░░░░▒▒▒▒▓▓██████████▓▒▒▒░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒░░ ░░░▒▒▒▒▒▒▓▓▓▓████▓▓▒▒▒░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒░░░░   ░░▒▒▒▒░░░░ ░░░░░▒▒▒░▒▒▒▒▒▒▒▒▒▒░░ ░░░░░░░░░░░░░░░░░▒▒▒▒▒░░░   ░░░░▒░░░░░ ░░░░░░░░░░░ ░ ░░░ ░░░░░░░░░░░░░░░░▒▒░▒░░░░░   ░░░▒░░▒▒▒░░░░░░░░░░░░░ ░░░░░░░░░ ░░░░░░▒░▒░░░░   ░░░░▒▒▒▒▒▒▒▒░░░░░░░░░ ░░░░▒░░░░░░░░░░░░░░░▒░░░░   ░░▒▒▒▓▒▒▒▒▒▒░░░░░░ ░ ░░▒░▒▒▒░░░░░░░░░░░░▒░░░░   ░░▒▒▒▒▓▓▒▒▒▒░░░░░░░░░ ░░░▒▒▒▒▒▒░░░░░░░░░▒▒▒▒░░░░   ░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░▒▒▒▒▓▓▒▒▒▒▒░░░░░░░░░▒▒░░░░   ░░░▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒▒░░░░░░░░░▒░░░   ░░░░░▒░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░   ░░░░░░░░░░░░▒▒▒▒▓▓▓█▓▓▓▓▓▓▓▒▒░░░░░░░░░░░░░   ░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▒▒░░░░ ░ ░░░░░   ░░ ░ ░░░░░░▒▒▒▒▒▒▒▒▒▒▒░░░   ░░░ ░ ░░░░▒░▒▒░░   ░   ░░ press ⏎ to skip ░ ░ ░  [?7h[?25l[?7l ✦ ████████████████████████ ·   ✦ ████████████████████████   · ████✧ ████   ✦ ✦ · ████ ████   ✧ ████ ████   ████· ████   ✦ · ✧ ▒▒▒▒ ████ ✦ ✦ ·   ✦ · ████   ████   ░░░░▒▒░▒▒▒▒▒▒▒▒▒░░ ░ ░▒▒▒▓▓█▓▓▓▒▒▒░░░░▒▒▓███████▓▒▒░ ░░░▒▒▒▒▒▒▒▒▒▒▒░░   ░░░▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░▒▒▓████▓▓▓▒▒▒▒▒▓███████▓▒░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒░   ░░▒▒▒▒▒▒▒▒▒░▒░░░░ ░░▒▒▓▓███████▓▓▓███████▓▓▒░░ ░░░░░░▒▒▒▒▒▒▒▒▒▒░   ░░▒▒▒▒▒▒▒▒░░░░░ ░░▒▒▓▓████████████████▓▒▒▒░░ ░░░░░░░░▒▒▒▒▒▒▒▒▒░   ░▒▒▓▓▒▒▒▒░░░ ░░░░▒▓▓██████████████▓▒▒▒░░░░ ░░░░░░░░░░░░░▒▒▒▒▒▒░░░   ░▒▒▒▒▓▓▒▒░░ ░░░░▒▒▒▓▓███████████▓▓▒▒░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒░░   ░░▒▒▒▒▒▒▒░ ░░░▒▒▒▒▒▒▓▓▓██████▓▓▒▒▒░░░░░░░░░░░░░░░░░░░░░▒▒▒▒░░░   ░░░▒▒▒▒░░░ ░░░░░▒▒▒▒▒▒▒▒▒▓▓▓▒▒░░░ ░░░░░░░░░░░░░░░░░░░▒▒▒░░░   ░░░░▒░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░ ░░░░░░░░░░░░░░░░░▒░░░░░░░   ░░░░▒░░░░░░░░ ░░░░░░░░░ ░░░░░░░░░ ░░░░░░░░▒▒▒░░   ░░░░▒▒▒▒▒░░░░░░░░░░░░ ░░░▒░░░░░░░░░░░░░░░▒▒░░░   ░░░▒▒▒▒▒▒▒▒▒▒░░░░░░ ░░░▒▒▒░░░░░░░░░░░░▒░░░░░   ░▒▒▒▒▒▓▓▒▒▒▒░░░░░░░░ ░░░▒▒▒▒▒▒░░░░░░░░░░▒▒▒░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░ ░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒░░░░░   ░░▒▒▒▒▒▒▒▒░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▓▒▓▓▓▓▒▒▒▒░░░░░░░░▒░░░░   ░░░░▒░▒░▒░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░ ░   ░░░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░   ░ ░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▓▒▒▒░░░ ░ ░░░░░   ░ ░░░░░░▒▒▒▒▓▓▒▒▒▒▒▒░░   ░░░ ░ ░░░░░▒░▒▒▒░░░   ░     press ⏎ to skip ░ ░  [?7h[?25l[?7l ✦ ████████████████████████ ·   ✦ ████████████████████████   ✧ ████ ████   ████· ████   ✦ · ✧ ▒▒▒▒ ████ ✦ ✦ ·   ✧ ▒▒▒▒ ████ ✦ ✦   ░░░░░░░░░▒▒▒░░░░░░░░░▒▒▒▓▓▓█▓▓▒▒░░ ░░░▒▒▓███████▓▒▒░ ░░▒░▒▒▒▒▒▒▒▒▒▒░░   ░░░░░░▒░░░▒▒▒▒▒░░░░░░░░░▒▒▒▓▓▓▓▓▓▒▒░░░ ░░░▒▒▓██████▓▒▒░░░ ░░░░░░▒▒▒▒▒▒▒▒░░░   ░░░░░▒▒░▒▒░░░░░░░░░░ ░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▓▓█████▓▒▒░░ ░░░░░▒▒▒▒▒▒▒▒▒░░   ░░▒▒▒▒▒▒▒▒▒░░░░░ ░ ░ ░░▒▒▒▓▓█████▓▓▓▓▓▓▓████▓▓▓▒▒░░░░ ░░░░░░░░░░░▒▒▒▒▒░░░░   ░▒▒▒▒▒▒▒▒░░░░ ░░░▒▓▓████████████████▓▓▓▒▒░░░░░░░░░░░░░░░░░▒▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒░░ ░░░░▒▒▓▓█████████████▓▓▓▒▒░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒░░░ ░░░▒▒▒▒▓▓███████████▓▓▓▒▒░░░░░░░░░░░░░░░ ░░░░▒░░▒░░░   ░░▒▒▒▒▒▒░░░ ░░░░░▒▒▒▒▓▓▓█████▓▓▓▒▒░░░░░░░░░░░░░░░░░ ░░░░░▒░░░░   ░░▒▒▒▒▒░░░ ░░░░░░▒░▒░▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░░░░░░░░ ░ ░░░░░░░▒░░░   ░░░░▒░▒░░░░ ░░░░░░░░ ░ ░░░░░░ ░░░░░░░░ ░ ░░░░░░▒▒░░░   ░░░░░░░░░░ ░░░░░░░░░░░ ░░░░░░░░░ ░░░░▒░░░░   ░░░▒░▒▒▒▒░░░░░░░░░░░░ ░░░░▒░░░░░ ░░░░░░░▒░░░░░░   ░░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░ ░░░░░░░░░░░░░ ░░░░░▒░░░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░ ░░▒▒▒▒▒▒▒░░░░ ░░░░░▒░▒░░░░   ░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░   ░░░▒▒▒▒▒▒░▒░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▒▒▒▒▒░░░░░░░░░░░░░░░░   ░░░▒▒▒░░░░░░░░░░▒▒▒▒▓▒▓▓▒▒▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░ ░   ░░░░░░░ ░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░   ░░░░░▒▒▒▒▓▓▓▓▓▒▓▒▒▒▒░░ ░ ░ ░░░░░   ░ ░░░░▒▒▒▒▓▒▒▒▒▒▒▒░░ ░ ░░   ░░░░░░░░░░░░   ░   press ⏎ to skip  [?7h[?25l[?7l ✦ ████████████████████████ ·   ✦ ████████████████████████   · ████✧ ████   ✦ ✦ · ████ ████   ✧ ████ ████   ░ ░░░░░░░░░░░░░░░░░░░░▒▒▒▓▓▓▓█▓▓▒▒░ ░ ░░░▒▓▓██████▓▒▒░ ░░░▒▒▒▒▒▒▒▒▒▒░░   ░░░░░░░░░░▒░░▒▒░░░░░░░░▒▒▒▒▓▓▓▓▓▒▒░░░░ ░░░▒▒▓▓█████▓▓▒▒░░ ░░░░▒▒▒▒▒▒▒▒░░░░   ░░░░░░▒▒░░▒░░░░░░░░░░░░░░░▒▒▓▓▓▓▓▓▓▒▒▒▒░▒▒▒▒▓▓█████▓▒▒▒░░ ░░░░░▒░▒▒▒▒▒▒▒░░   ░░▒▒▒▒▒▒░░▒░░░░░ ░ ░░░ ░░░▒▒▒▓▓████▓▓▓▓▒▓▓▓████▓▓▓▓▒▒░░░ ░░░░░░░░░░▒▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒░░░░░ ░░░▒▓▓██████████▓█████▓▓▓▒▒▒░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒▒░░ ░░░░▒▒▓▓█████████████▓▓▓▒▒▒░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒░░ ░░░▒▒▒▒▓▓███████████▓▓▓▒▒░░░░░░░░░░░░░░░ ░░░░░░░▒░░░   ░░░▒▒▒▒▒▒░░ ░░░░▒▒▒▒▓▓▓██████▓▓▓▒▒▒░░░░░░░░░░░░░░░░ ░░░░░░░░░░░   ░░▒▒▒▒▒▒░░ ░░░░░░▒▒▒▒▒▒▒▓▒▓▓▒▒▒▒░░░ ░░░░░░░░░░░░ ░ ░░░░░░▒░░░   ░░░▒▒▒░░░ ░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░ ░░░░▒▒░░░░   ░░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░ ░░░░▒░░░░   ░░░▒░▒░░▒░░░░░░░░░░░░ ░░░░░░░░ ░ ░░░░▒░░░░░░   ░░░▒▒▒▒▒▒▒░▒░░░░░░░░ ░░░░░░░░░░░ ░░░░░░░░░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒░░░░░ ░ ░░░▒▒▒▒▒░░░░ ░░░░░░░▒░░░░   ░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░ ░ ░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░   ░░▒▒▒▒▒▒▒░▒░░░░░░░░▒▒▒░▒░░░░▒▒▒▒▒▓▒▒▒▒▒░░░░░░░░░░░░░░░░░   ░ ░░▒▒▒▒░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░ ░   ░░░░░░░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░░   ░░░░░▒▒▓▒▓▓▓▓▓▓▓▓▒▒▒▒░░░ ░ ░░░░░   ░ ░░░░▒▒▒▒▓▒▓▒▒▒▒▒░░░ ░ ░░░   ░░░░░░▒▒░░░░░   ░ ░░  [?7h[?25l[?7l ✦ ████████████████████████ ·   ✦ ████████████████████████   · ████✧ ████   ✦ ✦ · ████ ████   ✧ ████ ████   ████· ████   ✦ · ✧ ▒▒▒▒ ████ ✦ ✦ ·   ✧ ▒▒▒▒ ████ ✦ ✦   ✦ · ████   ░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▓█▓▓█▓▓▒░ ░ ░░░▒▓▓███████▓▒░ ░░░▒▒▒▒▒▒▒▒▒▒░░   ░░░░░░░░░░░░░░▒░░░░░░▒▒▒▒▒▓▓▓▓▓▒▒░░ ░░░▒▒▓██████▓▓▒░░ ░░░▒░▒▒▒▒▒▒▒░░░   ░░░░░░▒░░░░░░░░░░░░░░░░░░▒▒▒▓▓▓▓▓▓▒▒▒░░░░░▒▒▒▓▓████▓▓▒▒▒░░ ░░░░░░░▒▒▒▒▒▒▒░░░   ░░▒▒▒▒▒▒░░░░░░░░░░ ░░░░░░░▒▒▒▓▓████▓▒▒▒▒▒▒▓▓▓██▓▓▓▓▒▒░░░ ░░░░░░░░░░░▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒░░░░░ ░░▒▒▓▓████████▓▓▓█████▓▓▓▒▒▒░░░░░░░░░░░░░░░░░░▒▒▒░▒▒░░░   ░░▒▒▒▒▒▒▒▒░░ ░░░░▒▒▓▓██████████████▓▓▒▒▒▒░░░░░░░░░░░░░░░░░░░░▒▒▒▒░░░   ░░░▒▒▒▒▒▒▒░░ ░░░▒▒▒▓▓█████████████▓▓▒▒▒░░░░░░░░░░░░░░ ░░░░░░░▒░░░   ░░░▒▒▒▒▒▒▒░░ ░░░░▒▒▒▓▓████████▓█▓▓▒▒░░░░░░░░░░░░░░░░ ░░░░░░░░░░░   ░░▒▒▒▒▒▒░░ ░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒░░ ░░░░░░░░░░░░ ░ ░░░░░░▒░░░   ░░▒▒▒▒░░ ░░░░░░░░░░░░░▒▒▒▒░▒░░ ░░░░░░░░░░ ░░░░░▒░░░░░   ░░░░░░░░░ ░ ░░░░░░░░░░ ░ ░░░░░░░░ ░░░░░░░░░   ░░░▒░░░░░░░░░░░░░░░░░ ░░░░░░░░ ░░░░░░░░░░░   ░░░▒▒▒▒▒▒▒░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░░░░   ░░▒▒▒▒▒▒▒▒▒░░░░░░░ ░ ░░▒░░░▒░░░░ ░░ ░░░░▒░░░░░   ░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░ ░░░▒▒▒▒▒▒▒▒░░░░ ░░░░░░░░░░░   ░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░   ░░░░▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▓▒▓▓▓▒▒▒░░░░░░░░░░░░░░░░░   ░ ░░░░░░░░░░░░░░░▒▒▒▓▓▓▓▓▒▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░░   ░ ░ ░░░░░▒▒▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░ ░ ░░░░░ ░   ░ ░░░░▒▒▒▒▓▒▓▓▒▒▒▒░░░ ░ ░░░   ░░░▒▒▒▒▒▒▒░░░░ ░   ░░░░░░░░░  [?7h[?25l[?7l   ✧ ✧ · ✦   ✧ ✧ ✦✧   · ✧   ✧   · ✦   ✧ · ████████████████████████·✧   ████████████████████████ ✦ ·   ✦ · ████ ████ ✦   ████ ·████   ████ ████   ✧ ████ ████ ✧ ✧ ✦ ✦   ✦ ▒▒▒▒ ████   · ▒▒▒▒ ✦ ████ ✧   ✧ ████   ████   ░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▓▓██▓█▓▒░░ ░░░ ░░▒▓███████▓▒▒░ ░░░░▒▒▒▒▒▒▒▒▒░░░   ░ ░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▒░░ ░░░▒▒▓▓█████▓▓▒░░ ░░░░░▒▒▒▒▒▒▒░░░░   ░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▒▒░░░░░░░▒▒▒▒▓▓███▓▓▓▒▒░░ ░░░░░░░░▒▒▒▒▒▒░░░░   ░░░▒░░▒░░░░░░░░░░░░░░░░░░░▒▒▒▒▓▓▓█▓▓▒▒▒▒▒▒▒▒▓▓▓█▓▓▓▓▒▒░░░ ░░░░░░░░░░░▒▒▒▒▒░░░░   ░░░▒▒▒▒▒▒▒░░░░░ ░░▒▒▓▓███████▓▓▓▓▓▓███▓▓▓▒▒▒▒░░░░░░░░░░░░░░░░░░▒▒░▒▒░░░   ░░▒▒▒▒▒▒▒░░ ░░░░▒▒▓▓██████████████▓▓▓▓▒▒░░░░░░░░░░░░░░ ░░░░▒▒▒▒░░░   ░░▒▒▒▒▒▒▒▒░░ ░░░▒▒▒▓▓█████████████▓▓▓▒▒▒░░░░░░░░░░░░░ ░░░░░░░░░░░   ░░░▒▒▒▒▒▒▒░ ░░░▒▒▒▓▓▓██████████▓▓▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░░░░   ░░░▒▒▒▒▒▒░░ ░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░░ ░ ░ ░░░░░░░░   ░░░▒▒▒▒░░ ░░░░░░░░░░░▒▒▒▒▒▒▒▒░░░ ░ ░░░░░░░░ ░░░░░░░░░   ░░░░░░░░░ ░░░░░░░░░ ░░░░ ░░░░░░░░ ░░░░░░░░   ░░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░ ░░░░░░░░░░   ░░░▒▒▒▒░▒▒░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░░░░   ░░▒▒▒▒▒▒▒▒▒░░░░░░░ ░░░░░░▒░░░░ ░░ ░░░░░░░░░░░   ░░▒▒▒▒▒▒▒▒▒░░░░░░░░░ ░░░▒▒▒▒▒▒▒░░░░ ░░░░░░░░░░░░   ░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒░░░░░ ░░░░░░░░░░░░   ░░▒░▒▒▒▒▒░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▒▒▒░░░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░░▒▒▒▓▓▓▒▓▒▒▓▓▓▓▒▒▒▒▒░░░░░░░░░░░░░░   ░░░░ ░░░░░▒▒▓▓▓▓▓▓▓▒▓▓▓▒▒▒░░░ ░ ░░░░░ ░   ░ ░░░░▒▒▒▒▓▒▓▓▓▒▒▒▒▒░░ ░ ░░░ ░   ░░░▒▒▒▒▒▒▒▒░▒░░ ░░   ░░░░░░░░░  [?7h[?25l[?7l ✧ · ████████████████████████·✧   ████████████████████████ ✦ ·   ✦ · ████ ████ ✦   ████ ·████   ████ ████   ✧ ████ ████ ✧ ✧ ✦ ✦   ✦ ▒▒▒▒ ████   · ▒▒▒▒ ✦ ████ ✧   ░░░░░░░░░░░░░░░░░░░░░░▒░▒▒▓▓███▓▓▓▒░ ░░░ ░░▒▒███████▓▓▒░ ░░░░▒▒▒▒▒▒▒▒░░░   ░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▒▒░ ░░░░▒▒▓█████▓▓▒▒░░ ░░░░░▒▒▒▒▒▒▒▒░░░   ░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▓▓▓▒▒▒░░░ ░░░░░░▒▒▓▓▓██▓▓▓▒▒░░ ░░░░░░░░▒▒▒▒▒░▒░░   ░░░▒░░▒░░░░░░░░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▓▓▓█▓▓▓▓▒▒▒░░░░ ░░░░░░░░░░░░▒▒▒▒░░░░   ░░░▒▒▒▒▒░░░░░░░ ░░░ ░░▒▒▒▓▓▓█████▓▓▓▓▒▓▓▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░░░░░░▒░▒▒░░░   ░░▒▒▒▒▒▒▒░░ ░░░░▒▒▓▓▓█████████████▓▓▓▓▓▒▒▒░░░░░░░░░░░░░ ░░░░░░░▒▒░░   ░░░▒▒▒▒▒▒▒░░░ ░░░▒▒▒▓██████████████▓▓▓▒▒▒▒░░░░░░░░░░░░ ░ ░░░░░░░░░░░   ░░▒▒▒▒▒▒▒▒░ ░░░▒▒▒▓▓▓██████████▓▓▓▒▒▒▒░░░░░░░░░░░░░ ░░░░░░░░░░   ░░▒▒▒▒▒▒░░░ ░░░░░▒▒▒▓▓▓▓██▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░ ░ ░░░░░░░░   ░░░▒▒▒░░░ ░░░░▒░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░ ░░░░░░░░ ░░░░░░░░░   ░░░░▒░░░ ░░░░░░░░░░░ ░░░░░░░░ ░░░░░░░░ ░░░░░░░ ░   ░░░░░░░░░░░░ ░░░░░░░░░ ░░░░░░░░ ░░░░░░░░░   ░░░░░▒░░░░░░░░░░░░░░░░ ░░░░░░░░░ ░░░░░░░░░░░   ░░░▒▒▒▒▒▒▒░░░░░░░░░ ░░░░░░▒░░░░ ░ ░░░░░░░░░░   ░░▒▒▒▒▒▒▒▒▒░░░░░░░░░ ░░▒▒▒▒░▒░░░ ░░░░░░░░░░░   ░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░ ░ ░░░░▒▒▒▒▒▒▒▒░░░░░ ░░░░░░░░░░░░   ░░▒▒▒▒▒▒▒░░░░░░░░░░▒▒░▒▒▒▒░▒▒▒▒▒▒▒▓▒▒▒░░░░░░░░░░░░░░░░░   ░░░░░░▒░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▓▓▓▓▒▒▒▒▒░░░░░░░░░░░░░░░   ░░░░░ ░░░░░▒▒▓▒▓▓▓▓▓▒▓▓▓▓▒▒▒░░░ ░ ░░░░░░░   ░░░░░▒▒▒▒▓▓▓▓▓▓▓▒▒▒▒░░░ ░ ░░░ ░░   ░░░░▒▒▒▒▒▒▒▒▒▒░░░░ ░░ ░   ░░░░░░░░░░░░  [?7h[?25l[?7l ✧ · ████████████████████████·✧   ████████████████████████ ✦ ·   ✦ · ████ ████ ✦   ████ ·████   ████ ████   ✧ ████ ████ ✧ ✧ ✦ ✦   ✦ ▒▒▒▒ ████   · ▒▒▒▒ ✦ ████ ✧   ░░░░░░░ ░░░ ░░░░░░░░░▒▒▒▓▓▓███▓▓▒▒░ ░░░ ░░▒▒▓██████▓▓▒░░ ░░░░▒▒▒▒▒▒▒▒░░░░   ░░░░░░░░ ░░ ░░░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▒░░ ░░░░░▒▒▓▓█████▓▓▒▒░ ░░░░▒░▒▒▒▒▒▒░░░   ░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▓▓▓▒▒▒▒░░░ ░░░░▒▒▒▓▓▓██▓▓▒▒░░ ░ ░░░░░░▒▒▒▒▒▒▒░░   ░░░▒░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▓▓▓▓▒▒▒░░░▒▒▒▒▒▓▓▓▓▓▓▓▓▒▒░░░░ ░░░░░░░░░░░░▒▒▒▒░░░░   ░░░▒▒▒▒▒░░░░░░░ ░░░░░░░░░▒▒▒▓▓▓██▓▓▓▓▓▒▒▒▓▓▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░░░░░░▒░░▒░░░░   ░░▒▒▒▒▒▒▒░░ ░░░░▒▒▓▓▓███████▓▓▓██▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░ ░░░░░░▒▒░░   ░░▒▒▒▒▒▒▒▒░░░ ░░░▒▒▒▓██████████████▓▓▓▒▒▒▒░░░░░░░░░░░░ ░ ░░░░░░░░░░   ░░░▒▒▒▒▒▒▒░░ ░░░▒▒▒▓▓▓██████████▓▓▓▒▒▒▒▒░░░░░░░░░░░░ ░░░░░░░░░░░   ░░▒▒▒▒▒▒░░ ░░░░░▒▒▒▓▓█████▓▓▓▓▓▓▒▒░░░░░░░░░░░░░░░░ ░░░░░░░░   ░░▒▒▒▒▒░░░ ░░░░▒░░░▒▒▒▒▒▓▓▓▒▒▒▒▒░░░░░░ ░░░░░░░░░ ░░░░░░░░░   ░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░ ░░░░░░░░░   ░░░░░░░░░ ░ ░░░░░░░░░ ░░░░░░░░ ░░░░░░░░░   ░░░░░▒░░░░░░░░░░░░░░░░ ░░░░░░░░ ░░░░░░░░░░░   ░░░▒▒▒▒▒░▒░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░░░   ░░▒▒▒▒▒▒▒▒▒░░░░░░░░ ░░░░░▒░▒░░░ ░░░░░░░░░░ ░   ░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░░░░░░▒▒▒▒▒▒░░░░░ ░░░░░░░░░░░░   ░░░▒▒▒▒▒▒▒░░░░░░░░░░▒▒░░░░░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░   ░░░░▒░░▒░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▓▓▒▒▒▒▒░░░░░░░░░░░░░░░   ░░░░░░ ░░░░░▒▒▓▒▓▓▓▓▒▒▓▓▓▓▒▒▒░░░░ ░ ░░░░░░░░   ░░░░░▒▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒░░ ░ ░░░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░   ░░░░░▒░▒▒░░░░░   ░  [?7h[?25l[?7l ✧ · ████████████████████████·✧   ████████████████████████ ✦ ·   ✦ · ████ ████ ✦   ████ ·████   ✧ ████ ████ ✧ ✧ ✦ ✦   ✦ ▒▒▒▒ ████   · ▒▒▒▒ ✦ ████ ✧   ✧ ████   ████   ░░░░░░░ ░░░ ░░░░░░░░░▒▒▓▓▓████▓▓▒▒ ░░░ ░░▒▓▓██████▓▓▒░ ░░░░▒▒▒▒▒▒▒▒░▒░░   ░░░░░░░░ ░ ░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▒▒░ ░░░░░░▒▒▓██████▓▒▒░░ ░░░░▒░▒▒▒▒▒▒░░░░   ░░░░░░░░ ░░░░░░░░░░▒▒▒▒▒▒▓▓▓▓▓▒▒▒░░ ░░░░░▒▒▓▓▓██▓▓▒▒▒░░░ ░ ░░░░░░▒░▒▒▒▒▒░░   ░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▓▒▒▒▒▒░░░░░░░░▒▒▓▓▓▓▓▓▓▒▒▒░░░░ ░░░░░░░░░░░░▒▒▒▒░░░░░   ░░░▒▒▒▒▒░░░░ ░░░░░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░░░░░░░░░▒░░░░   ░░░▒▒▒▒▒▒░░░ ░░░░▒▒▓▓▓██████▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░ ░░░░░░▒▒░░░   ░░▒▒▒▒▒▒▒░░░ ░░░▒▒▒▓██████████████▓▓▓▒▒▒▒░░░░░░░░░░░░ ░ ░░░░░░░░░   ░░░▒▒▒▒▒▒▒░ ░░░▒▒▒▓▓███████████▓▓▓▓▒▒▒▒░░░░░░░░░░░░ ░░░░░░░░░░░   ░░░▒▒▒▒▒░░░ ░░░░▒▒▒▓▓▓██████▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░░ ░░░░░░░   ░░░░▒▒▒▒▒░░ ░░░░▒░▒▒▒▒▒▓▓▓▓▓▒▒▒▒▒░░░░░░░░░░░░░░░░░ ░░░░░░░░░   ░░░░░░░░ ░░░░░░░░░░░░░░░░░▒▒░░░░ ░ ░░░░░░░░░ ░░░░░░░░░   ░░░░░░░░░ ░░░░░░░░░ ░ ░░░░░░░ ░░░░░░░░ ░   ░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░ ░░░░░░░░░░   ░░░░▒▒▒▒▒░░░░░░░░░░░ ░░░░░░░░░ ░░░░░░░░░░░   ░░░▒▒▒▒▒▒▒▒░░░░░░░░░ ░░░░░▒░░░░░ ░░░░░░░░░░░░   ░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░░░▒▒▒▒▒▒░░░░░ ░░░░░░░░░░░░   ░░░░▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒░░░░░ ░ ░░░░░▒░░░   ░░░░▒░░▒░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░   ░░░░░░░░ ░░░░░▒▒▓▒▓▓▓▒▒▒▓▒▓▓▒▒▒░░░░░░ ░░░░░░░   ░ ░ ░░░░░▒▒▒▓▓▓▒▓▓▓▓▒▒▒▒▒░░ ░ ░ ░░░░░░░   ░ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░░   ░░░░░▒▒▒▒░░░░░░   ░░ ░░░  [?7h[?25l[?7l ✧ · ████████████████████████·✧   ████████████████████████ ✦ ·   ✦ · ████ ████ ✦   ████ ·████   ████ ████   ✧ ████ ████ ✧ ✧ ✦ ✦   ✦ ▒▒▒▒ ████   · ▒▒▒▒ ✦ ████ ✧   ████   ░░░░░░░░░ ░░░ ░░░▒▒▓▓█████▓▓▒░ ▒░░ ░░▒▓▓██████▓▒░░ ░░▒▒▒▒▒▒▒▒░▒░░   ░░░░░░ ░ ░░░░░░░░▒▒▒▓▓▓▓█▓▓▒▒░░ ░░░░░░░▒▓▓█████▓▒▒░░ ░░░░░░▒▒▒▒▒▒░░░░   ░░░░░░ ░░ ░░░░░░░░░░▒▒▒▒▒▒▓▓▓▓▓▒▒░░ ░░░░░░▒▒▓▓█▓▓▓▓▓▒░░░ ░░░░░░░░▒▒▒▒▒░░   ░░░░░░░░░ ░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▒▒░░░░░░░░ ░░░░░░░░░░▒░░░░░░░   ░░░▒░░░░░░░░ ░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░░░░░░░░▒░░░░   ░░░▒▒▒▒░░░░░ ░ ░░░░░░▒▒▓▓▓█████▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒░░░░░░░░░░░ ░░░░░░▒▒░░░   ░░▒▒▒▒▒▒░░░░ ░░░▒▒▒▓██████████▓█▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░ ░ ░░░░░░░░░░   ░░░▒▒▒▒▒▒░░ ░░░▒▒▓▓██████████▓█▓▓▓▓▒▒▒▒░░░░░░░░░░░░ ░░░░░░░░░░   ░░▒▒▒▒▒▒░░ ░░░▒▒▓▓▓▓██████▓▓▓▓▓▓▒▒░░░░░░░░░░░░░░░ ░░░░░░░   ░░░░▒▒▒▒▒░░ ░░░░▒▒▒▒▒▒▓▓▓▓▓▓▓▒▓▒▒▒░░░░░░░░░░░░░░░░ ░░░░░░░░   ░ ░░░░░░░░ ░░░░░░░░░░░░▒▒▒▒▒▒▒▒░░░░░ ░░░░░░░░░ ░░░░░░░░░░   ░░░░░░░░░ ░░░░░░░░ ░░░░░ ░░░░░░░ ░░░░░░░░░░   ░░░░░░░░░ ░░ ░░░░░░░░░ ░░░░░░░ ░░░░░░░░░ ░   ░░░░░▒▒▒▒░░░░░░░░░░░░ ░░░░░░░░░ ░ ░░░░░░░░   ░░░▒▒▒▒▒▒░░░░░░░░░░░ ░░░░░░░░░░ ░ ░░░░░░░░░░   ░░░▒▒▒▒▒▒▒▒░░░░░░░░░ ░░░░░▒▒▒▒░░░░░ ░ ░░░░░░░░░░   ░░░░▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░▒░▒▒▒▒▒▒░░░░░ ░ ░░░░░▒░░░░░░   ░░░░▒▒▒▒░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░   ░░░░░░░░ ░░░░░▒▒▒▒▒▓▒▒▒▒▓▒▒▓▒▒▒░▒░░░░ ░░░░░░░░   ░ ░ ░ ░ ░░░░░▒▒▒▓▓▓▒▓▓▓▓▒▓▒▒▒░░░░ ░ ░░░░░░░   ░ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░ ░░░░░   ░░░▒▒▒▒▒▒░▒▒░░░░ ░   ░░░░░░░ ░  [?7h[?25l[?7l ✧ · ████████████████████████·✧   ████████████████████████ ✦ ·   ✦ · ████ ████ ✦   ████ ·████   ████ ████   ✧ ████ ████ ✧ ✧ ✦ ✦   ✦ ▒▒▒▒ ████   · ▒▒▒▒ ✦ ████ ✧   ░░░░░░░░░░ ░░░ ░░░▒▒▓██████▓▓▒░ ░▒░░ ░░▒▓▓██████▓▒▒░ ░░░▒▒▒▒▒▒▒░▒░░   ░░░░░░░ ░░░░░░░░▒▒▒▓▓███▓▓▒▒░ ░░░░░░░▒▒▓▓████▓▓▒▒░░ ░░░░▒▒▒▒▒▒░░░░   ░░░░░ ░░░░░░░░▒▒▒▒▒▒▓▓▓▓▓▒▒░░ ░░░░░░▒▒▒▓▓▓▓▓▓▓▒▒░░ ░ ░░░░░░▒▒▒░▒░░░░   ░░░░░░░ ░░░░░░░░▒░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░░░▒▒▒▓▓▓▓▓▓▓▒▒▒░░░░░░░ ░░░░░░░░░░▒░░░░░░░   ░░░░░░░░░░░ ░ ░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▒▒▒▒▒░▒░▒▒▒▒▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░░░░░░░░▒░░░░░   ░░░▒▒░░░░░░░ ░ ░ ░░░░░░░▒▒▒▓▓▓▓██▓█▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒░░░░░░░░░░░░ ░░░░░░░░░░░   ░░▒▒▒▒▒▒░░░ ░░▒▒▒▓▓█████████▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░ ░ ░ ░░░░░░░░   ░░░▒▒▒▒▒▒░░ ░░░▒▒▓▓██████████▓█▓▓▓▓▒▒▒▒▒░░░░░░░░░░░░ ░ ░░░░░░░░   ░░▒▒▒▒▒▒░░ ░░▒▒▒▓▓████████▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░ ░░░░░░░░   ░░░▒▒▒▒▒▒░░ ░░░░▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░░░ ░░░░░░░░   ░░░░▒▒░░░░ ░░░░░░░░░▒░▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░░░░░░░ ░░░░░░░░░   ░░░░░░░░ ░░░░░░░░ ░ ░ ░░░░░░ ░░░░░░░ ░░░░░░░░░   ░░░░░░░░ ░ ░░░░░░░░░ ░░░░░░░ ░░░░░░░░░░   ░░░░░▒░░░░░░░░░░░░░░░ ░░░░░░░ ░ ░░░░░░░░░   ░░░▒▒▒▒▒▒░░░░░░░░░ ░ ░░░░░░░░░ ░░░░░▒░░░░   ░░▒▒▒▒▒▒▒░░░░░░░░░░ ░░░░░░▒▒░░░░░ ░░░░░░░░░░░   ░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░ ░ ░░░░░▒▒▒▒▒▒░░░░░ ░ ░░░░▒░░░░░░   ░░▒░▒▒▒▒░░░░░░░░░░░░▒░▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░   ░░░░░░░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▓▒▒▒▒▒░░░░ ░░░░░░░░░░   ░ ░ ░ ░ ░ ░░░░░▒▒▒▒▓▓▒▓▒▓▓▒▓▒▒▒▒░░░ ░ ░░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░░░░   ░░░░▒▒▒▒▒▒░▒▒░░░░░ ░   ░░░░░░░░░░░░  [?7h[?25l[?7l ✧ · ████████████████████████·✧   ████████████████████████ ✦ ·   ████ ·████   ████ ████   ✧ ████ ████ ✧ ✧ ✦ ✦   ✦ ▒▒▒▒ ████   · ▒▒▒▒ ✦ ████ ✧   ✧ ████   ████   ░░░░░░░░░░ ░░▒▒▓██████▓▓▒░ ░▒▒▒░ ░░▒▒▒▓██████▓▒░░ ░░░░▒░▒▒▒░▒░░░   ░░░░░░░░ ░░░░░▒▒▒▓█████▓▒▒░░ ░░▒░░░░░░▒▒▓▓▓██▓▓▓▒▒░░ ░░░░░░▒▒░▒░▒░░░   ░░░░░░ ░░░▒▒▒▒▒▒▓▓▓▓▓▓▓▒░░ ░░░░░░░▒▒▒▓▓▓▓▓▓▒▒░░░░ ░░░░░░░▒░░▒░░░░   ░░░░░░░ ░░░░▒▒░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░░░▒▒▒▒▓▓▓▓▒▒▒░░░░░░░ ░ ░░░░░░░░░░░░▒░░░░   ░░░░░░░░░ ░░░░░░░░░░░░▒▒▒▒▓▓▒▒▒▒░░░░░▒░▒░▒▒▒▒▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░▒░░   ░░░░░▒░░░░░░░ ░░░░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▓▓▓▓▓▒▒▒▒▒░░░░░░░░░░░ ░░░░░░░░░░░░░   ░░▒▒▒░░░░░░ ░░░░▒▒▒▓▓███████▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░ ░░░░░░░   ░░▒▒▒▒▒░░░░ ░░░▒▒▓▓█████████▓▓▓▓▓▓▓▓▓▒▒▒▒▒░░░░░░░░░░░ ░░░░░░░░   ░░▒▒▒▒▒▒▒░░ ░░░▒▒▒▓▓████████▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░▒░░░░ ░░░░░░░░   ░░░░▒▒▒▒▒░░ ░░░░▒▒▒▒▓▓▓████▓▓▓▓▓▓▒▒▒▒░░░░░░░░▒░░░░░ ░ ░░░░░░   ░░▒░▒▒░░░ ░░░░░░▒▒▒▒▒▒▓▓▓▒▓▒▒▒▒▒░░░░░ ░░░░░░░░░░ ░░░░░░░░░   ░░░░░░░░░░ ░░░░░░░░░░░░░░░░▒▒░░░░░░ ░░░░░░░ ░░░░░░░░░   ░░░░░░░░░ ░░░░░░░░░ ░ ░░░░░░░ ░░░░░░░░   ░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░ ░░░░░░░░░░░   ░░░░▒▒▒▒░░░░░░░░░░░ ░░░░░░░░ ░░░░░░░░░░░   ░░▒▒▒▒▒▒░░░░░░░░░ ░ ░░░░▒░░░░░░ ░░░░░░░░░░   ░░▒▒▒▒▒▒▒▒░░░░░░░░ ░ ░ ░░ ░░░░▒░▒▒░░░░░ ░ ░░░░▒░░░░   ░░▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░   ░░░▒▒▒░░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░░░░░░░   ░░░░░░░ ░ ░ ░░░░░▒▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒▒░░░ ░ ░ ░░░░░░░░   ░░░▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░░░░   ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░ ░   ░░░░░░▒░░░░░  [?7h[?25l[?7l · ✧     ✦ ✧ ·     · · ·   ✧   · ████████████████████████   ✦ ✦ ████████████████████████   ████ ████ ·  ████ ████ ✦   ████ ████ ·   ████ ████ ·   ✧ ▒▒▒▒ ████ ✦  ▒▒▒▒ ████   ✦ ✧ ✧ ████ · ✦   ████   ░░░░░░░░░░ ░░▒▓▓██████▓▒▒░ ░▒▒▒▒░░ ░ ░▒▒▓▓█████▓▒▒░░ ░░░▒░▒▒▒░▒░░░   ░░░░░░░░ ░░░░▒▒▓▓█████▓▒▒░ ░▒▒░░░░░░▒▒▓▓▓▓█▓▓▓▒▒░░ ░ ░░░░▒▒░▒░▒░░░   ░░░░░░░ ░░░▒▒▒▒▒▓▓▓▓▓▓▓▓▒░ ░░░░░░░░▒▒▓▓▓▓▓▓▒▒▒░░░ ░░░░░░░░░░▒░░░░░   ░░░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▓▒▒▒▒░░ ░░░░░░░▒▒▒▒▓▓▓▓▒▒▒▒░░░░░░ ░ ░░░ ░░░░░░░░░░░░░   ░░░░░░░░ ░░░░░▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░▒░░░▒▒▒▒▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░░░░░░░░░▒░░░   ░░░░░░░░░░░ ░░░░░░░░░░▒▒▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▒▒▒▒▒░░░░░░░░░░░ ░░ ░░░░░░░░░░   ░░░▒░▒░░░░░ ░░ ░░░░▒▒▒▓▓████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░ ░░░░░░░░   ░▒▒▒▒▒▒░░░░ ░░░▒▒▓▓████████▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░ ░░░░░░░░   ░░▒▒▒▒▒▒░░░ ░░░▒▒▒▓▓████████▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░▒░░░░ ░░░░░░░░   ░░░░▒▒▒▒▒░░ ░░░░▒▒▒▓▓██████▓▓▓▓▓▓▒▒▒▒▒░░░░░░░▒░░░░░ ░░░░░░░   ░░░▒▒▒▒░░░ ░░░░░░▒▒▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒░░░░░░░░░░░░░░░ ░░░░░░░░   ░░░▒░░░░ ░░░░░░░░░░░░░▒▒░▒▒░▒▒░░░ ░░░░░░░ ░░░░░░░░   ░░░░░░░░░░ ░░░░░░░░░ ░░░░░ ░░░░░░ ░░░░░░░░░   ░░░░░░░░░░ ░░░░░░░░ ░░░░░░ ░░░░░░░░░░   ░░░░░▒▒▒░░░░░░░░░░░░ ░░░░░░ ░░░░░░░░░   ░░░▒▒▒▒▒▒░░░░░░░░░ ░ ░░░░░░░░░ ░░░░░░░░░░░   ░░░▒▒▒▒▒▒▒▒░░░░░░░░ ░ ░░░░▒▒░░░░░ ░ ░░░░▒░░░░   ░░▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░░░░░░░░░▒▒▒▒▒▒▒░░░░░░ ░░░░░░░░░░░░   ░░▒▒▒▒▒░░░░ ░░░░░▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░░░░░░   ░░░░░░░ ░ ░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░ ░ ░░░░░░░░░░   ░ ░░░▒▒▒▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░ ░░░░░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░ ░ ░   ░ ░░▒▒▒▒▒░░░░░░  [?7h[?25l[?7l · ████████████████████████   ✦ ✦ ████████████████████████   ████ ████ ✦   ████ ████ ·   ████ ████ ·   ✧ ▒▒▒▒ ████ ✦  ▒▒▒▒ ████   ░░░░░░░░░░ ░▒▒▓▓██████▓▒▒░ ░▒▓▒▒░░ ░ ░▒▒▒▓█████▓▓▒▒░ ░░░░░▒▒▒░▒░░░   ░░░░░░░░░ ░░░▒▒▒▓▓█████▓▒░░ ░░▒▒▒░░░░░░▒▒▓▓▓██▓▓▓▒░░ ░░░░▒▒░░░▒▒░░░   ░░░░░░░ ░░░▒▒▒▒▓▓▓▓▓█▓▓▒▒░ ░▒░░░░░░░▒▒▒▓▓▓▓▓▓▒▒▒░░ ░░░░░░░░░░▒░▒░░░   ░░░░░░░ ░░░░▒▒▒▒▒▒▒▓▒▓▓▓▒▒▒░░ ░░░░░░░░▒▒▒▓▓▓▓▓▒▒▒░░░░░░ ░ ░░░ ░░░░░░░░░░░░░   ░ ░░░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░░░░░░▒▒▒▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░   ░░░░░░░░ ░ ░░░░░░░░░▒▒▒▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▒▓▓▒▒▒▒▒░░░░░░░░░░░░ ░░ ░░░░░░░░░   ░░░░░▒░░░░ ░ ░░░░░░░░▒▒▒▓▓▓▓██▓▓▓▓▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░ ░░░░░░░░░   ░▒▒▒▒▒▒░░░░ ░░░░▒▒▓▓████████▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░▒░░░ ░░░░░░   ░░▒▒▒▒▒░░░ ░░░▒▒▒▓████████▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░▒░░░░░ ░░░░░░░ ░   ░░░░▒▒▒▒▒▒░░ ░░░░▒▒▒▓▓██████▓▓▓▓▓▓▓▒▒▒▒▒░░░░░░▒░░░░░░ ░░░░░░   ░░░▒▒▒▒░░░ ░░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▒▓▒▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░░░   ░░░░▒▒░░░ ░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░ ░ ░ ░░░░░░░ ░ ░░░░░░░░   ░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░ ░░░░░░ ░ ░░░░░░░░   ░░░░░░░░ ░░░░░░░ ░░░░░ ░░░░░░░░░   ░░░░░░░░░░░░░░░░░░░ ░░░░░░ ░░░░░░░░░   ░░░░▒▒▒▒░░░░░░░░░░ ░░░░░░░░░ ░░░░░░░░░░   ░░░▒▒▒▒▒▒▒▒░░░░░░░ ░ ░░░░░▒░░░░░ ░░░░▒▒░░░░   ░▒▒▒▒▒▒▒▒░░░░░░░░░░ ░░ ░░░░░░░░▒▒▒▒░░░░░░ ░░ ░░░░░░░░░   ░░░▒▒▒▒▒░░░░ ░░░░░░░▒▒░░▒░░▒░▒▒▒▒▒▒▒▒▒░░░░ ░░░░░░░░░   ░░░░░░░░░ ░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░░░░░░░░░░   ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░ ░░░░░░░░   ░░▒▒▒▒▒▓▒▒▒▒▒▒▒▒▒░░░░ ░ ░ ░   ░░░░▒▒▒▒▒░░░░░░░░ ░  [?7h[?25l[?7l · ████████████████████████   ✦ ✦ ████████████████████████   ✧ ▒▒▒▒ ████ ✦  ▒▒▒▒ ████   ░░░░░░░░░░ ░▒▒▓▓██████▓▒▒ ░▒▓▒▒░░ ░ ░░▒▒▓█████▓▓▒▒░ ░░░░░░▒▒░▒░░░░   ░░░░░░░░ ░░░▒▒▓▓██████▓▒░░ ░░▒▒▒▒░░░░░▒▒▒▓▓█▓▓▓▓▒▒░░ ░░░░░▒░░░▒▒░░░   ░░░░░░░░ ░░░▒▒▒▒▓▓▓▓██▓▓▒░ ░▒░░░░░░░░▒▒▒▓▓▓▓▓▒▒▒░░ ░ ░░░░░░░░▒░▒░░░   ░░░░░░░ ░░░░▒▒▒▒▒▒▓▓▓▓▓▓▒▒░░ ░░░░░░░░▒▒▒▒▓▓▓▓▒▒▒▒░░░░░ ░ ░░░ ░░░░░░░░░░░░░   ░░░░░ ░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░░░▒▒▒▒▒▒▓▓▒▒▒▒░░░░░░░░░░░░░░░░ ░░░░░░░░   ░░░░░░░ ░ ░░░░▒░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░▒▒▒▒▒▒▒▒▒▓▒▒▒▒▒▒▒░░░░░░░░░░░ ░░ ░░░░░░░░░   ░░░░░░░░░ ░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▓▓▓▓▓▒▒▒▒░▒░░░░░▒░░░░ ░░░░░░░░   ░▒░▒░░▒░░ ░░░░░▒▒▒▓▓███████▓▓▓▒▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░▒▒░░░░ ░░░░░░   ░░░▒▒▒▒▒░░░ ░░░░▒▒▒▓████████▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░▒░░░░░░ ░░░░░░░░   ░░░░▒▒▒▒▒░░ ░░░░▒▒▓▓▓██████▓▓▓▓▓▓▓▒▒▒▒▒░░░░░░▒░░░░░░ ░░░░░░░   ░░░▒▒▒▒░░░ ░░░░░░▒▒▒▓▓▓██▓▓▓▓▓▓▒▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░░░   ░░░░░▒▒░░░ ░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░░░░░░░░░ ░░░░░░ ░   ░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░ ░░░░░░ ░░░░░░░░   ░░░░░░░░░ ░░░░░░ ░░░░░ ░ ░░░░░░░░ ░   ░░░░░░░░░ ░ ░░░░░░░ ░░░░░░ ░░░░░░░░░   ░░░░░▒▒░░░░░░░░░░░░ ░░░░░░░░░ ░░░░░░░░░   ░░░▒▒▒▒▒▒▒░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░ ░   ░░▒▒▒▒▒▒░▒░░░░░░░░ ░░░░░░░░▒▒▒▒░░░░░░ ░░░░░░░░░░   ░░░▒▒▒▒▒░░░░ ░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒░░░░ ░░░░░░░░░░   ░░░░░░░░░░ ░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░░░░░░░░░░   ░░ ░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░░░░░░   ░░▒▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒░░░ ░ ░ ░   ░░░░▒▒▒▒▒▒▒░░▒░░░░ ░  [?7h[?25l[?7l · ████████████████████████   ✦ ✦ ████████████████████████   ░░░░▒░░░░░░ ░▒▒▓███████▓▒░ ░▒▒▓▓▒▒░░░ ░░░▒▒▓█████▓▒▒░░ ░░░░░░▒░▒░░░░   ░░░░░░░░░ ░░░▒▒▓▓█████▓▒▒░ ░░▒▒▒▒▒░░░░░░▒▒▒▓█▓▓█▓▒▒░░ ░░░░░░░░░▒▒░░░   ░░░░░░░░ ░░▒▒▒▓▓▓▓▓██▓▒▒░ ░░▒▒▒░░░░░░░▒▒▒▓▓▓▓▓▒▒░░░ ░ ░░░░░░░▒░░░░░   ░░░░░░░ ░░░░▒▒▒▒▒▓▓▓▓▓▓▓▒▒░░ ░░░░░░░░░░▒▒▓▓▓▓▒▒▒▒░░░░░ ░ ░░░ ░░░░░░░░░░░░   ░░░░ ░░▒░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░░░░░░▒▒▒▒▒▓▒▒▒▒░░░░░░░░░░░░░░░░ ░░░░░░░░   ░░░░░░░ ░ ░░░░▒░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░▒░░▒░▒▒▒▒▓▒▒▒▒▒▒▒░▒░░░░░░░░░░░░ ░░░░░░░░   ░░░░░░░░░░ ░░░░░░░░░░░░░▒▒▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▓▓▒▓▓▒▒▒▒░▒░░░░░▒░░░░░ ░░░░░░░░   ░░░░░░░░░░ ░ ░░░░░▒▒▒▓▓█████▓▓▓▓▓▒▓▒▓▓▓▓▓▓▓▒▒▒░░░░░░░▒▒░░░░ ░░░░░   ░░░▒▒▒▒▒░░ ░░░░▒▒▓▓███████▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒░░░▒░░▒░░░░░░ ░ ░░░░░░   ░░░░▒▒▒▒▒░░ ░░░░▒▒▓▓███████▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░▒░▒░░░░ ░░░░░░   ░░░░▒▒▒░░░░ ░░░░▒▒▒▒▓▓▓███▓▓▓▓▓▓▓▒▒▒▒▒░░░░░░░▒░░░░░ ░░░░░░░   ░░░░▒▒▒░░ ░░░░░░▒░▒▒▒▒▓▒▒▓▒▒▒▒▒▒░░░░░░░░░░░░░░░░░ ░░░░░░░   ░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░ ░░░░░░░░   ░░░░░░░░░░ ░░░░░░ ░ ░░ ░░░░░ ░ ░░░░░░░░░░   ░░░░░░░░░ ░░░░░░ ░░░░ ░░░░░░░░░   ░░░░░░░░░░░░░░░░░░░ ░░░░░░░ ░░░░░░░░░   ░░░░▒▒▒▒▒▒▒░░░░░░░░ ░░░░░░░░░ ░░░░░░░░░░   ░░▒▒▒▒▒▒░▒░░░░░░░ ░ ░░░░░░░░▒░░░░░░ ░░░░░░░░░░░   ░░░▒▒▒▒▒░░░░ ░░░░░░░░░░░░░░░░░░░░▒▒▒▒░▒░░░░ ░░░░░░░░░░   ░░░░▒░░░░░░ ░ ░░░░░░▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░░░░░░░░░░░   ░░░░ ░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░░░░░░   ░░▒▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒▒░░░ ░ ░░░   ░░░░▒▒▒▒▒▒▒░░▒▒░░░░ ░░  [?7h[?25l[?7l · ████████████████████████   ✦ ✦ ████████████████████████   ████ ████ ·  ░░░░▒░░░░░░ ░▒▒▓███████▒▒░ ░▒▒▓▓▓▒░░░ ░░░▒▒▓▓████▓▓▒▒░░ ░░░░░░▒░▒░░░░░░   ░░░░░░░░░ ░░░▒▒▓▓█████▓▒░░ ░▒▒▒▓▒▒░░░░░░▒▒▒▓▓▓▓█▓▓▒▒░ ░ ░░░░░░░░▒░░░   ░░░░░░░░░ ░░▒▒▒▓▓▓████▓▒▒░ ░░▒▒▒░░░░░░░▒▒▒▓▓▓▓▓▒▒▒░░ ░░░░░░░░░░░░░   ░░░░░░░ ░░░░▒▒▒▒▒▓▓▓▓▓▓▓▒▒░░ ░░░░░░░░░░░▒▒▒▓▓▒▒▒▒▒░░░░ ░ ░░░ ░░░░░░░░░░░░░   ░░░░░ ░░▒░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░░░░░░▒▒▒▒▒▓▒▒▒▒░░░░░░░░░░░░░░ ░ ░░░░░░░░   ░░░░░ ░░░░░░▒░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒░░░░░▒▒▒▒▒▒▒▒▒▒▒░▒░░░░░░░░░░░░ ░░░░░░░░   ░░░░░░░ ░░░░░░░░░░░░░▒▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▒▒▒▒░▒░░░░░▒░░░░░ ░░░░░░░   ░░░░░░░░░ ░ ░░░░░░░░▒▒▒▓▓█▓██▓▓▓▓▒▓▒▒▒▒▒▒▒▓▓▓▒▒▒░░░░░░░▒▒░░░░ ░░░░░   ░░░░▒▒▒▒░░░ ░░░░▒▒▒▓████████▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒░░░▒░░▒▒░▒░░░ ░░░░░   ░░▒░▒▒▒░░░░ ░░░▒▒▒▓▓███████▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░▒░▒░░░░ ░░░░░░   ░░░▒░▒▒▒░░ ░░░░▒▒▒▒▓▓▓████▓▓▓▓▓▓▓▒▒▒▒░░░░░░░▒░░░░░ ░░░░░   ░░░░░▒▒░░░ ░░░░░░▒▒▒▒▒▒▓▓▒▓▓▒▒▒▒▒▒░░░░░░░░░░░░░░░░ ░░░░░░░   ░░░░░░░░ ░░░░░░░░░░░░░░▒▒▒▒░░░░░░░░░ ░░░░░░░ ░░░░░░░░   ░░░░░░░░ ░░░░░ ░ ░░ ░░░░░ ░ ░░░░░░░░░░   ░░░░░░░░░░ ░░░░░░ ░░░░ ░░░░░░░░░   ░░░░░░░░░░░░░░░░░░░ ░░░░░░░ ░░░░░░░░░░   ░░░░▒░▒░▒░░░░░░░░░░ ░ ░░░░░░░ ░░░░░░░░░░   ░░░▒▒▒▒▒░░░░░░░░░░ ░░░░░░░░░░░░░░░ ░░░░░░░░░   ░░░▒▒▒▒▒▒░░░░ ░░░░░░░░░░ ░░░░░░░░▒░▒▒░▒░░░░ ░░░░░░░░░░   ░░░░▒▒░░░░░░ ░ ░░░░░░▒▒░░▒░▒▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░░░░░░░░░░░   ░░░░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░░░░░░   ░ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░ ░ ░░░ ░   ░░░▒▒▒▒▒▒▒▒░░▒▒░░░░ ░░ ░  [?7h[?25l[?7l · ████████████████████████   ✦ ✦ ████████████████████████   ████ ████ ·  ████ ████ ✦   ████ ████ ·   ████ ████ ·   ✧ ▒▒▒▒ ████ ✦  ░░░░▒░░░░░░ ░░▒▒███████▓▒░░ ░▒▒▓▓▓▓▒▒░░ ░░░░▒▓▓▓████▓▓▒░░ ░░░░░▒░▒░░░░░░   ░░░░░░░░░ ░░░▒▒▓██████▓▒░ ░▒▒▒▓▒▒▒░░░░░░▒▒▒▓▓▓█▓▓▒▒░░░ ░░░░░░░░▒░░░░   ░░░░░░░░░░ ░░▒▒▒▓▓█████▓▒▒░ ░░▒▒▒░░░░░░░▒▒▒▓▓▓▓▓▒▒▒░░ ░░░░░░░░░░░░░   ░░░░░░░ ░ ░▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒░ ░▒░░░░░░░░░▒▒▒▓▒▒▒▒▒▒░░░░ ░ ░ ░ ░░░░░░░░░░░░░   ░░░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░ ░░░░░░░░░▒▒▒▒▒▓▒▒▒▒░░░░░░░░░░░░░░ ░ ░░░░░░░░░   ░░░░░ ░░░░▒░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░ ░░▒░░░░░░▒▒▒▒▒▒▒▒▒▒░▒░░░░░░░░░░░░ ░░░░░░░░░   ░░░░░░░ ░░░░░░░░░░░░▒▒▒▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒░▒░░░░░▒░░░░░░ ░ ░░░░░   ░░░░░░░░░ ░░░░░░░░░░░░▒▒▒▓▓▓▓█▓▓▓▓▓▒▒▒▒▒▒▒▒▒▓▓▓▒▒▒░░░░░░░▒▒░░░░ ░░░░░░   ░░░░▒░▒▒░░░ ░░░░░░░▒▒▒▓████████▓▓▓▓▓▓▓▓▓▓▒▓▒▒▒▒░░▒▒░▒▒░▒░░░░ ░░░░   ░░▒░▒▒▒░░░ ░░░▒▒▓▓▓███████▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░▒░▒░░░░░ ░░░░░░   ░░░▒▒▒▒▒░░ ░░░░▒▒▒▓▓██████▓▓▓▓▓▓▓▒▒▒▒▒░▒░░░░▒░░░░░ ░░░░░   ░░░▒░▒▒░░░ ░░░░░▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒░░░░░░░░░░░░░░░ ░░░░░░░   ░░░░░░░░░ ░░░░░░░░░░░░░▒▒▒▒▒░▒▒▒▒░░░░ ░░░░░░░ ░░░░░░░░   ░░░░░░░░░ ░░░░░ ░ ░░░░░░░░░░ ░ ░░░ ░ ░░░░░░░░░   ░ ░░░░░░░░ ░░░░░░ ░░░░░ ░░░░░░░░   ░░░░░░░░░░░░░░░░░░░ ░░░░░░░ ░░░░░░░░░   ░░░░▒░▒░░░░░░░░░░░░ ░░░░░░░ ░░░░░░░░░░   ░░░▒▒▒▒▒░░░░░░░░░░ ░░░░░░░░░░░░░ ░░░░░░░░   ░░░░▒▒▒▒▒▒░░░░░░░░░░░ ░░ ░ ░░░░░░░░░░░▒░░░░ ░░░░░░░░░   ░░░▒▒▒░░░░░░ ░░░░░░░░▒░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░░░ ░░░░░░░░   ░░░░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░░░░░░░░   ░ ░ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░░░░░░   ░░░▒▒▒▒▒▒▒▒▒░▒▒░░░░░░ ░░░░  [?7h[?25l[?7l · ████████████████████████   ✦ ✦ ████████████████████████   ████ ████ ·   ████ ████ ·   ✧ ▒▒▒▒ ████ ✦  ▒▒▒▒ ████   ✦ ✧ ✧ ████ · ✦   ████   ░░░░▒░░░░░░ ░▒▒▒██████▓▓▒░░ ░░▒▒▓▓▓▓▒▒░░ ░░░░▒▒▓▓████▓▓▒░░ ░░░░░▒░▒░░░░░░   ░░░░░░░░░ ░░░▒▓▓█████▓▓▒░ ▒▒▒▓▓▓▒▒▒░░░░░▒▒▒▓▓▓█▓▓▒▒░░░ ░░░░░░░░░░░░░   ░░░░░░░░░ ░░▒▒▓▓█████▓▓▒░ ░▒▒▒▒▒░░░░░░░▒▒▒▓▓▓▓▒▒▒▒░░░ ░ ░░░░░░░░░░░   ░░░░░ ░ ░▒▒▒▒▓▓▓▓▓▓▓▓▒▒░ ░▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░ ░ ░░░ ░░░░░░░░░   ░░░░░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▓▒▒░░░ ░▒░░░░░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░░░░   ░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░▒░░░░░░▒▒▒▒▒▒▒▒▒▒░▒░░░░░░░░░░░░ ░░░░░░░░░   ░░░░░ ░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒░░░▒▒▒▒▒░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░░▒░░░░░░ ░ ░░░░░   ░░░░░░░ ░ ░░░░░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▒▒▒▒▒░░░░░▒▒░░░░░░ ░░░░░   ░░░░░░░░░░░ ░ ░░░░░░░▒▒▒▓███████▓▓▓▓▒▒▒▓▓▓▓▒▓▒▒▒▒░▒▒▒░▒▒░▒░░░░ ░░░░   ░░▒░░▒▒░░░ ░░░▒▒▓▓████████▓▓▓▓▓▓▓▓▓▓▒▒▒▒░▒░░▒░▒░▒░░░░ ░░░░░░   ░░░▒▒▒▒▒░░ ░░░░▒▒▓▓▓██████▓▓▓▓▓▓▓▓▓▒▒▒░▒░░░░▒░░░░░░ ░░░░░   ░░░▒▒▒▒░░░ ░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒░░░░░░░░░░░░░░░ ░░░░░░ ░   ░░░░░░░░ ░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░ ░ ░░░░░░░ ░░░░░░░░   ░░░░░░░░ ░░░░░ ░ ░░ ░░░░░░░░░░ ░ ░ ░░░ ░░ ░░░░░░░   ░░░░░░░░░ ░░░░░ ░░░░░ ░░░░░░░░░   ░░░░░░░░░ ░ ░░░░░░░ ░░ ░░░░ ░░░░░░░░░   ░░░░▒░▒░░░░░░░░░░░░ ░░░░░░░ ░ ░░░░░░░░   ░░░▒▒▒░▒░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░░   ░░░░▒▒▒▒▒▒░░░░░░░░░░ ░░░░░░░░░░▒░░░░ ░ ░░░░░░░░   ░░░▒▒▒░░░░░░░░░░░░░░░░░░░░░░▒▒▒░▒░▒▒▒░░░░░ ░ ░░ ░░░░░░░░░   ░░░░░░░░░░ ░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░ ░░ ░░░░░░░░   ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░ ░░░░   press ⏎ to skip ░  [?7h[?25l[?7l · ████████████████████████   ✦ ✦ ████████████████████████   ████ ████ ·  ████ ████ ✦   ████ ████ ·   ████ ████ ·   ▒▒▒▒ ████   ✦ ✧ ✧ ████ · ✦   ████   ░░░░░░░░░░░ ░▒▒▓██████▓▓▒░░ ░▒▒▓▓▓▓▓▒▒░░░░░ ░░▒▓▓████▓▓▒▒░░ ░░░░░░▒░░░░░░   ░░░░░░░░░ ░░░▒▓▓█████▓▒▒░ ░▒▒▓▓▓▓▓▒▒░░░░░░░▒▓▓▓█▓▓▓▒▒░░ ░░░░░░░░░░░░░   ░░░░░░░░░ ░░▒▒▓▓█████▓▓▒░ ▒▒▒▒▒▒▒░░░░░░░▒▒▒▓▓▓▒▒▒▒░░░ ░ ░░░░░░░░░░░   ░ ░░░░░ ░ ░▒▒▒▒▓▓▓██▓▓▓▒▒░ ░▒▒░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░ ░ ░ ░░░ ░░░░░░░░░   ░░░░░░░ ░░░▒▒▒▒▒▓▒▓▓▓▒▓▒▒░░ ░▒░░░░░░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░░░░░░░░░░   ░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░▒░░░░░░░░▒▒▒▒▒▒▒▒░▒░░░░░░░░░░░░ ░░░░░░░   ░░░░░░ ░░░░░▒░▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░▒▒░░░░▒▒▒▒▒▒▒▒▒▒▒░▒░░░▒░░░░░░ ░░░░░   ░░░░░░ ░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░▒▒░░░░░░ ░░░░   ░░░░░░░░░ ░░ ░░░░░░░▒▒▒▓▓▓████▓▓▓▒▒▒▒▒▒▒▓▒▒▓▒▒▒▒▒▒▒▒░▒▒░▒░░░░ ░░░░░   ░░▒░░░░░░░ ░░░▒▓▓▓███████▓▓▓▒▓▒▓▓▓▓▓▒▒▒▒░▒░░▒░▒▒▒░░░░ ░░ ░░░   ░░░▒░▒░░░ ░░░░░▒▒▓▓▓██████▓▓▓▓▓▓▓▓▓▒▒▒▒▒░░░░▒░░░░░░░ ░░░░░   ░░░▒▒▒▒░░░ ░░░░░▒▒▒▓▓▓▓█▓▓▓▓▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░ ░░░░ ░   ░░░▒▒░░░░ ░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░ ░░░░░░░ ░ ░░░░░░░░   ░░░░░░░░ ░░░░░░░ ░░░░░░░░░░░░░░░░░░░ ░ ░░░░░░ ░░░░░░░ ░   ░░░░░░░ ░░░░░ ░ ░░░░░ ░░░░░░░   ░░░░░░░░ ░░░░░░░ ░░░░ ░░░░░░░░░░   ░░░░░░░░░░░░░░░░░░ ░ ░░░░ ░░░░░░░░░   ░░░▒▒▒░▒░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░   ░░░░▒▒▒▒▒▒░░░░░░░░░░ ░ ░░░░░░░░░░░░░ ░ ░░░░░░░░░   ░░░░▒▒▒░░░░░░░░░░░░░░░░░░░░░░▒░░░▒░▒▒░░░░░░ ░ ░ ░░░░░░░░░   ░░░░░░░░░░ ░░░░░░░▒▒▒▒░▒░░▒▒░▒▒▒▒▒▒▒░░░░░ ░░ ░░░░░░░░░   ░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░ ░░░░░░░░   ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░▒░░░░ ░░░░   ░ press ⏎ to skip ░ ░  [?7h[?25l[?7l ✦ · ✧ ·   ✦ ·   · ✧   ·     ✧ ✦ ✧   ✦ · ████████████████████████   ✧ ████████████████████████   ████ ████ ✧   ✦ ████ ████ ✧   ✧ ████ ████   ✧ ✧ · ████ ████ ✦   · ▒▒▒▒ ████ ·   ▒▒▒▒ ████ ·   · ████ ✦ ✧ ✧ ✦   ░░░░░░░░░░░░ ░▒▒▓█████▓▓▒░░░ ░░▒▒▓▓▓▓▓▓▒▒▒░░░ ░░▒▒▓▓███▓▓▓▒▒░ ░ ░░░░░░░░░░░░   ░░░░░░░░░░ ░░▒▒▓▓█████▓▒░░ ░░▒▓▓▓▓▓▓▒▒░░░░░░░▒▒▓▓█▓▓▓▒▒░░ ░░░░░░░░░░░░   ░░░░░░░░░░ ░▒▒▒▓▓█████▓▒▒░ ░▒▒▓▒▓▒▒░░░░░░░░▒▒▓▓▓▓▓▒▒░░░░ ░ ░░░░░░░░░░░   ░░░░░░░ ░ ░▒▒▒▒▓▓▓██▓▓▓▒▒░ ░░▒▒▒░░░░░ ░░▒▒▒▒▒▒▒▒▒▒░░░░░ ░ ░ ░░░ ░░░░░░░░░   ░░░░░░ ░░░▒▒▒▒▒▓▓▓▓▓▒▓▒▒░░ ░▒░░░░░░░░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░ ░ ░░░░░░░   ░░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░▒░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░ ░ ░░░░░   ░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░▒░░░░░▒▒▒▒▒▒▒▒▒▒▒░▒░░░▒░░░░░░░ ░░░░░   ░░░░░░░ ░░░░░░░░░░░▒▒▒▒▒▓▓▓▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░▒▒░░░░░░ ░░░░   ░░░░░░░░░ ░░░░░░░░░░▒▒▒▓▓▓█▓█▓▓▓▒▒▒▒▒▒▒▒▒▒▒▓▒▒▒▒▒▒▒▒░▒▒▒▒▒░░░ ░░░░   ░░▒░░░░░░░ ░ ░░░▒▓▓▓███████▓▓▒▒▓▒▓▓▓▓▓▒▒▒▒▒▒░░▒░▒▒▒▒░░░ ░░░ ░   ░░░▒░▒░░░ ░░░░░▒▒▓▓███████▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░▒░░▒▒░░░ ░░░░░ ░   ░░░▒▒▒░░░ ░░░░░▒▒▒▓▓▓███▓▓▓▒▒▓▒▒▒▒▒▒░░░░░░░░░░░░░░ ░ ░░░░░░░   ░░░░▒▒░░░ ░░░░░░░▒▒▒▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░ ░░░░░░░░   ░░░░░░░░ ░░░░░░░ ░░░░░▒░▒░▒░░░░░░░░░ ░ ░░░░░░ ░░░░░░ ░   ░░░░░░░ ░░░░░ ░░ ░░ ░ ░░░░░ ░░░░░░   ░ ░░░░░ ░░░░░░░ ░░ ░ ░░░░░░░░░   ░░░░░░░░░░░░░░░░░ ░ ░░░░ ░░░░░░░░░   ░░░▒▒▒░░░░░░░░░░░ ░░░░░░░░ ░░░░░░░░░░   ░░░░▒▒▒▒░░░░░░░░░░░ ░ ░ ░░░░░░░░░░░ ░░░░░░░░░   ░░░░▒▒▒░░░░░░░░░░░░░░ ░░ ░ ░░░░░░░░░░░░░░░ ░ ░░░░░░░░░   ░░░░░░░░░░░ ░░░░░░░▒░▒░░░░░░░░▒░░▒▒▒▒░░░░░░░ ░░░░░░░░░░   ░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░ ░░░░░░░░   ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░▒░░░░ ░░░░░░   ░ press ⏎ to skip ░ ░░  [?7h[?25l[?7l ✦ · ████████████████████████   ✧ ████████████████████████   ▒▒▒▒ ████ ·   ░░░░░░░░░░░░ ░▒▓▓█████▓▒▒░░░ ░░▒▒▓▓▓▓▓▓▓▒▒▒░░░ ░░▒▒▓█████▓▒▒░ ░░░░░░░░░░░░   ░░░░░░░░ ░▒▒▒▓▓████▓▓▒░ ░░▒▓▓▓▓▓▓▓▒▒░░░░░░░▒▒▓▓▓▓▓▓▒▒░░ ░░░░░░░░░░░   ░░░░░░░░ ░ ░▒▒▒▓▓████▓▓▒░░ ░░▒▒▓▓▓▒▒▒░░░░░░░▒▒▓▓▓▓▓▒▒▒░░░ ░░░░░░░░░░░   ░░░░░░░ ░ ░▒▒▒▒▓▓███▓▓▒▒▒░ ░▒▒▒▒▒▒░░░ ░░░▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░ ░░░ ░░░░░░░░   ░░░░░ ░░░▒▒▒▒▓▓▓▓▓▓▒▒▒▒░░ ░▒░▒░░░░ ░░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░░░░░░░   ░░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░▒░░░░░░░░▒░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░ ░░░░░   ░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░▒░░░░░░░░▒▒▒▒▒▒▒▒░▒░░░▒░░░░░░░░ ░░ ░░░   ░░░░░ ░░░▒░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒░▒░░░░▒▒▒▒▒▒▒▒▒▒▒░░░▒▒░░░░░░ ░░░░   ░░░░░░░░ ░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒░░░ ░░░   ░░░░░░░░░ ░ ░░░▒▓▓▓█████▓▓▓▓▒▒▒▒▓▒▒▒▓▒▒▒▒▒▒░░▒░▒▒▒▒░░░ ░░░ ░   ░░░▒░▒░░░ ░░░░▒▒▒▓▓███████▓▓▓▒▓▓▓▓▓▓▒▒▒▒░▒░░▒░▒▒▒▒░░ ░░░░ ░   ░░░░▒░░░░ ░░░░▒▒▒▒▓▓▓███▓▓▓▒▒▓▓▒▒▒▒▒▒▒░░░░░░░░░░░░ ░░░░░░░   ░░░░▒▒░░░ ░░░░░░░▒▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░   ░░░░░░░░░ ░░░░░░░░░░▒░▒▒▒░▒░▒▒░░░░░░░ ░ ░░░░░░ ░░░░░░ ░   ░░░░░░ ░░░░ ░ ░░░░░░ ░░ ░░░░░ ░░░░░░   ░ ░░ ░░░░░░░ ░░ ░ ░░░░░░░░░   ░░░░░░░ ░░░░░░░░░ ░ ░░ ░░░░░░░░░   ░░░░░░░░░░░░░░░░ ░░░░░░░░ ░░░░░░░░░   ░░░░░▒▒▒▒░░░░░░░░ ░░ ░ ░ ░░░░░░░░░░ ░░░░░░░░░░   ░░░░▒▒▒░░░░░░░░░░░░ ░ ░░░░░░░░░░░░░░░ ░ ░░░░░░░░░░   ░░░░▒░░░░░░░ ░░░░░░░░░░░░░░░░░░▒░░░▒▒▒░░░░░░░ ░░░░░░░░░░░   ░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░ ░ ░░░░░░   ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░▒░░░░ ░░░░░░   ░░░ press ⏎ to skip ░░ ░░  [?7h[?25l[?7l ✦ · ████████████████████████   ✧ ████████████████████████   ░░░░░░░░░░░░ ░░▒▓▓████▓▓▒▒░ ░ ░░░▒▒▓▓▓▓▓▓█▓▓▒▒░░░ ░░▒▒▓▓▓███▓▒▒░░ ░░░░░░░░░░░░░   ░░░░░░░░ ░▒▒▒▓▓███▓▓▒▒░ ░░▒▓▓▓▓▓▓▓▓▒▒░░░░░░░▒▒▓▓▓▓▓▓▒▒▒░ ░░░░░░░░░░░░   ░░░░░░░░ ░ ░▒▒▒▓▓███▓▓▓▒░░ ░░▒▒▓▓▓▓▒▒░░░░░░░▒▒▒▒▓▓▓▒▒▒░░░ ░ ░░░░░░░░   ░░░░░░░ ░ ░▒▒▒▒▓▓███▓▓▒▒░░ ░▒▒▒▒▒▒░░░ ░░░▒▒▒▒▒▒▒▒▒░░░░ ░ ░ ░░░ ░░░░░░░░   ░░░░░░ ░░░▒▒▒▒▓▓▓▓▓▓▒▒▒░░ ░░▒░▒░░░░ ░░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░░   ░░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▓▒▒▒▒░░ ░▒░░░░ ░░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░ ░ ░░░░   ░░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░▒░░░░░░░░▒▒▒▒▒▒▒▒░▒░░░▒░░░░░░░░ ░░░   ░░ ░░ ░░░░▒░▒░░░░░▒▒▒▒▒▒▒▒▒▒▒░░░▒░▒░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░▒▒░▒░░░░░ ░░░   ░░░░░░░░ ░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒░░░ ░░░░   ░░░░░░░ ░ ░░ ░░░░▒▒▓▓▓▓████▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░▒░▒▒▒▒▒░░░ ░░ ░   ░░░▒░░░░░ ░░░░▒▒▒▓▓██████▓▓▓▓▒▓▒▓▒▓▓▒▒▒▒▒▒░░▒░▒▒▒▒░░░ ░░░░░   ░░░░▒░░░░ ░░░░▒▒▓▓▓█████▓▓▓▒▒▒▓▒▒▒▒▒▒▒░▒░░░░░░░░░░░ ░░░░░░░   ░░░░▒░░░░ ░░░░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░ ░░░░░   ░░░░▒░░░ ░░░░░░░░░▒▒▒▒▒▒▒▒░▒▒▒▒░░░░░ ░░░░░░░░ ░░░░░░░   ░░░░░░ ░░░░ ░░░░░░░░░░░░░░ ░░░░░ ░░░░░░   ░ ░░ ░░░░░░ ░ ░ ░░░░░░░░   ░░░░░ ░ ░░░░░░░░░ ░ ░░ ░░░░░░░░░   ░░░░░░░░░░░░░░░░ ░ ░░░░░ ░░░░░░░░   ░░░░░▒▒▒░░░░░░░░░ ░░ ░ ░ ░░░░░ ░░░░░░░░░░   ░░░░░▒▒▒░░░░░░░░░░░░ ░░░░░░░░░░░░░░░ ░ ░░░░░░░░░░   ░░░░▒░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░▒▒▒░░░░░░░ ░░░░░░░░░░░   ░░░░░░░ ░░░░░▒▒▒▒▒▒░▒▒▒▒▒░▒▒▒▒▒░░░░░░ ░ ░░░░░░   ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░▒░░░░░ ░░░░░░   ░░░ press ⏎ to skip ░░░ ░░  [?7h[?25l[?7l ✦ · ████████████████████████   ✧ ████████████████████████   ████ ████ ✧   ✦ ████ ████ ✧   ░░░░░░░░░░░░ ░░▒▓▓█▓█▓▓▒▒▒░ ░ ░░▒▒▓▓▓▓▓▓▓█▓▓▒▒░░░ ░░▒▓▓▓███▓▓▒▒░ ░░░░░░░░░░░░░   ░░░░░░░░ ░▒▒▒▓▓███▓▓▒▒░ ░░▒▒▓▓▓▓█▓▓▓▓▒▒░░░░░░▒▒▒▓▓▓▓▓▒▒▒░ ░░ ░░░░░░░░░   ░░░░░░░░ ░▒▒▒▓████▓▓▒▒░░ ░░▒▒▒▓▓▓▓▒▒▒░░░░░░░▒▒▒▓▒▓▒▒▒░░░ ░ ░░░░░░░░   ░░░░░░░ ░ ░▒▒▒▒▓▓███▓▓▒▒░ ░░▒▒▒▒▒▒▒░░ ░░░▒▒▒▒▒▒▒▒▒▒░░░ ░ ░ ░ ░ ░░░░░░░░   ░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▓▒▒▒░ ░░▒▒▒░░░░ ░░░░░▒▒▒▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░░   ░░░░░░ ░░░░▒▒▒▒▒▒▓▓▓▓▓▒▒░░ ░▒▒░░░ ░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░░░   ░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░▒░░░░░░░░░▒▒▒▒▒▒▒░▒░░░▒░░░▒░░░░ ░░░ ░   ░░ ░░ ░ ░░░▒▒▒▒▒░▒▒░▒▒▒▒▒▒▒▒░░░░░░░░▒░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░▒▒░▒▒░░░░ ░░░   ░░░░░ ░░░░░░░░░░░░▒▒▒▒▓▓▓▒▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒░░ ░░░░   ░ ░░░░░░░ ░░ ░░ ░░░░▒▒▓▓▓▓▓█▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░▒░▒▒▒▒▒░░░░ ░░ ░   ░░░░░░░░░ ░░░░▒▒▒▓▓██████▓▓▓▓▒▒▒▒▒▓▓▒▒▒▒▒▒░░▒░▒▒▒▒░░░░ ░░░░░   ░░░░░▒░░░░ ░░░░▒▒▓▓██████▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░░▒░░░░░░░ ░░░░   ░░░░░▒░░░░ ░░░░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░   ░░░░▒░░░ ░░░░░░░░▒▒▒▒▒▒▒▒▒░▒▒▒▒░▒░░░░░░░░░░░░░ ░░░░░░   ░░░░░░░░ ░░░ ░ ░░░░░░░░░░░░░░░░░ ░░░░░ ░ ░░░░░░   ░░░░░░ ░ ░░░░ ░ ░ ░ ░ ░░░░░░ ░   ░░░░░░ ░ ░░░░░░ ░░░░░░░ ░   ░░░░░░░░░░░░░░░░ ░░ ░ ░░░░░░░░   ░░░░░░▒░░░░░░░░░░ ░░░░░ ░░░░░░░░   ░░░░░░▒▒▒░░░░░░░░░░ ░░░░░░ ░░░░░░░░ ░ ░░░░░░░░░░   ░░░░▒░░░░░░░ ░░░░░░░░░░░ ░░░░░░░░░░░▒▒░░░░░░░ ░░░░░░░░░░░   ░░░░░░░░ ░░░░░▒░░▒░▒░░▒░▒▒░▒▒▒▒▒░░░░░░ ░░░░░░░   ░ ░░░░░░▒▒▒▒▒▒▒▒▒░▒▒░░▒░░░░░░ ░░░░░░░░   ░░░ press ⏎ to skip ░░░ ░░ ░  [?7h[?25l[?7l ✦ · ████████████████████████   ✧ ████████████████████████   ████ ████ ✧   ✦ ████ ████ ✧   ✧ ████ ████   ✧ ✧ · ████ ████ ✦   · ▒▒▒▒ ████ ·   ░░░░░░░░░░░ ░░▒▒▓▓█▓▓▓▒▒▒░░ ░░░░▒▒▓▓▓▓▓▓▓█▓▓▒▒▒▒░ ░░▒▒▓▓▓██▓▓▓▒░░ ░░░░░░░░░░░░   ░░░░░░░░ ░░▒▒▓▓▓▓█▓▓▒▒░░ ░░░▒▒▓▓▓▓█▓█▓▓▒▒░░░░ ░▒▒▒▓▓▓▓▓▓▒▒░░ ░░ ░░░░░░░░░ ░   ░░░░░░░ ░░▒▒▒▓███▓▓▓▒▒░ ░▒▒▓▓▓▓▓▓▓▒▒░░░░░░░░▒▒▒▒▓▒▒▒▒░░░ ░ ░ ░░░░░░   ░░░░░░░ ░ ░▒▒▒▒▓▓███▓▓▒▒░ ░▒▒▒▓▒▒▒▒░░ ░░░░▒▒▒▒▒▒▒▒▒░░░░░ ░ ░ ░ ░░░░░░░░   ░░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▓▒▒▒░ ░░▒▒▒▒▒░░ ░░░░░▒▒▒▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░░░░   ░░░░░░ ░░░░▒▒▒▓▒▒▓▓▓▓▓▒▒░░ ░▒▒▒░░░░░ ░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░░░   ░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░▒░░░░░░░░░░▒▒▒▒▒▒▒▒░░░▒░░░▒░░░░ ░░░ ░   ░░ ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒░░░░░░░░▒▒▒▒▒▒▒▒▒░░░▒▒░▒▒▒░░░░ ░   ░░ ░░░░░░░░░░░░░▒▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒▒▒░░▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒░░░ ░ ░░   ░ ░░░░░ ░░░░░░ ░░░░▒▒▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░▒░▒▒▒▒▒░░░░ ░ ░   ░░░░░░░░░ ░░░░▒▒▒▒▓▓██████▓▓▓▒▒▒▒▒▒▓▓▒▒▒▒▒▒░░▒░▒▒▒▒░░░░ ░░ ░░   ░░░░░▒░░░░ ░░░░░▒▒▓▓██████▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░░▒░░░▒░░░░ ░░░░   ░░░░░▒░░░░ ░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░   ░░░░░▒░░░ ░░░░░░░▒▒▒▒▒▓▒▒▒▒░▒▒▒▒▒▒▒░░░░░░░░░░░░ ░░░░░░   ░ ░░░░░░░ ░░ ░░░░░░░░░░░░░░░░░░░░ ░ ░░░░░░░ ░░░░░░ ░   ░░░░░░ ░░░░ ░░░░ ░ ░ ░░░░░░░░   ░░░░ ░ ░░░░░ ░░░░░░░░░   ░ ░░░░░░░░ ░░░░░░░ ░ ░ ░░░░░░░   ░░░░░░░░░░░░░░░░░ ░ ░░░ ░░░░░░░░░   ░░░░░░▒▒▒░░░░░░░░ ░ ░░░░░ ░░░░░░░░ ░ ░░░░░░░░░░   ░░░░░▒░░░░░░░ ░░░░ ░░░ ░ ░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░   ░░░░░░░░░░ ░░░░░░░░░░░░░▒░░▒░░▒▒▒▒░░░░░░ ░░░░░░░░   ░ ░ ░ ░░░░░░▒▒▒▒▒▒▒▒▒░▒▒░░▒░░░░░░ ░░░░░░░░░   ░░░░ press ⏎ to skip ░░░ ░░ ░  [?7h[?25l[?7l ✦ · ████████████████████████   ✧ ████████████████████████   ████ ████ ✧   ✧ ████ ████   ✧ ✧ · ████ ████ ✦   · ▒▒▒▒ ████ ·   ▒▒▒▒ ████ ·   · ████ ✦ ✧ ✧ ✦   ████   ░░░░░░░░░░░ ░░▒▒▓▓▓▓▓▒▒░░░ ░░░▒▒▓▓████▓▓▓▓▓▓▓▓▒▒░░ ░░░▒▒▓▓▓██▓▓▒▒░░ ░ ░░░░░░░░░   ░░░░░░░░ ░░▒▒▓▓▓▓▓▓▒▒░░ ░░▒▒▒▓▓██▓▓▓▓██▓▓▒▒░░░ ░░▒▒▓▓▓▓▓▒▒▒░░ ░░░░░░░░░   ░░░░░░░ ░▒▒▒▓▓▓█▓▓▒▒▒░ ░░▒▒▒▓▓▓▓█▓▓▓▒▒░░░ ░░▒▒▒▒▓▒▓▒▒▒░░░ ░ ░ ░░░░ ░   ░░░░░░░ ░ ░▒▒▒▒▓▓██▓▓▒▒░░ ░░▒▒▒▓▓▓▓▓▒▒▒░ ░░░░░░▒▒▒▒▒▒▒▒░░░░░░ ░ ░ ░░░░░   ░░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▓▒▒▒░ ░░▒▒▒▒▒▒░░░░░ ░░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░░   ░░░░░░ ░░░░▒▒▒▓▒▓▓▓▓▓▒▒░░ ░░▒▒▒▒░░░░ ░░░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░   ░░░ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░▒▒░░░░░░ ░░░░▒░░▒▒▒░░░▒░░░▒░░░░░ ░ ░   ░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░▒░░░░ ░░░▒▒▒▒▒▒▒▒░░░▒▒░▒▒▒░░░░   ░ ░░░░░▒▒░▒▒░░░░▒▒▒▒▒▒▒▒░▒░░░░░▒▒░░░░░░░▒▒▒▒▒▒▒▒▒░▒▒░▒▒▒▒▒░░ ░░   ░ ░░░ ░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒░▒▒░▒▒▒▒▒▒▒░░░ ░   ░░░░░░░ ░ ░░░░░▒▒▒▒▓▓▓████▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒░░░░░░ ░░   ░░░░░░░░░ ░░░░░▒▓▓▓██████▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░░▒░░░▒▒░░░░ ░ ░░   ░░░░░░░░ ░░░░▒▒▒▓▓▓███▓█▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░ ░░░░   ░░░░░░░░░ ░░░░░▒▒▒▒▓▓▓▓▓▓▒▒░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░░░░░ ░   ░░░░░░░░░ ░░░░░░░░▒▒▒▒▒▒░░░░▒░▒░░░░░░░░░░░░░░ ░░░░░░░   ░ ░░░░ ░░░░░ ░░░░░░░░░░░░░ ░ ░ ░ ░░░░░░░   ░░ ░ ░ ░░░ ░ ░░░░░░░░░   ░░ ░ ░░░░ ░░░░░ ░ ░ ░░░░░░░░   ░░░░░░░░░░░░░░░ ░ ░░░░░░░░░   ░░░░░░▒░░░░░░░░░░ ░ ░░░ ░ ░ ░ ░░░░░░░░░   ░░░░░▒░░░░░░░ ░░░░ ░░░░░ ░░░░░░░░░░ ░░░░░░░░░░   ░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ░ ░░░░░░   ░░ ░░░ ░ ░░░░░░░▒▒░░░▒░▒░▒░░░░░░░░░░░░ ░░░░░░░░░   ░░░░ press ⏎ to skip ░░░░░ ░░░░░░  [?7h[?25l[?7l ✦ · ████████████████████████   ✧ ████████████████████████   ████ ████ ✧   ✦ ████ ████ ✧   ✧ ████ ████   ✧ ✧ · ████ ████ ✦   · ▒▒▒▒ ████ ·   · ████ ✦ ✧ ✧ ✦   ████   ░░░░░░░░░░ ░░▒▒▒▓▒▓▒▒▒░░░░ ░░▒▒▓▓█████▓▓▓▓▓▓▓▓▒▒▒░░ ░░▒▒▒▓▓██▓▓▓▒░░ ░ ░░░░░░░░░░   ░░░░░░░░ ░░▒▒▒▓▓▓▓▓▒▒▒░░ ░░▒▒▒▓▓▓██▓▓▓▓██▓▓▒▒▒░░ ░░░▒▒▓▓▓▓▓▒▒░░ ░░░░░░░░░   ░░░░░░░ ░▒▒▒▓▓▓▓▓▓▒▒▒░ ░░▒▒▓▓▓▓▓▓█▓▓▓▓▒▒░░░ ░░░▒▒▒▓▒▓▒▒▒▒░░ ░ ░ ░░░░ ░   ░░░░░░░ ░░░▒▒▒▓▓▓██▓▓▒▒░ ░░▒▒▒▓▓▓▓▓▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒░░░░░░ ░ ░ ░░░░░   ░░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▓▒▒░ ░▒▒▒▒▓▒▒▒░░░░ ░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░░   ░░░░░ ░░░░▒▒▒▓▓▓▓▓▓▓▒▒░░ ░░▒▒▒▒░░░░ ░ ░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░ ░░   ░░ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░▒▒▒░░░░░ ░░░░░░░▒░▒░░░▒░░░▒░░░░░ ░   ░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░▒░░░░ ░░░░▒▒░▒▒▒░░░▒▒░▒▒▒░▒░░   ░ ░ ░░░░▒▒▒▒▒▒▒▒░▒░▒▒▒▒▒▒░░░░░░░░░▒░░░░░░░░▒▒▒▒▒▒▒▒▒░▒▒░▒▒▒▒▒░░░ ░░   ░ ░░░ ░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒░░▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒░░░ ░   ░░░░░ ░ ░░░ ░░░░░▒▒▒▒▓▓▓█▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒░░░░░░ ░░   ░░░░░░░░░ ░░░░░▒▒▓▓▓█████▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░▒░░░▒▒░░░░ ░░   ░░░░░░░░ ░░░░▒▒▒▓▓████▓█▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░▒░░░░░░░░ ░░░░░ ░   ░░░░░░░░░ ░░░░░▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░░░░░ ░░   ░░░░░░░░░░ ░░░░░░░▒▒▒▒▒▒▒▒░░▒▒░▒▒░░░░░░░░░░░░░ ░░░░░░░   ░░░░░░ ░░░░░ ░░░░░░░░░░░░░░░░░ ░ ░ ░ ░ ░░░░░░░   ░░ ░ ░ ░░ ░ ░░░░░░░░░   ░░ ░ ░░░░░ ░░░░░░░   ░░░░░░░░░░░░░░░░ ░ ░░░░░░░░░   ░░░░░░░░░░░░░░░░░░ ░░░ ░ ░ ░ ░░░░░░░░   ░░░░░▒░░░░░░░ ░░░░ ░░ ░ ░░░░░░░░░ ░░░░░░░░░   ░░░░░░░░░░░ ░ ░░ ░░░░░░ ░░░░░░░░░░░░░░░░░░░ ░ ░░░░░░░   ░░░░░░ ░ ░░░░░░░░░░░░░░▒░▒░░░░░░░░░░░░ ░░░░░░░░░   ░░░░ press ⏎ to skip ░░░░░ ░ ░░░░░░  [?7h[?25l[?7l · ✦ · ✦ ✦ ·   ✦   ✧   ✧ ✧   ✦ ✦ ·   ✧   ✧ ████████████████████████   ✦ ████████████████████████✦ ✧  ████ ████ ✦   ████ ████   · ████ ████ · ·   ✦ ████· ████   ▒▒▒▒ ████ ✧ ✧ ✦   · ▒▒▒▒ ████ · ✧   ✧ ✧ ✧ ████   ░░░░░░░░░░ ░░░▒▒▓▓▒▓▒▒░░░░░ ░▒▒▓▓██████▓▓▓▓▓▓▓▓▒▒▒░░ ░░▒▒▓▓██▓▓▓▒▒░ ░ ░░░░░░░░░░   ░░░░░░░ ░░▒▒▒▒▓▓▒▒▒▒░░░ ░▒▒▒▓▓▓▓██▓▓▓▓███▓▓▒▒░░ ░░░▒▒▓▓▓▓▓▓▒░░░ ░░░░░░░░░   ░░░░░░░ ░▒▒▒▓▓▓▓▓▒▒▒░░ ░░▒▒▒▓▓▓▓▓▓█▓▓▓▓▒▒░░░ ░░░░▒▒▒▒▓▒▒▒▒░░ ░ ░ ░░░░ ░   ░░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▒▒░░ ░░▒▒▒▒▓▓▓▓▓▓▓▒▒░░░░ ░░░▒▒▒▒▒▒▒▒░░░░░░ ░ ░ ░░ ░░   ░░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▒▒▒░ ░░▒▒▒▒▓▒▒▒▒░░░ ░░░░▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░░   ░░░░░ ░░░░▒▒▒▓▓▓▓▓▓▓▒▒░ ░░▒▒▒▒▒░░░░ ░ ░░░▒▒░▒▒▒▒░░░░░░░░░░░░ ░░   ░░░░ ░░▒▒▒▒▒▒▒▓▒▒▒▒▒▒░░ ░░▒▒▒░░░░░ ░░░░░░▒░▒░░░░░░░▒░░░░░ ░   ░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░▒░░░░ ░░░░░▒░▒▒▒░░░░▒░▒▒▒░▒░░   ░ ░ ░░░░▒▒▒▒▒▒▒▒▒▒░▒▒▒░░▒░░ ░░░░░░▒░░░░░░░░░▒▒▒▒▒▒▒▒░▒▒░▒▒▒▒▒░░░░ ░░   ░ ░ ░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒░░░░ ░   ░░░░░ ░░░ ░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒░░░░░░░ ░░   ░░░░░░░░ ░░░░░▒▒▓▓▓█████▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░▒░░░▒▒░░░░░ ░░   ░░░░░░░░ ░░░░▒▒▒▓▓████▓█▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░▒░▒▒░░░░░░░ ░░░░ ░   ░░░░░░░░ ░░░░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░ ░░░ ░░   ░░░░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒░░▒▒▒▒▒▒░░░░░░░░░░░░ ░ ░░░░░░░   ░░░░░░ ░ ░░░ ░░░░░░░░░░░░░░░░░░░░ ░ ░ ░ ░ ░░░░░░░   ░░ ░░ ░ ░ ░░░░░░░   ░░ ░░ ░ ░░░░░░░░░   ░░░░░░░░░░░░░░░ ░ ░░░░░░░   ░░░░░░░░░░░░░░░░░░ ░░ ░ ░ ░ ░░░░░░░░░   ░░░░░▒░░░░░░░ ░░ ░░ ░ ░░░░ ░░░░ ░░░░░░░░░ ░   ░░░░░░░░░░░ ░ ░░ ░ ░ ░░░░░░░░░░░░░░░░░ ░ ░░░░░░░   ░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░   ░ ░░░░ press ⏎ to skip ░░░░░ ░ ░░░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████   ✦ ████████████████████████✦ ✧  ░░░░░░░ ░░ ░░▒▒▒▒▓▒▒▒▒░░ ░░░▒▒▓▓█████▓▓▒▓▓▓▓▓▓▓▓▒░░ ░▒▒▒▓▓█▓▓▓▒▒░░ ░░░░░░░░░░   ░░░ ░░░ ░░░▒▒▒▒▒▒▒▒▒░░░░ ░░▒▒▓▓▓████▓▓▓▓███▓▓▓▒▒░ ░░░▒▒▒▓▓▓▓▓▒▒░░ ░░░░░░░░░   ░░░░░░░ ░▒▒▒▒▓▓▓▓▒▒▒░░ ░ ░░▒▒▒▓▓▓▓▓▓▓███▓▓▓▒▒░░ ░ ░░▒▒▒▒▓▒▒▒▒░░ ░ ░ ░░░ ░   ░░░░░░░░ ░░░▒▒▒▒▓▓▓▓▓▒▒░░ ░░▒▒▒▒▓▓▓▓▓▓▓▓▒░░░░ ░░░░░▒▒▒▒▒▒░░░░░░ ░ ░ ░░ ░░   ░░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▒▒▒░ ░░▒▒▒▒▓▓▓▒▒░░░ ░░░░▒▒▒▒▒▒░░░░░░░░░░░ ░ ░   ░░░░ ░░░░░▒▒▒▓▓▓▓▓▓▓▒▒░ ░░▒▒▒▒▒▒░░░ ░ ░░░░░░▒▒▒▒░░░░░░░░░░░░ ░   ░░░░ ░░▒▒▒▒▒▒▒▓▓▒▒▒▒▒░░ ░░▒▒▒░░░░░ ░░░░░░▒░▒░░░░░░░▒░░░░░░░ ░   ░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░▒░░░░ ░░░░▒░░▒▒░░░░▒░░▒▒▒▒░░░   ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░ ░ ░░░▒░░░░░░░░░░░░▒▒▒▒▒░▒░░▒▒▒▒▒░░░░░   ░ ░ ░░░░░▒▒░░░░░░▒▒▒▒▒▒▒▒▒▒░▒░░▒▒▒░▒░░░░░░▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒░░░░ ░   ░ ░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒░░▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒░░░░░ ░   ░░░░░░░ ░░░░▒▒▒▓▓▓██▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░▒▒░░░░░ ░░ ░   ░░░░░░░░ ░░░░▒▒▒▓▓████▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒░░░▒░░░ ░░░ ░   ░░░░░░░░ ░░░░░▒▒▓▓▓█▓█▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░▒░░░░░░░░░░░ ░ ░ ░░   ░░░░░░░░ ░░░░▒▒▒▒▒▓▒▒▒▒▒░░▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░░░░░░   ░ ░░░░░░ ░ ░░░ ░░░░░▒░▒░░░░░░░░░░░░░░░ ░ ░ ░ ░░░░░░░   ░░░ ░ ░ ░ ░ ░░░ ░ ░░░░░░░   ░░░ ░░ ░ ░░░░░░░   ░░░░░ ░░ ░░░░░ ░ ░░░░░░░   ░░░░░░░░░░░░░░░░░░ ░ ░░░░░░░░░░   ░░░░░░░░░░░░░ ░ ░░ ░ ░░░ ░ ░ ░ ░░░░░░░░░   ░░░░░░░░░░░░ ░ ░░ ░░░░░░░░░░░░░░░░░ ░░░░░░░░   ░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░   ░ ░ ░ ░░░░ press ⏎ to skip ░░░░░░░ ░░░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████   ✦ ████████████████████████✦ ✧  ░░ ░░░░ ░░ ░░░▒▒▒▒▒▒▒░░░ ░░░▒▓▓██████▓▓▒▓▓▓▓▓▓▓▓▒▒░░░ ░░▒▒▒▓▓▓▓▓▒▒▒░ ░ ░░░░░░░░   ░░ ░░░▒▒▒▒▒▒▒▒▒░░ ░ ░░▒▓▓██████▓▒▓▓███▓▓▓▒▒░░ ░ ░▒▒▒▒▓▓▓▓▒▒░░ ░░░░░░░ ░   ░░░░░ ░░▒▒▒▒▓▓▓▒▒░░░░ ░░░░▒▒▓▓▓▓▓▓▓▓▓██▓▓▓▒▒░░ ░ ░░░▒▒▒▒▒▒▒▒░░░░ ░░░ ░   ░░░░░░░░ ░░░▒▒▒▒▓▓▓▓▓▒░░░ ░░▒▒▒▒▓▓▓▓▓▓▓▓▓▒▒░░░ ░ ░░░▒▒▒▒▒▒░▒░░░░ ░ ░ ░   ░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▒▒░░ ░░▒▒▒▒▒▓▓▓▒▒▒░░ ░░░░░░▒▒▒▒░░░░░░░░░░░ ░ ░   ░░░░░ ░░░░░▒▒▒▓▓▓▓▓▓▒▒░░ ░░░▒▒▒▒▒▒▒░░ ░ ░░░░░▒▒▒▒░░░░░░░░░░░░░   ░░░░ ░░▒▒▒▒▒▒▓▓▓▒▒▒▒▒░░ ░░░▒▒▒▒░░░░ ░░░░░▒░▒░░░░░░░▒░░░░░░░ ░   ░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░▒░░░░ ░░░░░░░░▒░░░░▒░░▒▒▒▒░░░░   ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░▒░░░░░░ ░░░░░░▒▒▒░▒░░▒▒▒▒▒░░░░░   ░ ░░░░░▒▒▒░░▒░░░▒▒▒▒▒▒▒░░░░░░░▒▒▒░▒░░░░░░░▒░▒▒▒░▒▒░▒▒▒▒▒▒▒▒▒░░░   ░ ░░░░░░░░░░░░▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░   ░░░░░░░ ░ ░░░░▒▒▒▓▓▓██▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒░▒▒▒░░░░░ ░░ ░   ░░░░░░░░ ░░░░░▒▒▓▓▓████▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒░░▒░░░░ ░ ░ ░   ░░░░░░░░ ░░░▒▒▒▒▓▓██▓█▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒░░░░░░░░░ ░ ░ ░░   ░░░░░░░░ ░░░░▒▒▒▒▓▓▓▓▒▒▒░░▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░░░   ░ ░░░░░░ ░ ░░░░░░░▒▒▒▒▒▒░░░░░░░░▒░░░░░ ░░ ░ ░ ░░░░░░   ░░░ ░ ░ ░░ ░░░░░░░ ░ ░░░░░░░░   ░░ ░░ ░ ░░░░░░░░   ░░░ ░░░░ ░░░░░░░░   ░░░░░░░░░░░░░░░░░░ ░ ░░░░░░░░   ░░░░░░░░░░░░░ ░ ░ ░ ░ ░░░░░░░░░   ░░░░░░░░░░░░░░ ░ ░░ ░░░░░░░░░░░░░░░░ ░░░░░░░░   ░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░   ░░ ░░░░ ░░░░ press ⏎ to skip ░░░░░░░ ░░░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████   ✦ ████████████████████████✦ ✧  ████ ████ ✦   ████ ████   ░░ ░░░░ ░ ░░▒▒▒▒▒▒▒▒░░ ░░░▒▓███████▓▓▒▒▓▓▓▓▓▓▓▒▒░░░ ░░░▒▒▓▓▓▓▓▓▒▒░░ ░ ░ ░░░░░   ░░ ░░▒▒▒▒▒▒▒▒░░░ ░░░▒▓▓██████▓▓▒▓▓▓██▓▓▓▒▒░░ ░ ░░▒▒▒▓▓▓▓▒▒▒░░ ░░░░░ ░   ░░░░░ ░ ░░▒▒▒▒▒▓▒▒▒░░░░ ░░░▒▒▓▓▓█▓▓▓▓▓▓▓█▓▓▓▒▒▒░░░ ░░░░▒▒▒▒▒▒▒░░░░ ░░ ░   ░░░░░░ ░░░▒▒▒▒▓▓▓▓▒▒░░ ░▒▒▓▒▒▓▓▓▓▓▓█▓▓▓▒░░░ ░ ░░░▒▒▒▒▒▒▒▒░░░░ ░ ░ ░   ░░░░░ ░░░▒▒▒▓▓▓▓▓▒▒░░░ ░░▒▒▒▒▓▒▓▓▓▓▒▒▒░░ ░ ░░░░░▒▒▒░▒░░░░░░░░░ ░   ░░░░░ ░░░░░▒▒▒▓▓▓▓▓▓▒▒░░ ░░▒▒▒▒▓▓▒▒▒░░░ ░░░░░▒▒▒▒░░░░░░░░░░░░░░   ░░░░ ░░░▒▒▒▒▒▒▓▓▓▒▒▒▒▒░ ░░▒▒▒▒▒░░░░ ░ ░░░░░▒░░░░░░░░░░░░░░░   ░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░▒░▒░░ ░░░░░░░░░░░░▒░░▒▒▒▒░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░▒░░░░░ ░░░░░░▒▒▒░▒░░▒▒▒▒▒▒░░░░░   ░░░░░▒▒▒░▒▒░░░▒▒░▒▒▒░░░░░░░░░▒▒░░░░░░░░░░░▒▒▒░▒▒░▒░▒▒▒▒▒▒▒░░░   ░░░░░░░░░░░░▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░   ░░░░░ ░░ ░░░░▒▒▒▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒░░░░ ░ ░   ░░░░░░░ ░░░░░▒▒▓▓████▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒░░▒▒░░░ ░   ░░░░░░░░ ░░░▒▒▒▒▓▓██▓█▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒░░░░░░░░░ ░ ░ ░░   ░░░░░░░ ░░░▒▒▒▒▓▓▓▓▓▒▒▒▒░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░   ░ ░░░░░░ ░ ░░░░░░▒▒▒▒▒▒▒▒░░░░░▒▒▒░░░░░░░░░░ ░ ░ ░░░░░░   ░░░░░░ ░ ░░ ░░░░ ░░░░░░░ ░░ ░ ░░░░░░░░   ░ ░░ ░ ░░ ░░░░░░░░   ░░░ ░░░░ ░░░░░░░   ░░░░░░░░░░ ░ ░░░ ░ ░░░░░░░░░   ░░░░░░░░░░░░░░ ░ ░ ░ ░░░░░░░░░   ░░░░░░░░░░░░░░ ░ ░░ ░░░░░░░░░░░░░░░░ ░░░░░░░░   ░░░░░░░░░░ ░░░░░ ░ ░ ░░░░░░░░░░░░░░░░░░ ░░░░░░░░   ░░░░░░░ ░ ░░░░ press ⏎ to skip ░░░░░░░░ ░░ ░░░░░  [?7h[?25l[?7l ✧ ████████████████████████   ✦ ████████████████████████✦ ✧  ████ ████ ✦   ████ ████   · ████ ████ · ·   ✦ ████· ████   ▒▒▒▒ ████ ✧ ✧ ✦   · ▒▒▒▒ ████ · ✧   ░░░░ ░ ░ ░░▒▒▒▒▒▒▒░░░ ░░▒▓▓███████▓▒▒▒▓▓▓▓▓▓▓▓▒▒░░ ░░░▒▒▓▓▓▓▓▓▒▒░░ ░ ░░░░░   ░░ ░░░▒▒▒▒▒▒▒░░░ ░░░▒▓███████▓▓▒▓▓▓▓██▓▓▓▒▒░ ░ ░░▒▒▒▓▓▓▒▒▒░░░ ░░░░░ ░   ░░░░░ ░░░░▒▒▒▒▒▒▒▒░░░░░░░░▒▒▓▓▓▓█▓▓▓▓▓▓▓███▓▓▒▒░░░ ░░░░▒▒▒▒▒▒▒▒░░░ ░ ░   ░░░░░ ░░░▒▒▒▒▓▒▓▒▒▒░░ ░░▒▒▒▓▓▓▓▓▓▓▓▓██▓▓▒▒░░░░ ░░░▒▒▒▒▒▒▒░░░░ ░ ░ ░   ░░░░ ░░░▒▒▒▓▓▓▓▓▒▒░░░ ░░░▒▒▒▒▓▓▓▓▓▓▒▒▒░░ ░ ░░░░░░▒▒░▒░░░░░░░░░ ░ ░   ░░░░░ ░░░░░▒▒▒▓▓▓▓▓▒▒▒░░ ░░▒▒▒▒▓▓▒▒▒▒░░ ░░░░▒░▒▒░░░░░░░░░░░░░░   ░░░ ░░░▒▒▒▒▒▒▓▓▓▒▒▒▒░░ ░░▒▒▒▒▒▒░░░ ░░░░░░░░░░░░░░░░░░░░░░   ░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░▒░▒░░░ ░░░░░░░░░░▒░░▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░▒░░░░░ ░░░░░░░▒▒░▒░░▒▒▒▒▒▒▒░░░░   ░░░░▒▒▒▒▒▒▒░░░▒▒░░▒░░░░░ ░░░░▒▒░░░░ ░░░░░░░▒░▒▒░▒░▒▒▒▒▒▒▒░░░   ░ ░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░   ░░ ░ ░ ░░░ ░░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒░░░░ ░ ░   ░░░░░ ░ ░░░░░▒▒▓▓████▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒░░░ ░   ░░░░░░░░ ░░░▒▒▒▓▓███▓█▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒░▒░░░░░░░ ░ ░░   ░░░░░░░ ░░░░▒▒▒▓▓▓▓▓▓▒▒▒▒░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░░   ░░░░░░░░ ░ ░░░░▒▒▒▒▒▒▒▒▒▒░░░░░▒▒▒░▒░░░░░░░░ ░░░ ░░░░░░   ░░░░░░ ░ ░░░░░░░░ ░░░░░░░░░░░ ░ ░░░░░░░░   ░ ░░ ░░░░ ░ ░░░░░░   ░ ░░ ░ ░░ ░░░░░░ ░   ░░░░░░ ░░░ ░ ░░░ ░ ░░░░░░░░   ░░░░░░░░░░ ░░░ ░ ░ ░░░░░░░   ░░░░░░░░░░░░░░ ░ ░░░░ ░ ░░ ░░ ░ ░░░░░░░   ░░░░░░░░░░ ░░░░ ░░░░░░░░░░░░░░░░░ ░░░░░░░░░   ░░░░░░░ ░ ░░░░ press ⏎ to skip ░░░░░░░░ ░░ ░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████   ✦ ████████████████████████✦ ✧  ████ ████ ✦   ████ ████   ✦ ████· ████   ▒▒▒▒ ████ ✧ ✧ ✦   · ▒▒▒▒ ████ · ✧   ✧ ✧ ✧ ████   ████   ░░░ ░ ░░░░░▒▒▒▒▒▒▒░░ ░░▒▓████████▓▒▒▒▓▒▓▓▓▓▓▓▒▒░░░ ░░░░▒▒▓▓▓▓▓▓▒░░ ░ ░░░░   ░░ ░ ░░▒▒▒▒▒▒▒░░░ ░░▒▒▓███████▓▒▒▒▓▓▓██▓▓▓▒▒░░░ ░░░▒▒▒▓▓▒▒▒▒░░ ░░ ░   ░░░ ░░░░▒▒▒▒▒▒▒░░░░░░░░▒▒▓▓████▓▓▓▓▓▓▓███▓▓▒▒▒░░ ░ ░░▒▒▒▒▒▒▒▒▒░░ ░   ░ ░░ ░░░▒▒▒▒▓▒▒▒▒░░ ░░▒▓▓▓▓▓▓▓▓▓▓▓██▓▓▒▒▒░░░ ░░░░▒▒▒▒▒▒░░░░ ░ ░ ░   ░░░░ ░░░▒▒▒▓▓▓▓▒▒▒░░░ ░░░▒▒▒▒▒▓▓▓▓▓▓▓▒▒▒░░ ░ ░░░░░░▒▒░▒░░░░░░░░░ ░ ░   ░░░░ ░░░░░▒▒▒▓▓▓▓▓▒▒▒░ ░░▒▒▒▒▓▓▓▒▒▒▒░░ ░░░░░░▒▒░░░░░░░░░░░░░░   ░░░ ░░░▒▒▒▒▒▒▓▓▓▒▒▒▒░░ ░▒▒▒▒▒▒▒▒░░ ░░░░░░░░░░░░░░░░░░░░   ░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▒░░░ ░░░░░░░░░░▒░░░▒▒▒▒░░░░   ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░▒▒░░░░ ░░░░░░▒░░▒░░▒▒▒▒▒▒▒░░░░   ░░░▒░▒▒▒▒▒▒▒░░░▒▒░░░░░ ░░▒▒░░░░ ░░░░░░▒░░▒░▒░▒▒▒▒▒▒▒▒░░   ░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒░▒▒░░░▒▒▒░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒░▒▒▒░▒░░   ░░ ░ ░░░░░ ░░░░░▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒░░░ ░   ░░ ░ ░░░░░▒▒▓▓▓██▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒░░░ ░   ░░░░░░░░ ░░▒▒▒▒▓▓███▓█▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒░▒▒░░░░ ░ ░ ░░   ░░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░ ░ ░░░░   ░░░░░░░░░ ░ ░░░░▒▒▒▒▒▒▒▒▒▒░░░░▒▒▒▒▒▒░▒░░░░░░░░░░░ ░ ░░░░   ░░░░░░ ░ ░░░░░░░░░░░░░░░░░░░░░ ░ ░ ░░░░░   ░░░ ░░ ░░░░ ░ ░░░░░░   ░ ░ ░ ░░░░░░ ░   ░░░ ░ ░ ░ ░ ░ ░ ░░░░░░░░   ░░░░░░░░░░ ░░░ ░░░░░░░   ░░░░░░░░░░░░░░ ░ ░░░░ ░ ░ ░░░░░░░   ░░░░░░░░░░ ░░ ░░░░░░ ░ ░░ ░░░ ░ ░░░░░░░   ░░░░░░░ ░ ░░░░ press ⏎ to skip ░░░░░░░░ ░ ░░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████   ✦ ████████████████████████✦ ✧  ████ ████ ✦   ████ ████   · ████ ████ · ·   ✦ ████· ████   ▒▒▒▒ ████ ✧ ✧ ✦   · ▒▒▒▒ ████ · ✧   ████   ░░ ░ ░░░░░▒▒▒▒▒▒▒▒░░ ░░▒▓████████▓▒▒▒▒▒▓▓▓▓▓▓▒▒░░░ ░░░░▒▓▓▓▓▓▓▒▒░░ ░░░░   ░░░░▒▒▒▒▒▒▒░░░ ░░▒▓████████▓▒▒▒▒▓▓▓▓▓▓▓▓▒░░░ ░░░▒▒▒▒▓▒▒▒▒░░ ░░ ░   ░░░░░▒▒▒▒▒▒▒░░ ░░░▒▒▒▓█████▓▓▓▓▓▓▓▓███▓▓▒▒░░ ░ ░░░▒▒▒▒▒▒▒▒░░░   ░ ░░░░▒▒▒▒▒▒▒▒▒░░ ░░░░▒▒▓▓▓▓▓▓▓▓▓▓▓███▓▓▒▒▒░░ ░░░░▒▒▒▒▒▒░░░░ ░ ░   ░░░░ ░░░▒▒▒▒▓▓▒▒▒░░░ ░░▒▒▒▒▒▒▒▓▓▓▓█▓▓▒▒▒░░░░ ░░░░░░▒▒░▒░░░░░░░░░ ░   ░░░░ ░░░░░▒▒▒▓▒▓▓▒▒▒▒░ ░▒▒▒▒▒▓▓▓▓▒▒▒░░░ ░░░░░░▒░░░░░░░░░░░░░░░   ░░░░ ░░░▒▒▒▒▒▒▓▓▓▒▒▒▒░ ░▒▒▒▒▒▒▒▒▒░░ ░░░░░░░░░░░░░░░░░░░   ░░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▒▒░░░ ░ ░░░░░░░░░░░▒░▒▒▒░░░   ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░▒▒▒░░░ ░░░░░░▒░░▒▒▒▒▒▒▒░░░░░   ░░░▒▒▒▒▒▒▒▒▒░▒░▒░░░░░ ░░▒▒░░░░ ░░░░░▒░░▒░▒░▒▒▒▒▒▒▒▒▒░░   ░░░░░░▒▒░░░░░▒░▒▒▒▒▒▒░░░░░░░░▒▒▒░░░░░░░░░▒░▒▒▒▒▒▒▒▒▒░▒▒▒▒▒░░   ░░░░░░░░░░░░░▒▒▒▒▓▒▓▒▒▒▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒░░░░ ░   ░ ░ ░░░░░▒▒▓▓▓█▓▓▓▓▓▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒░░░ ░   ░░░░░░░ ░░▒▒▒▓▓▓███▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒░▒▒░░░░░░ ░░   ░░░░░░░░ ░░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░ ░░░░   ░░░░░░░░ ░ ░░░░▒▒▒▓▓▓▒▒▒▒░░░░▒▒▒▒▒▒░▒░░░░░░░░░░░ ░░░░   ░░░░░░ ░ ░░░░░▒▒░▒░░░░░░░░░░░░░░░░░ ░ ░░░░░░   ░░░░ ░ ░░ ░░░░░░░ ░░░░░░░░   ░ ░ ░ ░░░░░░░   ░░ ░ ░ ░ ░░░░░░░░   ░ ░░░░░░░░░ ░░░ ░░░░░░░░   ░░░░░░░░░░ ░░░ ░ ░ ░░ ░ ░ ░░░░░░░░░░   ░░░░░░░░░░ ░░ ░░░░░░ ░ ░ ░ ░ ░░░░░░░░   ░░░░░░░░ ░ ░░░ press ⏎ to skip ░░░░░░░░ ░░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████   ✦ ████████████████████████✦ ✧  ✦ ████· ████   ▒▒▒▒ ████ ✧ ✧ ✦   · ▒▒▒▒ ████ · ✧   ░ ░░░░░▒▒▒▒▒▒▒░ ░▒▒█████████▓▒▒▒▒▒▒▓▓▓▓▓▒▒▒░░ ░░ ░░▒▒▓▓▓▓▓▓▒▒░░ ░░░░   ░ ░░░░▒▒▒▒▒▒░░░ ░░▒▓████████▓▒▒▒▒▓▓▓▓██▓▓▒▒░░ ░░░░▒▒▒▓▒▒▒▒░░ ░   ░ ░░ ░░░░░▒▒▒▒▒░░░ ░░░▒▒▓▓█████▓▓▒▒▒▓▓▓▓██▓▓▓▒░░░░ ░░░░▒▒▒▒▒▒▒░░░   ░ ░░░░▒▒▒▒▒▒▒▒░░ ░░░▒▒▓▓▓█▓▓▓▓▓▓▓▓████▓▒▒▒░░ ░░░▒▒▒▒▒░░░░░ ░ ░   ░░ ░ ░░░▒▒▒▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▒▒▓▓▓▓█▓▓▓▓▒░░░░ ░░░░░░░▒░▒░░░░░░░░░ ░   ░░░░ ░░░░░▒▒▒▓▒▓▒▒▒▒░░ ░░▒▒▒▒▒▓▓▓▓▓▒▒▒▒░ ░░░░░░▒░░░░░░░░░░░░░░░   ░░░░ ░░░▒▒▒▒▒▒▓▓▓▒▒▒░░ ░░▒▒▒▒▓▒▒▒▒░░░ ░░░░░░░░░░░░░░░░░░░░   ░░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░ ░░░▒▒▒▒▒░░ ░░░░░░░░░░░░░▒▒▒░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░▒▒▒░░░ ░░░░░░░░░▒░▒▒▒▒▒░▒░░░   ░░▒▒▒▒▒▒▒▒▒▒▒▒░▒░░░░ ░░░▒░▒░░ ░ ░░░░░▒░▒░▒▒▒▒▒▒▒▒▒░░   ░░░░░░▒▒░░░░░▒░▒▒▒░▒░░░░░░░░░▒▒▒░░░ ░░░░░░░▒░▒▒░▒▒▒▒░▒▒▒▒▒▒░░░   ░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒░░░ ░   ░░ ░░░░░▒▒▓▓▓▓▓▓▓▒▓▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒░░ ░   ░░░░░░ ░░░▒▒▒▓▓▓███▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒░░░░░░ ░░   ░░░░░░░░ ░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░ ░░░   ░░░░░░░ ░░░░▒▒▒▒▓▓▓▓▓▒▒▒░░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░░   ░░░░░░░░ ░ ░░░░▒▒▒▒▒▒░░░░░░░░░░▒░░░░░ ░ ░ ░░░░░   ░░░░░ ░ ░░░ ░░░░░░░░ ░░░░░░░░   ░░ ░ ░ ░░░░░░░   ░░ ░ ░ ░ ░░░░░░░   ░ ░░░░ ░ ░░ ░░░ ░░░░░░░░   ░░░░░░░░░░ ░░░ ░ ░░ ░░░░░░░░░   ░░░░░░░░ ░ ░ ░░░░░░ ░ ░░░░░░░░   ░░░░░░░░░ ░ ░░░ ░░ press ⏎ to skip ░░░░░░░░ ░░░░░░░  [?7h[?25l[?7l · ✦   ·   · · ✧     ✦ ✦   ✦ ✧ ·   ✧ ████████████████████████ ✦   ✧ ████████████████████████   ✦ ✦ ████ ████   ✦ ████ ✦████   ████ ████ ·   · ✦ ████ ████ ✦   ✧ ✦ ▒▒▒▒ ████   ✧ ▒▒▒▒ ████ ✧   ✦ ████· ✦   ░░ ░░░░▒▒▒▒▒▒▒▒░ ░▒▓████████▓▓▒▒▒▒▒▒▒▓▓▓▓▓▒▒▒░░░░ ░░▒▒▒▓▓▓▓▓▒▒░░ ░░░░   ░ ░░░░░░▒▒▒▒▒░░░ ░▒▒▓████████▓▒▒▒▒▒▓▓▓▓█▓▓▓▒░░ ░░░░▒▒▒▓▒▒▒▒░░░ ░   ░░ ░░ ░░░░░▒▒▒░░░░ ░░░▒▓▓██████▓▓▒▒▒▓▓▓▓██▓▓▓▒▒░░░ ░░▒▒▒▒▒▒▒░░░   ░░░░▒▒▒▒▒▒▒░░░ ░░░▒▒▒▓███▓▓▓▓▒▓▓▓████▓▓▒▒░░ ░░░▒▒▒▒▒░░░░░░ ░ ░   ░░ ░░░░▒▒▒▒▒▒▒▒░░░░ ░░░▒▒▒▒▓▓▒▒▓▒▓▓██▓▓▓▒▒░░░ ░░░░░▒░▒░░░░░░░░░ ░░   ░░ ░░░░░▒▒▒▓▒▒▒▒▒▒░░ ░░░▒▒▒▒▒▓▓▓▓▓▓▒▒▒░░ ░░░░░░░░░░░░░░░░░░░   ░░░ ░░░▒▒▒▒▒▒▒▓▒▒▒▒░░ ░▒▒▒▒▒▓▒▒▒▒▒░░ ░░░░░░░░░░░░░░░░░░░░░   ░░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░▒▒▒▒▒▒░░░ ░░░░░░░░░░░░▒▒▒░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░▒▒▒░░░░ ░░░░░░░░░░░▒▒▒▒░▒▒░░   ░░░▒▒▒▒▒▒▒▒▒▒▒▒░▒░░░░ ░░░▒░▒░░ ░░░░░▒░░░▒▒▒▒▒▒▒▒▒▒░   ░░░░▒▒▒▒░░░▒▒▒░▒▒▒░░░░░ ░░░░░▒▒▒░░░ ░ ░░░░░▒▒░▒▒▒▒░▒▒▒▒▒▒░░░   ░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒░░   ░ ░░ ░░ ░░░░░▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒░░░░ ░   ░░ ░░ ░░░▒▒▒▓▓▓██▓▓▓▓▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒░▒▒░░▒▒░░░ ░   ░░░░░ ░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░ ░ ░   ░░░░░░░ ░░░░▒▒▒▒▓▓▓▓▓▒▒▒░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░ ░░░░   ░░░░░░░░ ░ ░░░░▒▒▒▒▒▒▒▒░░░░░░░░▒▒░░░░░░░░ ░ ░░░░░░   ░░░░░ ░░░░░░ ░ ░░░░░░░░░ ░░░░░░░   ░░ ░ ░ ░░░ ░░░░░░   ░░ ░ ░ ░ ░ ░░░░░░░░   ░░░░ ░ ░░░ ░░░░░░░   ░░░░░░░░░░ ░░ ░ ░░░░░░░░░░   ░░░░░░░░ ░ ░░░░░ ░░░░░░░   ░░░░░░░░░ ░ ░░░ ░░ press ⏎ to skip ░░░ ░░░░ ░░░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████ ✦   ✧ ████████████████████████   ✧ ▒▒▒▒ ████ ✧   ░░ ░░▒▒▒▒▒▒▒▒▒░░ ▒▒▓████████▓▓▒▒▒▒▒▒▒▓▓▓▓▓▒▒▒░░░░ ░▒▒▒▓▓▓▓▓▒▒░░ ░░░░   ░░░░░░░░▒▒▒▒▒▒░░ ░▒▓████████▓▓▒▒▒▒▒▓▓▓▓█▓▓▓▒▒░░ ░ ░░▒▒▒▓▒▒▒▒▒░░░ ░   ░░ ░ ░░░░░░░░▒▒▒░░░ ░░▒▓▓███████▓▓▒▒▒▒▓▓▓███▓▓▒▒▒░░ ░░▒▒▒▒▒▒▒░░░░   ░ ░░░░▒▒▒▒▒░░░░ ░░▒▒▓▓▓███▓▓▓▒▒▓▓▓████▓▓▓▒▒░░ ░░░░▒▒▒▒░░░░░░░░░░   ░ ░░░░▒▒▒▒▒▒▒░░░ ░░ ░░▒▒▒▓▒▓▓▓▒▒▒▒▓▓███▓▓▓▒░░░ ░░░░░▒░░░░░░░░░ ░░   ░ ░░░░░▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒░░░ ░░░░░░░░░░░░░░░░░░░   ░░░ ░░░▒▒▒▒▒▒▒▓▒▒▒░░░ ░░▒▒▒▒▒▓▓▓▒▒▒░░░ ░ ░░░░░░░░░░░░░░░░░░░   ░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒░░ ░▒▒▒▒▒▒▒▒░░░ ░░░░░░░░░░░░▒▒▒░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▒▒░░░ ░░░░░░░░░░▒▒▒▒▒▒▒░░   ░░░▒▒▒▒▒▒▒▒▒▒▒▒░▒░░ ░░░▒░▒░░ ░░░░░░░░░▒▒▒▒▒▒▒▒▒░░   ░░░░▒▒▒▒▒▒░▒▒▒▒░▒░░░░░ ░░░░▒▒▒░░░ ░░░░░▒░▒▒▒▒░▒▒▒▒▒▒░░░   ░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░▒░░▒▒▒▒░░░░ ░░░░▒░░▒▒▒▒░▒▒▒▒▒▒▒▒░░   ░░░░░░░ ░░░░░▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒░░░ ░   ░ ░░░▒▒▒▓▓▓▓█▓▓▓▓▒▒▒▒▒▒░▒░▒▒▒▒▒▒▒▒▒▒▒░▒▒░░▒▒░░░ ░   ░░░░░ ░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░ ░   ░░░░░░░ ░░░░▒▒▒▓▓▓▓▓▓▓▒▒▒░▒░▒▒▒▒▒▒▒▒▒░▒░░░░░░░░ ░░   ░░░░░░░░ ░ ░░░░▒▒▒▒▒▒▒▒░░░░░░░▒▒▒░▒░░░░░░░░ ░░░░░░   ░░░░░░ ░░░░░░░░░░ ░░░░░░░░░ ░ ░ ░░░░░   ░░ ░░░░░░ ░░░░░░   ░ ░ ░ ░ ░░░░░░░   ░ ░░░ ░░░░░░░   ░░░░░░░░░░ ░░ ░ ░░░░░░░░   ░░░░░░░░ ░ ░░░░ ░░░░░░░   ░░░░░░░░░ ░ ░░░ ░ press ⏎ to skip ░░░ ░ ░░ ░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████ ✦   ✧ ████████████████████████   ✦ ✦ ████ ████   ✦ ████ ✦████   ████ ████ ·   ✧ ✦ ▒▒▒▒ ████   ✧ ▒▒▒▒ ████ ✧   ░░░░░▒▒▒▓▓▓▒▒▒░░ ░▒▒▓████████▓▓▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒░░░ ░░▒▒▓▓▓▓▓▒▒▒░░ ░░░░   ░░░░░░▒▒▒▒▒▒▒▒░░ ░▒▒▓███████▓▓▓▒▒▒▒▒▒▓▓▓██▓▓▒▒▒░ ░ ░░░▒▒▒▒▒▒▒▒▒░░   ░░ ░░ ░░░░▒░░░▒▒░░░ ░░▒▓▓███████▓▓▒▒▒▒▒▓▓▓██▓▓▓▒▒░░ ░░░░▒▒▒▒▒▒▒░░   ░ ░ ░░░░░░▒▒▒░░░░░░ ░░▒▒▓▓████▓▓▓▒▒▒▒▓▓███▓▓▓▒▒░░ ░░░▒▒▒▒░░░░░░░░░░   ░ ░░░░░▒▒▒▒▒▒░░░░ ░░░░▒▒▒▓▓▓▓▓▓▒▒▒▒▓▓███▓▓▓▒▒░░ ░░░░░▒░░░░░░░░░ ░░   ░ ░░░░░░▒▒▒▒▒▒▒▒▒░░ ░░░░▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒░░░ ░░░░░░░░░░░░░░░░░░░ ░   ░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░▒▒▒▒▒▓▓▓▓▓▒▒░░░ ░░░░░░░░░░░░░░░░░░░   ░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒░░░ ░░▒▒▒▒▒▒▒▒░░░░ ░░░░░░░░░░░▒▒▒░░░░   ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▒▒▒░░ ░░░░░░░░░░▒▒░▒▒▒▒░░░   ░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒░▒░ ░░░░░▒░░ ░░░░░░░░░▒░▒▒▒▒▒▒░░░   ░░░░▒▒▒▒▒▒░▒▒▒▒░▒░░░░ ░░░▒▒▒░░░ ░░░░░▒░░░▒▒░░▒▒▒▒▒░░░░   ░░░░░▒░░░░░░░░▒▒▒░▒▒▒░░░░░░░░▒▒▒▒░░░ ░░░░▒░░▒▒▒▒░▒▒▒▒▒▒▒▒░░░   ░░░░░░░░░░░░░░▒▒▒▓▓▓▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒░▒▒▒▒▒░░░   ░░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒░░░▒▒▒▒▒▒▒▒▒▒▒░▒▒░░▒▒░░░░   ░░ ░░▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░ ░   ░░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░ ░░   ░░░░░░░░ ░ ░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒░░░░░░░ ░░░░░░   ░░░░░░ ░░░░░░░░░░░ ░░░░░░░░░░░░ ░ ░░░░░   ░ ░░ ░░░░░░░ ░░░░░   ░ ░ ░░░░░░░   ░░░ ░░░░░░░░   ░ ░░░░░░ ░ ░░ ░░░░░░░░░   ░░░░░░░░ ░ ░░░░ ░░░░░░░░   ░░░░░░░░░ ░░░░░ press ⏎ to skip ░ ░ ░ ░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████ ✦   ✧ ████████████████████████   ✦ ✦ ████ ████   ✦ ████ ✦████   ████ ████ ·   · ✦ ████ ████ ✦   ✧ ✦ ▒▒▒▒ ████   ✧ ▒▒▒▒ ████ ✧   ░░░░▒▒▒▒▓▓▓▒▒░░ ░▒▒▓████████▓▓▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒░░░ ░░▒▒▓▓▓▓▒▒▒░░ ░ ░   ░░░░░▒▒▒▒▒▒▒▒▒░ ░▒▒▓███████▓▓▒▒▒▒▒▒▒▓▓▓██▓▓▒▒▒░░░ ░░░▒▒▒▒▒▒▒▒░░   ░░░░░░░░░░▒▒░░▒░░░ ░▒▒▓████████▓▓▒▒▒▒▒▓▓▓███▓▓▒▒░░ ░░░░▒▒▒▒▒▒▒░░   ░ ░ ░░░░░░░░▒▒▒░░░░░ ░▒▒▓▓█████▓▓▒▒▒▒▒▒▓████▓▓▓▒▒░░ ░░░░▒▒▒▒▒░░░░░░░░   ░░ ░░░░░▒▒▒▒▒▒░░░ ░░░░▒▒▓▓▓▓▓▓▓▒▒▒▒▓▓█████▓▒▒▒░ ░ ░░░░▒░░░░░░░░░ ░░░   ░░░░░░▒▒▒▒▒▒▒▒░░░ ░░░░▒▒▒▓▒▒▒▒▓▓▓▓▓▓▓▓▒▒░░░ ░░░░░░░░░░░░░░░░░░░░░   ░░ ░░░░▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▒▒▒▒▓▓▓▓▓▒▒░░░ ░░░░░░░░░░░░░░░░░░░   ░░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒░░ ░░▒▒▒▒▒▒▒▒▒░░░ ░░░░░░░░░░░▒▒▒░░░░░   ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▒▒▒░░ ░░░░░░░░░▒▒░▒▒▒▒▒░░   ░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░▒░░░ ░░░░░░░░░▒░▒▒▒▒▒▒░░░   ░░░▒▒▒▒▒▒▒░▒▒▒▒░░░░ ░░▒▒▒░░░ ░ ░░░▒░░░▒▒░░▒▒▒▒▒░░░░   ░░░▒░▒░░▒░░░░░▒▒▒░░▒░░░░░░░░░▒▒▒▒░░░ ░░░░░░▒▒▒░▒▒▒▒▒▒▒▒▒░░   ░ ░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒░▒▒▒▒▒▒▒░▒▒▒▒▒▒░░   ░ ░░ ░░░░▒▒▒▓▓▓▓▓▓▒▓▒▒▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒▒▒░▒▒░▒▒▒░░░░   ░░ ░░▒▒▒▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░   ░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░ ░   ░░░░░░░ ░ ░░░▒▒▒▒▓▓▒▒▒▒▒░░░░░░▒▒▒▒▒▒░░░░░░░░ ░░░░░   ░░░░░░ ░░░░░▒▒░▒░░░░ ░░░░░▒░░░░░░ ░ ░░░░░   ░ ░░ ░░ ░ ░░░░░░░ ░ ░░░░░   ░ ░ ░ ░░░░░░   ░░░ ░░░░░░░   ░ ░ ░ ░ ░░ ░░░░░░░   ░░░░░░░░ ░ ░░ ░░░░░░░░   ░░░░░░░░░ ░░░░░ press ⏎ to skip ░ ░░░░░░░ ░  [?7h[?25l[?7l ✧ ████████████████████████ ✦   ✧ ████████████████████████   ✦ ✦ ████ ████   ✦ ████ ✦████   ████ ████ ·   · ✦ ████ ████ ✦   ✧ ✦ ▒▒▒▒ ████   ✧ ▒▒▒▒ ████ ✧   ✦ ████· ✦   ████   ░░░░▒▒▒▒▓▓▓▒▒░░ ░▒▓▓████████▓▓▒▒▒▒▒▒▒▒▒▓▓▓▓▒▒▒░░░ ░░▒▒▒▓▓▓▒▒▒▒░   ░░░░░▒▒▒▒▒▒▒▒░ ░▒▓████████▓▓▒▒▒▒░▒▒▒▓▓▓██▓▓▒▒░░░ ░░▒▒▒▒▒▒▒▒░░   ░░░░░░░░░▒▒▒░░▒░░ ░▒▒█████████▓▓▒▒▒▒▒▒▓▓▓███▓▒▒▒░░ ░░░░░▒▒▒▒▒▒░░░░   ░░░░░░░░░░░░░▒▒░░░ ░░▒▓▓██████▓▓▒▒▒▒▒▒▓▓███▓▓▓▒▒░░░ ░░░▒░▒░▒░░░░░░░░   ░ ░░ ░░░░░▒░░▒░░░░ ░░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▓▓█████▓▓▒▒░░░ ░░░░░░░░░░░░░ ░░░ ░   ░░░░░░░▒▒▒▒▒▒░░░░ ░░░░▒▒▒▒▓▒▒▒▒▓▓▓▓▓█▓▓▓▒▒░░ ░░░░░░░░░░░░░░░░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒░░░ ░░░▒▒▒▒▒▒▓▓▓▓▓▓▒▒░░ ░░░░░░░░░░░░░░░░░   ░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒░░ ░░▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░░░░░░▒░▒░░░░░░   ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▒▒▒▒░░ ░░░░░░░░░░▒░▒░▒▒▒░░   ░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░▒▒░░░ ░░░░░░░░░▒▒▒▒▒▒░░░   ░▒░▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░▒▒▒▒░░ ░░░░░░░░▒░░░▒▒▒▒░░░░   ░░░▒░▒░░▒░░░▒░▒▒▒░░░░░░░ ░░░▒▒▒▒░░░ ░░░░░░▒░░░▒▒▒▒▒▒▒▒▒░░   ░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒░░▒▒▒▒▒▒░░░ ░░░░░░░▒▒▒▒▒▒▒░▒▒▒▒▒▒░░░░   ░░░░ ░░░░▒▒▒▓▒▓▓▓▓▒▒▒▒▒▒▒▒▒░░░░░▒▒▒▒▒▒▒▒▒░▒▒░▒▒▒░▒░░░   ░░ ░░▒▒▒▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒░░░░▒░░░░   ░░░░ ░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░ ░   ░░░░░░░ ░ ░░▒▒▒▒▓▓▓[0;38;5;69m▒▒▒▒▒░░░░░░▒▒▒▒▒▒░▒░░░░░░░ ░░░░░   ░░░░░░ ░░░░░░▒▒▒▒▒░░░ ░░░░░▒░░░░░░░░░ ░ ░░░░░   ░░ ░░░ ░░░░░░ ░░░░░░░░░ ░░░░   ░ ░ ░░ ░░░░░   ░░ ░░░░░░░   ░ ░ ░ ░░░░░░   ░░░░ ░ ░ ░ ░░ ░░░░░░░░   ░ ░░░░░░░░░ ░░░░░ press ⏎ to skip ░░░░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████ ✦   ✧ ████████████████████████   ✦ ✦ ████ ████   ✦ ████ ✦████   ████ ████ ·   · ✦ ████ ████ ✦   ████   ░░░▒▒▒▒▓▓▓▓▒▒░ ░▒▓████████▓▓▒▒▒▒▒░▒▒▒▒▓▓▓▓▓▒▒▒░░ ░░░▒▒▒▓▓▓▒▒▒░░   ░░░░░▒▒▒▒▒▒▒▒░ ░▒▓████████▓▓▒▒▒▒░▒▒▒▒▓▓▓█▓▓▒▒░░░ ░░░▒▒▒▒▒▒▒░░░   ░░░░░░░░░▒▒▒░░▒░░ ░▒▓████████▓▒▒▒▒▒▒▒▒▒▓▓▓██▓▓▒▒▒░░ ░░░▒▒▒▒▒▒░░░░   ░░░░░░░░░░▒░░▒░░░░ ░▒▒▓▓██████▓▓▒▒▒▒▒▒▓▓████▓▓▒▒░░░ ░░░░▒░▒░░░░░░░░   ░ ░░░░░░░░░▒░░░░░░ ░░░▒▓▓▓██▓▓▓▓▒▒▒▒▓▓▓█████▓▒▒▒░░ ░░░░░░░░░░░░░ ░░░ ░   ░░░░░░░▒▒▒▒▒░░░░░ ░░░▒▒▒▒▒▓▓▒▒▒▒▒▓▓▓▓██▓▓▒▒░░░ ░░░░░░░░░░░░░░░░░░░   ░░░░░▒▒▒▒▒▒▒░░░░ ░░░▒▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒░ ░░░░░░░░░░░░░░░░   ░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▒▒▒▒▒▓▒▒▒▒░░ ░░░░░░░░░░▒░░░░░░   ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒░░ ░░░░▒▒▒▒▒▒▒░ ░░░░░░░░░░░░▒▒▒▒░░   ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░▒▒░▒░░ ░░░░░░░░░▒░▒▒▒▒░░░░   ░▒░▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░▒▒▒▒░░ ░░░░░░░░░░▒▒▒▒▒░░░   ░░░▒▒▒░▒▒▒░░▒▒▒░░░░░░ ░░░▒▒▒▒░░░ ░░░░▒░░░▒▒░▒▒▒▒▒▒▒░░░   ░░░░░░░░░░░░░░░░▒▒▒▒▒▒░░░░▒▒▒▒▒▒░░░ ░░░░░░▒▒▒▒▒▒░▒▒▒▒▒▒░░░░   ░░░░░░░ ░ ░░░▒▒▒▒▒▒▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒░▒▒▒░▒░░░   ░ ░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒░░░▒▒▒▒▒▒▒▒▒▒▒░▒▒░▒░░░░   ░░ ░ ░░▒▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒░░▒▒▒▒▒▒▒▒▒▒▒░▒░░░░░░░ ░   ░░░░░ ░ ░ ░░▒▒▒▓▓▓▓▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒░░░░░░░░ ░░░   ░░░░░░ ░░░░▒▒▒▒▒▒▒░░░░░░░░░░▒▒▒░░░░░░░░░ ░░░░░   ░░░░░░ ░░░░░░░░░░ ░░░░░░░░░ ░░░   ░ ░░ ░░░░░░   ░ ░ ░ ░░░░░   ░ ░ ░ ░░░░░░   ░░░░ ░ ░ ░ ░ ░░░░░░░   ░ ░░░░░░░░░ ░░░ press ⏎ to skip ░░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████ ✦   ✧ ████████████████████████   ✦ ✦ ████ ████   ✦ ████ ✦████   ████ ████ ·   ✦ ████· ✦   ████   ░░░▒▒▓▓▓▓▓▒▒░░ ░▒▓▓████████▓▓▒▒▒▒▒░░▒▒▒▓▓▓▓▓▓▒▒▒░░ ░░░░▒▒▒▓▒▓▒▒▒░░   ░░░▒▒▒▒▒▒▒▒▒░░ ░▒▓▓███████▓▓▓▒▒▒░░░░▒▒▒▓▓▓▓▓▓▓▒░░░ ░░▒▒▒▒▒▒▒▒▒░░   ░░░░░░▒▒▒▒▒▒░░░░ ░▒▒▓███████▓▓▒▒▒▒▒▒░▒▒▒▓▓▓█▓▓▓▒▒░░ ░░░░▒▒▒▒▒▒░░░   ░░░░░░░░░░░▒░░░░░ ░░▒▓████████▓▒▒▒▒▒▒▒▒▓▓█▓███▓▓▒░░ ░░░░▒░▒░░░░░░░░   ░░░░░░░░░░░░░░░░ ░▒▒▒▓█████▓▓▓▒▒▒▒▒▒▓▓████▓▓▒▒░░░ ░░░░░░░░░░░░ ░░░ ░   ░ ░░░░░░░░░░▒░░░░░░░░░░▒▒▒▒▓▓▓▓▓▒▒▒▒▒▓▓▓▓██▓█▓▒▒░░░ ░░░░░░░░░░░░░░░░░░   ░ ░░░░░░▒▒▒▒▒░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▓▓▓▓█▓▓▒▒▒░░ ░░░░░░░░░░░░░░░░░░   ░ ░ ░░░░░▒▒▒▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▓▓▓▓▒▒▒▒░░ ░░░░░░░░░░░░░░░░░   ░ ░░░░▒░▒▒▒▒▒▒▒▒░░ ░░░░░▒▒▒▒▒▒▒░░░ ░░░░░░░░░░░▒▒▒▒░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░▒▒▒▒░░░ ░ ░░░░░░░░▒▒▒▒▒░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░▒▒▒░░░░ ░░░░░░░░░░░▒▒▒▒▒░░   ░░░░▒▒▒▒▒▒▒░░▒▒▒░░░ ░░░▒▒▒▒░░ ░ ░░░░░░▒▒░▒▒▒▒▒▒▒░░░░   ░░░░░▒░░░░░░░░░░░░▒░░░░░░░░░░░▒▒▒▒░░░ ░░░░░▒▒▒▒░▒▒▒▒▒▒░▒░░░   ░░░░░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░░░░▒▒▒▒▒▒▒▒░▒▒▒░▒▒░░░   ░░░░ ░░░▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▒▒▒▒▒▒▒░▒░░░░░░░   ░░ ░ ░░▒▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒▒▒▒░▒░░░░░░░   ░░░░ ░░░▒▒▒▒▓▓▓▓▒▓▒▒▒░▒░░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░ ░ ░░   ░░░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒░░░░░░░░░ ░░░░   ░░░░░░ ░░░░░░▒▒░░░░ ░░░░░░░░░░░░░ ░░ ░░   ░ ░ ░░░░░░ ░░░░░   ░ ░ ░░░░░░   ░░░░░   ░ ░░ ░ ░░░░░░░   ░ ░░░░ ░ ░░ ░░░ press ⏎ to skip ░░░░░░░  [?7h[?25l[?7l ✦ ✦ ✧   · ✦ ✦ ✦ ✧ · ✦   ✧ ✦ ✦ ✧ ✧ · · ·   ·   ✦ ✦   ✧ ✦   ████████████████████████ ✧ · ✦   ████████████████████████ ✧ ·✧   · ████ ████   ✦ ████ ████ ✧   ████ ████ ✦   ████ ████ ✦   ▒▒▒▒ ████ ·   ▒▒▒▒ ████ ·   · ████ ·   ████   ░░░▒▒▓▓▓▓▓▒▒░ ░▒▓▓███████▓▓▓▒▒▒▒▒░░░▒▒▓▓▓▓▓▓▓▒▒░░ ░░░▒▒▒▓▒▓▒▒▒░░   ░░░▒▒▒▒▒▒▒▒▒░░ ░▒▓▓███████▓▓▓▒▒▒░░░░▒▒▒▓▓▓▓▓▓▓▒▒░░░ ░░░▒▒▒▒▒▒▒░░   ░░░░░░▒▒▒▒▒▒░░░ ░▒▓████████▓▓▒▒▒▒░░░░▒▒▓▓▓██▓▓▒▒░░ ░ ░░▒▒▒▒▒▒░░░   ░░░░░░░░░░░▒░░░░ ░░▒▓████████▓▒▒▒▒▒▒▒▒▒▓▓▓███▓▓▒▒░░ ░░░▒░░░░░░░░░░   ░ ░░░░░░░░░░░░░░░ ░▒▒▓██████▓▓▒▒▒▒▒▒▒▓▓▓████▓▓▒▒░░ ░░░░░░░░░░░░ ░░░ ░   ░░░░░░░░░░░░░░░░░░ ░░░░░▒▒▓▓▓▓▓▓▓▒▒▒▒▒▓▓▓▓████▓▓▒▒░░ ░░░░░░░░░░░░░░░░░░   ░ ░░░░░░░▒░▒▒░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▓▓▓███▓▓▒▒░░ ░░░░░░░░░░░░░░░░░   ░ ░░░░░▒▒▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▒▓▓▓▓▓▒▒▒▒░░ ░░ ░░░░░░░░░░░░░░░   ░ ░░░░░░▒▒▒▒▒▒▒▒░░ ░░░░░░▒▒▒▒▒▒▒▒░░░ ░░░░░░░░░░░▒▒▒▒░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒░░░ ░ ░░░░░░░░▒▒▒▒▒░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░▒▒▒░░░░ ░░░░░░░░░░░▒▒▒▒▒░░   ░░░▒▒▒▒▒▒▒▒░░▒▒▒░░ ░░░▒░▒▒░░ ░░░░░░▒░░▒▒▒▒▒▒▒░░░   ░░░░░▒░░░░░░░░░░░░░░░░░░░░ ░░░▒▒▒▒░░░ ░░░░▒▒▒▒░▒▒▒▒▒▒▒▒░░░░   ░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒░░ ░░░░░░▒░▒▒▒▒▒▒░▒▒▒░▒▒░░░   ░░░░ ░░░░▒▒▒▒▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▒▒▒▒▒▒▒░▒░▒░░░░░   ░ ░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒░▒░░░░░░░   ░░░░ ░░░▒▒▒▒▓▓▓▓▒▒▒▒▒░▒░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░░ ░ ░   ░░░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒░░░░░░ ░░░░   ░░░░░░ ░░░░░▒▒▒░░░░ ░░░░░░░░░░░░░ ░░ ░░   ░ ░░ ░ ░░░░░░░ ░ ░░░░░░   ░ ░░ ░ ░░░░░   ░░░░░░   ░ ░ ░░░░░   ░ ░░░░ ░ ░░ ░░░ press ⏎ to skip ░░░░░░░ ░  [?7h[?25l[?7l ████████████████████████ ✧ · ✦   ████████████████████████ ✧ ·✧   · ████ ████   ████ ████ ✦   ▒▒▒▒ ████ ·   ▒▒▒▒ ████ ·   · ████ ·   ████   ░░░▒▓▓▓▓▓▓▒▒░ ░▒▓▓██████▓▓▓▓▒▒▒▒▒░░░▒▒▒▓▓▓▓▓▓▒▒▒░░ ░░▒▒▓▒▓▒▒▒▒░   ░░░░▒▒▓▓▒▒▒▒░░ ░▒▓███████▓▓▒▒▒▒▒░░░░░▒▒▓▓▓▓▓▓▓▒▒▒░░ ░░░▒▒▒▒▒▒▒░░░   ░░░░░░▒▒▒▒▒▒░░░ ░▒▓████████▓▓▒▒▒▒░░░░░▒▒▓▓██▓▓▓▒▒░░░ ░░░▒▒▒▒░░░░   ░░░░░░░░▒░▒▒░░░░ ░▒▓▓████████▓▒▒▒▒▒░░▒▒▓▓▓███▓▓▒▒░░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░ ░░▒▓▓██████▓▓▒▒▒▒▒▒▒▓▓▓▓███▓▓▓▒▒░░ ░ ░░░░░░░░░░ ░░░ ░   ░░░░░░░░░░░░░░░░░ ░░░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▓▓▓▓████▓▓▒▒░░ ░░░░░░░░░░░░░░░░░   ░ ░ ░░░░░░░░░░░░░░ ░░░░░▒▒▒▒▓▒▒▒▒▒▒▒▓▓▓███▓▓▓▒░░ ░░ ░░░░░░░░░░░░░░░   ░ ░░░░░░▒▒▒▒▒▒░░░░ ░░░░▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒░░ ░ ░░░░░░░░░░░░░░░   ░░░░░░▒▒▒▒▒▒▒▒░░ ░░░▒░░▒▒▒▓▓▓▒▒░░░ ░░░░░░░░▒▒▒▒░░░   ░░░░▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░░▒▒▒▒▒░░░ ░░░░░░░░░▒▒▒▒░░░░░   ░░░▒░▒▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▒▒░░░ ░ ░░░░░░░░░▒▒▒▒▒▒░░░   ░░░░▒▒▒▒▒▒▒▒░▒▒▒░░░ ░░▒░▒▒░░ ░░░░░░▒░░▒▒▒▒▒▒▒░░░   ░░░░░▒░░░░░▒░▒░░░░░░░░ ░░▒▒▒▒░░░ ░░░▒▒▒░░▒▒▒▒▒▒▒▒░░░░   ░░░░░░░░░░░░░░░░▒▒▒▒▒░▒▒░░░▒░▒▒▒▒▒▒░░ ░░░░▒▒▒░▒▒░▒▒▒░▒▒░░░   ░░░░░ ░░░░▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒░▒▒░░░░   ░░▒▒▓▒▓▓▓▓▓▓▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒░▒░▒░░░░░   ░░░ ░░░▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░░▒▒▒▒▒▒▒▒▒░▒░░░░░░░ ░   ░░░░░░ ░░░▒▒▒▒▒▓▓▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒░░░░░░ ░ ░░░░   ░░░░ ░ ░░░▒▒▒▒▒▒░░░░░ ░░░░░▒▒░▒░░░░ ░░ ░░   ░ ░░░░░ ░ ░░░░░░░░░ ░░░░░   ░ ░░░ ░░░░░   ░░░░░░   ░░░░   ░ ░ ░░ ░ ░ press ⏎ to skip ░░░░░ ░  [?7h[?25l[?7l ████████████████████████ ✧ · ✦   ████████████████████████ ✧ ·✧   · ████ ████   ✦ ████ ████ ✧   ████ ████ ✦   ████ ████ ✦   ▒▒▒▒ ████ ·   ▒▒▒▒ ████ ·   ░░▒▒▓▓▓▓▓▓▒░░ ░░▒▓▓█████▓▓▓▓▓▒▒▒▒▒░░░▒▒▒▒▒▓▓▓▓▒▒▒░░ ░░░▒▒▒▓▒▒▒▒░   ░░░▒▒▒▓▓▒▒▒▒░░ ░▒▒▓███████▓▓▒▒▒▒▒▒░░░░░▒▒▓▓▓▓▓▓▒▒▒░░ ░░░▒▒▒▒▒▒░░░░   ░░░░░▒▒▒▒▒▒▒░░░ ░▒▓███████▓▓▓▒▒▒▒░░░░░▒▒▓▓▓▓▓▓▓▓▒▒░░ ░░░▒▒▒▒░░░░░   ░░░░░░▒▒▒░▒▒░░░ ░▒▓█████████▓▒▒▒▒▒░░▒▒▒▓▓▓██▓▓▓▒▒░░ ░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░ ░▒▒▓▓██████▓▓▒▒▒▒▒▒▒▒▓▓▓████▓▓▒▒░░ ░░░░░░░░░░ ░░░ ░░   ░░░░░░░░░░░░░░░░░ ░░░▒▒▓▓█▓█▓▓▓▓▒▒▒▒▒▒▓▓▓████▓▓▓▒▒░░ ░░░░░░░░░░░░░░░░   ░ ░░ ░░░░░░░░░░░░░░ ░░░▒▒▒▒▒▓▓▒▒▒▒▒▒▒▓▓▓███▓▓▓▒▒░░ ░░ ░░░░░░░░░░░░░░   ░ ░░░░░░░▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▒░░░ ░ ░░░░░░░░░░░░░░░░   ░ ░░░░░░▒▒▒▒▒▒▒▒░░ ░░░▒░░▒▒▒▓▓▓▓▒▒▒░ ░░░░░░░░░▒▒▒░░░░   ░░░░▒▒░▒▒▒▒▒▒▒░░░░ ░░░░░░▒▒▒▒▒▒░░░ ░░░░░░░░▒▒▒░▒░░░   ░░░▒░▒▒▒▒▒▒▒▒▒▒▒░░ ░░░░░▒▒▒▒░░░ ░░░░░░░░░▒▒▒▒▒▒░░░   ░░░░▒▒▒▒▒▒▒▒░▒▒▒░░░ ░░▒░▒▒░░ ░░░░░░░░░▒▒▒▒▒▒░░░   ░░░░▒▒▒░▒░░▒░▒░░░░░░ ░░▒▒▒▒░░░ ░░░▒░░░▒▒▒▒▒▒▒▒░░░░   ░░░░░▒░░░░░░░░░░▒▒▒░░░░░░░░░░▒▒▒▒▒▒░░ ░░░▒░▒░▒▒░▒▒▒░▒▒▒░░░   ░░░░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░▒▒░░░░   ░░░░▒▒▓▒▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒░▒▒░░░░░░   ░ ░ ░░░▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░░▒▒▒▒▒▒▒▒▒░▒░░░░░░░   ░░░░░░ ░░░▒▒▒▒▒▓▓▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒░░░░░░░░ ░░░   ░░░░ ░ ░░▒▒▒▒▒▒▒░░░░░░ ░░░░░▒▒░▒░░░░░ ░░   ░ ░ ░ ░░░░░░░░░ ░░░░░░░░░░ ░░░░   ░ ░ ░░░░ ░░░░░   ░░░ ░░░░░░   ░░░   ░ ░ ░░ ░ press ⏎ to skip ░░░░░ ░  [?7h[?25l[?7l ████████████████████████ ✧ · ✦   ████████████████████████ ✧ ·✧   ✦ ████ ████ ✧   ████ ████ ✦   ████ ████ ✦   ▒▒▒▒ ████ ·   ░░▒▒▓▓▓▓▓▒▒░ ░▒▒▓▓▓███▓▓▓▓▓▓▒▒▒▒▒░░░▒░▒▒▒▓▓▓▓▒▒▒░░ ░ ░░░▒▒▒▒▒▒▒▒░░   ░░░▒▒▒▓▓▒▒▒▒░░ ░▒▒▓███████▓▓▒▒▒▒▒▒░░░░░▒▒▒▓▓▓▓▓▓▒▒░░░ ░░░▒▒▒▒▒▒░░░░   ░░░░▒▒▒▒▒▒▒▒░░░ ░▒▓▓███████▓▓▓▒▒▒▒░░░░░░▒▒▓▓▓▓█▓▓▒▒░░ ░░░▒░▒▒▒░░░░   ░░░░░░▒▒▒▒▒▒░░░ ░░▒▓████████▓▒▒▒▒▒▒░░▒▒▒▓▓▓███▓▓▒▒░░ ░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░ ░▒▓▓███████▓▓▒▒▒▒▒▒▒▒▒▓▓████▓▓▒▒░░ ░░░░░░░░░░ ░░░ ░░   ░░░░░░░░░░░░░░░ ░ ░░░▒▒▓▓████▓▓▓▒▒▒▒▒▒▓▓▓▓████▓▓▒▒░░ ░░░░░░░░░░░░░░░░   ░ ░ ░░░░░░░░░░░░░░░ ░░░▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒▓▓▓▓███▓▓▓▒▒░ ░░░░░░░░░░░░░░   ░ ░░░░░░░░▒░░░░░░ ░░▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▒▒░░ ░ ░░░░░░░░░░░░░░░   ░░ ░░░░░░▒▒▒▒▒▒▒░░░ ░ ░░░▒░░▒▒▒▓▓▓▓▒▒▒░░ ░░░░░░░░░▒▒▒░░░░   ░░░░░▒░▒▒▒▒▒▒▒░░ ░░░░░░▒▒▒▒▒▒▒░░ ░░░░░░░░▒▒▒▒▒░░░   ░░░▒░░▒▒▒▒▒▒▒▒▒░░ ░░░░░▒▒▒▒░░░ ░░░░░░░░▒▒▒▒▒▒░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒░░░ ░░▒░▒▒░░░ ░░░░░░░░░▒▒▒▒▒▒░░░   ░░▒░▒▒▒▒▒░▒▒░▒░░░░░ ░░▒▒▒▒░░░ ░░░░░░░▒▒▒▒▒▒▒▒░▒░░   ░░░░░▒░░░░░░░░░░░▒░░░░░░░░░░░▒▒▒▒▒▒░░ ░░░░▒░▒▒░▒▒▒░▒▒▒▒░░░   ░░░░░░░ ░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░▒▒░░░░░   ░░░░▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░   ░ ░░░░▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░░▒░▒▒▒▒▒▒▒▒▒░░░░░░░ ░   ░░░░░ ░░▒▒▒▒▓▓▓▓▒▒▒▒░▒░░░░░░░▒▒▒▒▒▒▒▒░░░░░░░ ░░   ░░░░ ░ ░░░▒▒▒▒▒▒▒▒░░░░░ ░░░░░▒▒░▒░░░░░ ░░   ░ ░ ░░░░░░░░░░░ ░░░░░░░░░░░░ ░░░   ░ ░ ░░░░░ ░░░░   ░ press ⏎ to skip ░░░░  [?7h[?25l[?7l ████████████████████████ ✧ · ✦   ████████████████████████ ✧ ·✧   ░░▒▒▓▓▓▓▓▒▒░ ░░▒▒▓▓▓█▓▓▓▓▓▓▓▒▒▒▒▒▒░░░▒░▒▒▒▒▓▓▓▓▓▒▒░░░ ░░░░▒▒▒▒▒▒▒▒░░   ░░░▒▒▒▓▓▒▒▒▒░ ░▒▓▓██████▓▓▓▒▒▒▒▒▒░░░░░░▒▒▓▓▓▓▓▓▒▒▒░░░ ░░░░▒▒▒▒▒░░░░   ░░░░▒▒▒▒▒▒▒▒░░ ░▒▓████████▓▓▒▒▒▒▒░░░░░░▒▒▒▓▓▓█▓▓▒▒░░░ ░░░░░▒▒▒░░░░░   ░░░▒░░▒▒▒▒▒▒░░ ░▒▒▓████████▓▒▒▒▒▒▒░░░▒▒▒▓▓████▓▒▒░░░ ░ ░░░░░░░░░░░   ░░░░░░░░░░░░░░░░ ░▒▓▓██████▓▓▓▒▒▒▒▒▒▒▒▒▓▓█████▓▓▒▒░ ░░░░░░░░░░ ░░░ ░░   ░░░░░░░░░░░░░ ░░ ░░▒▒▓▓█████▓▓▓▒▒▒▒▒▒▒▒▓▓█████▓▓▒▒░ ░ ░░░░░░░░░░░░░░ ░   ░░░░░░░░░░░░░░░░░░░░ ░░░▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒▓▓▓████▓▓▒▒░░ ░░░░░░░░░░░░░░   ░ ░░░░░░░░░░░░░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▒▒░ ░░░░░░░░░░░░░░░   ░░ ░░░░░░░▒▒▒▒▒▒░░ ░░▒▒▒▒░░▒▒▒▓▓▓▓▓▒▒▒░ ░░░░░░░▒▒▒░░░░░   ░░░░░░░░▒▒▒▒▒▒░░ ░░░░░░▒▒▒▒▒▒▒░░░ ░░░░░░░░▒▒░░▒░░░   ░░░▒░░▒▒▒▒▒▒▒▒▒░░ ░░░░░▒▒▒▒▒░░ ░░░░░░░▒▒░▒▒▒░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒░░░ ░░▒░░▒▒░░ ░░░░░░░▒▒▒▒▒▒░▒░░░   ░░▒░▒▒▒▒▒░▒▒░▒░░░ ░ ░░░▒▒▒░░░ ░░░░░░▒▒▒▒▒▒▒▒░▒░░░   ░░░░░▒░░░░░░░░░░░░░░░ ░░░░ ░░░░▒▒▒▒░░ ░░░░▒░▒▒░▒▒▒░▒▒▒▒░░░   ░░░░░░░░░░ ░░░░░▒▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒░░░ ░░░▒▒▒▒▒▒▒▒▒░▒▒░░░░░   ░ ░░░░▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░   ░░░▒▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▒▒▒▒▒▒░░░░░░░ ░   ░░░░ ░░▒▒▒▒▓▓▓▓▒▒▒▒░▒░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░ ░   ░░░░ ░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░▒▒▒▒░░░░░░ ░   ░ ░ ░ ░░░░░░▒░░░░ ░░░░░░░░░░░░ ░ ░ ░   ░ ░ ░ ░ ░░░░░░ ░░░   ░░░░░ ░░░░░░   ░░  [?7h[?25l[?7l ████████████████████████ ✧ · ✦   ████████████████████████ ✧ ·✧   · ████ ████   ✦ ████ ████ ✧   ░▒▒▒▓▓▓▓▓▒▒░ ░▒▒▓▓▓▓█▓▓▓▓▓▒▓▒▒▒▒▒▒░░░░░▒▒▒▒▒▓▓▓▓▒▒░░░ ░░░▒▒▒▒▒▒▒▒░░   ░░▒▒▒▒▓▓▒▒▒░░ ░░▒▓▓██████▓▓▒▒▒▒▒▒▒░░░░░░▒▒▒▓▓▓▓▓▓▒▒░░░ ░ ░░▒▒▒▒▒▒▒░░░   ░░░▒▒▒▒▒▒▒▒▒░ ░▒▓████████▓▒▒▒▒▒▒░░░░░░░▒▒▓▓▓█▓▓▓▒░░░░ ░░░░▒▒▒░░░░░   ░░░▒░▒▒▒▒▒▒▒░ ░▒▒▓███████▓▓▒▒▒▒▒▒░░░░▒▒▒▓▓███▓▓▒▒░░ ░░░░░░░░░░░   ░░░░░░░▒▒░░░░░░ ░░▒▓███████▓▓▓▒▒▒▒▒▒░▒▒▒▓▓█████▓▓▒░░ ░ ░░░░░░░░ ░░░ ░░   ░░░░░░░░░░░░░ ░░▒▒▓██████▓▓▓▒▒▒▒▒▒▒▒▓▓█████▓▓▒▒░░░ ░░░░░░░░░░░░ ░   ░░░░░░░░░░░░░░░░░ ░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▓▓█████▓▓▒░░░ ░░░░░░░░░░░░░░   ░ ░░░░░░░░░░░░░░ ░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▒▒░ ░░░░░░░░░░░░░░░   ░░ ░░░░░░░░▒▒▒▒░░░ ░░░░▒▒▒▒░░▒▒▒▓▓▓▓▓▒▒▒░░ ░░░░░░▒▒▒░░░░░   ░░░░░░░░▒▒▒▒▒▒░░ ░░░░░░▒▒▒▓▒▒▒▒░░ ░░░░░░░░▒░░▒░▒░░   ░░░░▒░░▒▒▒▒▒▒▒▒▒░░ ░░░░░░▒▒▒▒▒▒░░ ░░░░░░░░▒░▒▒▒░░░░   ░░░░░▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░▒▒░░ ░░░░░░░▒▒▒▒▒▒▒▒░░   ░░░▒░▒▒▒▒▒▒▒▒░▒░░░ ░░░▒░░▒░░ ░░░░░▒▒▒▒▒▒▒▒░▒░░░   ░░░░▒░▒▒░▒░░░░░░░░░░░░ ░░░░▒▒▒▒░░ ░░░░▒▒░░▒▒░▒▒▒▒▒░░░   ░░░░░░░░░░░░░░░░░▒▒░░▒░░░░░░▒▒▒▒▒▒▒░░░ ░░░▒▒▒▒▒▒▒▒▒░▒▒░░░░░░   ░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░   ░░░▒▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░   ░░ ░░░▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░   ░░░░ ░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒░░░░░ ░ ░ ░   ░ ░ ░░░░░░░▒▒▒░░░ ░░░░░░░░░░░░░░░ ░   ░ ░ ░░░░░ ░░░░░░░░ ░ ░░   ░░░░░ ░░░░░░   ░░   press ⏎ to skip ░░░░  [?7h[?25l[?7l ████████████████████████ ✧ · ✦   ████████████████████████ ✧ ·✧   · ████ ████   ✦ ████ ████ ✧   ████ ████ ✦   ████ ████ ✦   ▒▒▒▒ ████ ·   ▒▒▒▒ ████ ·   ░▒▒▒▓▓▓▒▒▒░░ ░░▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒░░░░░▒▒▒▒▒▓▓▓▓▓▒▒░░░ ░░▒▒▒▒▒▒▒▒░░   ░░▒▒▒▒▓▓▒▒▒░░ ░▒▒▓▓▓████▓▓▒▒▒▒▒▒▒▒░░░░░░░▒▒▓▓▓▓▓▓▓▒▒░░ ░░░░▒▒▒▒▒░░░   ░░░▒▒▒▒▒▒▒▒▒░ ░▒▓███████▓▓▒▒▒▒▒▒░░░░ ░░▒▒▓▓▓█▓▓▓▒▒░░░ ░░░░▒▒▒░░░░░   ░░░▒▒▒▒▒▒▒▒▒░ ░░▒▓████████▓▓▒▒▒▒▒▒░░░░░▒▒▓▓███▓▓▓▒▒░░ ░░░░░░░░░░░   ░░░░▒░░▒▒░░░░░░ ░▒▓▓███████▓▓▒▒▒▒▒░▒░▒▒▒▒▓█████▓▓▒▒░ ░░░░░░░░ ░░░ ░░   ░░░░░░░░░░░░░░ ░▒▒▓▓██████▓▓▓▒▒▒▒▒▒▒▒▒▓▓█████▓▓▒▒░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░ ░▒▒▒▓▓▓█▓▓▓▓▒▒▒▒▒▒▒▒▓▓██████▓▒▒░░ ░ ░░░░░░░░░░░░░░   ░ ░ ░ ░░ ░░░░░░░░ ░░░░░▒▒▒▒▓▒▒▒▒▒▒▒▒▒▓▓▓▓▓██▓▓▒▒░ ░ ░░░░░░░░░░░░░   ░░ ░░░░░░░░▒▒░░░░░ ░░▒░▒▒▒▒░░▒▒▒▓▓▓▓▓▓▓▒▒░ ░░░░░░░▒▒░░░░░   ░░░░░░░░▒▒▒▒▒▒░░ ░░░░░░░▒▒▒▓▒▒▒▒▒░ ░░░░░░░▒░░▒▒▒░░░   ░░░░░░░░▒▒▒▒▒▒▒░░░ ░░░░░░▒▒▒▒▒▒░░ ░ ░░░░░▒░▒▒▒░░░░░   ░░░░░░▒▒▒▒▒▒▒▒▒▒░░ ░░░░░░▒▒░░░ ░░░░░░░▒▒▒▒▒▒▒░░   ░░░▒░▒▒▒▒▒▒▒▒░▒░░ ░░░▒░░▒░░░ ░░░░░░░▒▒▒▒░▒░▒▒░░   ░░░░▒▒▒▒▒▒░░░░░░░░░░ ░░░░▒░▒▒░░ ░░░░▒▒░░▒▒░▒▒▒▒▒░░░   ░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▒░▒▒░░░░░░   ░░░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░ ░░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░   ░░░▒▒▒▒▒▓▓▓▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒░░░░░░░░░░   ░░░▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░   ░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒▒░░░░░░░ ░   ░ ░ ░░░░▒▒▒▒▒▒░░░ ░░░░░░░░▒░░░▒░░░░ ░   ░ ░ ░ ░░░░░░ ░░░░░░░░░░ ░░   ░░░░░ ░░░░░   press ⏎ to skip ░ ░  [?7h[?25l[?7l ████████████████████████ ✧ · ✦   ████████████████████████ ✧ ·✧   · ████ ████   ✦ ████ ████ ✧   ████ ████ ✦   ████ ████ ✦   ▒▒▒▒ ████ ·   ▒▒▒▒ ████ ·   ░▒▒▒▓▓▒▒▒▒░░ ░░▒▒▓▓▓▓▓▒▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░▒▒▒▒▒▓▓▓▓▓▒▒░░░ ░░░▒▒▒▒▒▒░░░   ░░▒▒▒▒▓▓▒▒▒░ ░▒▒▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▒▓▓▓▓▓▒▒░░ ░░░▒▒▒▒▒░░░   ░░░▒▒▒▒▒▒▒▒░░ ░░▒▓██████▓▓▒▒▒▒▒▒▒░░░░ ░░░▒▒▓▓█▓█▓▓▒▒░░ ░ ░░▒░░░▒░░░   ░░░▒▒▒▒▒▒▒▒░░ ░░▒▓████████▓▓▒▒▒▒▒░░░░░░▒▒▒▓███▓▓▓▒▒░░░ ░ ░░░░░░░░░   ░░░░▒░▒▒▒░░░░ ░▒▓████████▓▓▒▒▒▒▒░▒░░░▒▒▓▓████▓▓▒▒░░ ░░░░░░░░ ░░░ ░░   ░░░░░░░░░░░░░░ ░▒▒▓▓██████▓▓▓▒▒▒▒▒▒▒▒▒▓▓▓████▓▓▒▒░░ ░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░ ░░▒▒▓▓▓████▓▓▒▒▒▒▒▒▒▒▓▓██████▓▓▒░░ ░░░░░░░░░░░░░   ░░░░░░░ ░ ░░░░░░░ ░░░▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒▒▓▓▓▓██▓▓▒▒░░ ░░░░░░░░░░░░░   ░░░ ░ ░░░░░░░░░░░░ ░░░▒▒▒▒▒▒░░▒▒▒▓▓▓▓▓▓▓▒▒░░ ░░ ░░░░░▒░░░░░░░   ░ ░░░░░░░░▒▒▒▒▒▒░░ ░░░░░░░░▒▒▒▓▓▓▒▒▒░░ ░ ░░░░░░░░▒▒▒░░░   ░░░░░░░░▒▒▒▒▒░▒░░░ ░░░░░░▒▒▒▒▒▒▒░░ ░░░░░░░▒▒░░░░░   ░░░░░░▒▒▒▒▒▒▒▒░▒░░ ░░░░░░▒▒▒░░ ░░░░░░░▒▒▒▒▒▒▒░░   ░░░▒░▒▒▒▒▒▒▒▒░▒░░ ░░░░░░▒░░░ ░░░░░░░░░▒▒░▒░▒▒▒░░░   ░░░░▒▒▒▒▒▒░░░░░░░░░░ ░░░░▒░▒▒░░ ░░░░░▒░░▒▒░▒▒▒▒▒░░░   ░░░░░▒░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒░░░░ ░░░░░░▒▒▒▒░▒▒░░░▒░░░   ░░░░░░░ ░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░▒▒▒▒▒▒▒▒▒░░░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░░   ░░▒▒▒▒▒▓▒▓▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░   ░░ ░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░▒▒▒▒▒▒▒░░░░░░░   ░ ░ ░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░▒░▒░▒░░░░ ░   ░ ░ ░░░░░░░░░ ░░░░░░░░░░ ░   ░░░░░░ ░░░░   ░ ░   press ⏎ to skip ░ ░  [?7h[?25l[?7l ✧   ✧ ✧ · · ✦✧   ✦   · ✦ ✧ ✦   · · · ·   · · ·✧ · ·   ████████████████████████ ✧   ████████████████████████ ✦   ✧ ████ ████   ✦ ████ ████ ✧   · ████ ████ ✧   · · ████ ████ ·   ✧ ✦ ▒▒▒▒ ████ ✧   · ▒▒▒▒ ████ ✧   · · ████ ·   ░░▒▒▒▓▓▒▒▒░░ ░░░▒▒▒▓▓▒▒▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░▒▒▒▒▒▓▓▓▓▓▒▒░░░ ░░░▒▒▒▒▒▒░░░░   ░░▒▒▒▒▓▓▒▒▒░ ░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▓▓▓▓▓▒▒░░░ ░░░▒▒▒▒▒░░░   ░░░▒▒▒▒▒▒▒▒░░ ░▒▓▓█████▓▓▓▒▒▒▒▒▒▒░░░ ░░▒▒▒▓▓▓█▓▓▒▒░░ ░░░░░░▒░░░   ░░░▒▒▒▒▒▒▒▒░ ░▒▒▓███████▓▓▓▒▒▒▒▒░░ ░░░░▒▒▓▓▓█▓▓▓▒▒░░░ ░░░░░░░░░   ░░░░▒▒▒▒▒░░░░ ░▒▓████████▓▒▒▒▒▒▒░░░░░▒▒▓▓▓███▓▓▓▒░░ ░ ░░░░░░ ░░░ ░░   ░░░░░░░░░░░░░░ ░▒▒▓███████▓▓▓▒▒▒▒▒▒▒▒▒▒▓▓█████▓▓▒░░░ ░░░░░░░░░░░░░   ░░░░░░░░░░░░░░ ░░▒▒▓██████▓▓▒▒▒▒▒▒▒▒▒▓▓█████▓▓▒▒░░ ░ ░░░░░░░░░░░   ░░░░░░░ ░ ░░░░ ░░░▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▓▓▓▓███▓▓▒░░ ░░░░░░░░░░░░░   ░░░ ░ ░░░░░░░░░░░░ ░░░▒▒▒▒▒▒▒▒░░▒▒▒▓▓▓▓▓▓▓▓▒░░ ░ ░░░░░▒░░░░░░░   ░ ░░░░░░░░░▒▒░░░░ ░░░░░░░░░▒▒▒▓▓▓▓▒▒▒░ ░░░░░░░░▒▒▒░░░   ░░░░░░░░░░▒▒▒░░░░ ░░░░░░░▒▒▒▒▒▒▒░░ ░░░░░░░▒▒░░▒░░░   ░░░░░░▒▒▒▒▒▒▒░░░░░ ░░░░░░░▒▒▒░░░ ░░░░░░░░▒▒▒▒▒▒░░░   ░░░▒░▒▒▒▒▒▒▒▒░▒░░ ░░░░░░▒░░░░ ░░░░░░░░░▒░▒░▒▒▒░░░   ░░░░▒▒▒▒▒▒░░░░░░░░░ ░░░░▒░▒▒░░ ░░░░░░▒▒░▒▒▒▒▒░░░░   ░░░░░▒░░░░░░░░░░░░░░░░ ░░░░▒▒▒▒░░░░ ░░░░░▒▒▒▒░▒▒░░▒▒░░░░   ░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒░▒░▒▒▒▒▒▒░░░ ░░░▒▒▒▒▒▒▒▒▒░▒░▒░░░░   ░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░▒▒▒▒▒▒▒░░░░░░░░░   ░░▒▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░   ░ ░░▒▒▒▓▒▒▒▒▒▒▒▒░░░░░░░░░░▒▒▒▒▒▒▒░░░░░░░ ░   ░ ░ ░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░▒▒▒░▒░░░░░░░   ░ ░ ░░░░░▒░░░ ░░░░░▒▒░░░░ ░   ░░░░░░░░ ░░   press ⏎ to skip ░ ░ ░  [?7h[?25l[?7l ████████████████████████ ✧   ████████████████████████ ✦   ✦ ████ ████ ✧   · ████ ████ ✧   · · ████ ████ ·   ✧ ✦ ▒▒▒▒ ████ ✧   · ▒▒▒▒ ████ ✧   ░▒▒▒▒▓▓▒▒▒░░ ░░▒▒▒▒▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓▒▒▒▒░░░░▒░▒▒▒▓▓▓▓▒▒▒░░ ░░▒▒▒▒▒▒▒░░░   ░░▒▒▒▒▓▒▒▒░░ ░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▒▓▓▓▓▓▒▒░░ ░░▒░▒▒▒▒░░   ░░░▒▒▒▒▒▒▒▒░ ░░▒▓▓████▓▓▓▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▓▓▓█▓▓▒▒░░░ ░░░░░░▒░░░   ░░░▒▒▒▒▒▒▒░░ ░░▒▒▓██████▓▓▓▒▒▒▒▒▒░░ ░░░░░▒▒▓▓██▓▓▓▒▒░░░ ░░░░░░░░   ░░░░▒▒▒▒▒░░░░ ░░▒▓███████▓▓▒▒▒▒▒▒░░░░░░▒▒▓▓▓██▓▓▓▒▒░░ ░░░░░░ ░░░ ░░   ░░░░░░▒░░░░░░░ ░░▒▓▓██████▓▓▓▒▒▒▒▒▒▒▒░▒▒▓▓█████▓▓▒▒░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░ ░ ░▒▒▓▓██████▓▒▒▒▒▒▒▒▒▒▒▓▓▓████▓▓▒▒▒░░ ░ ░░░░░░░░░░░   ░░░░░░░ ░ ░░ ░ ░░▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▓▓▓▓███▓▓▒▒░░ ░░░ ░░░░░░░░░   ░ ░░░ ░ ░ ░░░░░░░░░░ ░░░░▒▒▒▒▒▒▒▒░▒▒▒▒▓▓▓█▓▓▓▓▒▒░░ ░░░░░░░░░░░   ░ ░░░░░░░░░▒▒░░░ ░░░░▒░░░░░▒▒▒▓▓▓▓▓▒▒░░ ░░░░░░░░▒░░░░   ░░░░░░░░░░▒▒▒░░░░ ░░░░░░░▒▒▒▒▒▒▒▒░░ ░░░░░░▒▒░░▒░░░   ░░░░░░▒░▒▒▒▒▒░░░░░ ░░░░░░░▒▒▒▒░░░ ░░░░░░░░▒▒▒▒▒░░░   ░░░▒░▒▒▒▒▒▒▒▒░░░░ ░░░░░░▒▒░░░ ░░░░░░░░░▒░▒▒▒░░░   ░░░░▒▒▒▒▒▒░░░░░░░░░ ░░░░▒░▒░░░ ░░░░░░░▒░▒▒▒▒▒░░░░   ░░░░░░▒▒░░░░░░░░░░░░ ░░░░▒▒▒░░░░░ ░░░░░░▒░▒░▒▒░░▒▒░░░░   ░░░░░░░░░░░░░░░░▒▒▒░░░░░░░░▒▒▒▒▒▒░░░ ░░░░░▒▒▒▒▒▒▒░▒░▒░░░░   ░░░ ░ ░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░ ░░░▒▒▒▒▒▒▒▒░░░░░░░░   ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░   ░░░▒▒▓▓▒▒▒▒▒▒▒▒▒░▒░░░░░░░░░▒▒▒▒▒▒░░░░░░ ░   ░ ░ ░░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░▒▒▒▒░▒░░░░░   ░ ░ ░░░░░░░▒░░░░ ░░░░░▒▒░░░░░░   ░░ ░░░░░░░░░ ░░   ░  [?7h[?25l[?7l ████████████████████████ ✧   ████████████████████████ ✦   ✧ ████ ████   ✦ ████ ████ ✧   · ████ ████ ✧   · · ████ ████ ·   ████   ░▒▒▒▒▓▓▒▒▒░░ ░░▒▒▒▒▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓▒▒▒▒░░░░░░▒▒▒▒▓▓▓▒▒▒▒░░░ ░░░▒▒▒▒▒▒▒░░   ░░▒▒▒▒▓▒▒░░░ ░░▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▓▓▓▓▓▒▒▒░░░ ░░░░▒▒▒▒▒░   ░░░▒▒▒▒▒▒▒▒░ ░▒▒▓▓█▓█▓▓▓▓▒▒▒▒▒▒▒▒▒░░░ ░░▒▒▒▓▓█▓▓▓▒▒░░ ░░░░▒░░░░   ░░░▒▒▒▒▒▒▒░░ ░░▒▓▓█████▓▓▓▒▒▒▒▒▒▒░░ ░ ░░░▒▓▓██▓▓▓▒▒░░░ ░░░░░░░░░   ░░░░▒▒▒▒▒░░░░ ░▒▒▓███████▓▓▒▒▒▒▒▒░░░░░░░▒▓▓▓████▓▓▒░░░ ░░░░░░ ░░░ ░░   ░░░░░░▒░░░░░░░ ░▒▒▓███████▓▓▒▒▒▒▒▒▒▒▒░░▒▒▓██████▓▓▒▒░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░ ░░▒▒▓███████▓▒▒▒▒▒▒▒▒▒▒▒▓▓▓████▓▓▒▒░░ ░ ░░░░░░░░░░   ░░░░░░░ ░ ░░▒▒▒▓▓█▓▓▓▓▒▒▒▒▒▒▒▒▒▓▓▓▓████▓▓▒░░░ ░ ░ ░░░░░░░░░░   ░░░░░ ░ ░ ░░░░░░░ ░░░░▒▒▒▓▒▒▒▒▒▒▒▒▒▒▓▓▓█▓▓█▓▓▒▒░░ ░░░░░░░░░░░   ░ ░░░░░░░░░░░░░░ ░░░░░░░▒░░▒░░▒▒▒▓▓▓▓▓▓▒░░ ░░░░░░░░▒░░░░   ░░░░░░░░░░▒▒▒░░░░ ░░░░░░░▒▒▒▒▒▓▒▒░░░ ░░░░░░▒▒░░▒░░░   ░░░░░░▒░▒▒▒▒▒░░░░ ░░░░░░░▒▒▒▒░░░ ░ ░░░░░░▒▒░▒▒░░░   ░░░░▒░░▒▒▒▒▒▒▒░░░ ░░░░░░▒▒▒░░░ ░░░░░░░▒░▒▒▒░░░░   ░░░░▒▒▒▒▒▒▒░▒░░░░░ ░░░░▒░▒░░░ ░ ░░░░░▒░▒▒▒▒▒░░░░░   ░░░░░░▒▒▒░░░░░░░░░░░ ░░░░▒▒▒░░░░░ ░░░░▒░▒░▒▒░░▒▒░░░░   ░░░░░░░░░░░░░░░░▒░░░░░░░░░░▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▒░▒░▒░░░░░   ░░░ ░ ░ ░░░░░░▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒░░░ ░ ░░░░░▒▒▒▒▒▒░░░░░░░░░░   ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░   ░░░▒▒▓▓▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒░░░░░░ ░   ░ ░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░▒▒▒▒▒░░░░░░   ░ ░░░░░▒▒▒░░░░░ ░░░░░▒▒░░░░░░░   ░░░░░ ░░░░░░░░░ ░   ░░ ░   press ⏎ to skip ░ ░  [?7h[?25l[?7l ████████████████████████ ✧   ████████████████████████ ✦   ✧ ████ ████   ✦ ████ ████ ✧   · ████ ████ ✧   · · ████ ████ ·   · · ████ ·   ████   ░░▒▒▒▒▒▒░░░░ ░░░▒▒▒▒▓▒▒▒▒░▒░░░░▒▒▒▒▒▒▓▓▓▒▒▒▒░░░░░░▒▒▒▓▓▓▒▒▒▒░░ ░ ░░▒▒▒▒▒▒░░   ░░▒▒▒▒▒▒░░░ ░░▒▒▒▓▓▓▒▒▒▒▒▒▒▒▒▒▒▓▒▒▒▒▒░░░ ░░░░░▒▓▓▓▓▓▓▒▒░░░ ░░▒░▒▒▒░░░   ░░░▒▒▒▒▒▒▒░░ ░░▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░▒▓▓▓▓▓▓▓▒▒░░ ░░░░░░░░░ ░   ░░░▒▒▒▒▒▒░░░ ░░▒▒▓▓███▓▓▓▒▒▒▒▒▒▒▒▒░░ ░▒▒▓▓▓▓█▓▓▒▒░░░ ░░░░░░░░   ░░░░▒▒▒▒▒░░░ ░░▒▓███████▓▓▒▒▒▒▒▒▒░░░░ ░░▒▒▒▓▓████▓▒▒░░░ ░░░░░ ░░░ ░░   ░░░░░░▒░░░░░░ ░▒▓▓███████▓▓▒▒▒▒▒▒▒▒░░░░▒▒▓▓█████▓▒▒░░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░ ░░▒▓███████▓▓▒▒▒▒▒▒▒▒▒▒▒▒▓▓█████▓▓▒▒░░ ░░░░░░░░░░   ░░░░░░░░░ ░ ░░▒▒▓▓▓███▓▓▓▒▒▒▒▒▒▒▒▒▒▒▓▓█████▓▒▒▒░ ░░░░░░░░░░   ░ ░░░░░░ ░ ░░ ░░░▒▒▒▒▓▓▓▒▓▒▒▒▒▒▒▒▒▒▓▓▓▓▓██▓▓▒▒░░ ░░░░░░░░░░░░░   ░ ░ ░░░ ░░ ░░░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒░░ ░░░░░░░▒░░░░░   ░░ ░░░░░░░▒░░░░ ░░░░░░░░░▒▒▒▒▓▓▓▒▒░░ ░░░░░░░░▒░░░░   ░░░░░░░░░░▒▒▒░░░░ ░░░░░░░▒▒▒▒▒▒░░ ░░░░░░░░▒▒░▒░░░   ░░░░░░░▒░▒▒▒▒▒░░░ ░░░░░░▒▒▒░░░ ░░░░░░░░░▒▒▒░░░░░   ░░░░░░▒▒▒▒▒▒▒░▒░░░░ ░░░░░░░░░░░░ ░░░░░░░░░░▒▒░░░░░░   ░░░░▒▒▒▒▒▒░░░░░░░ ░░░▒▒░░░░░░ ░░░░░▒░▒▒░░▒▒░░░░░░   ░░░░░░░░░░░░░░░░░░░░ ░ ░░░░░▒▒▒▒▒░░░ ░░░░░▒▒▒▒░▒░▒░░░░░░░   ░░░░░░░ ░░░░░░░░░░▒░░░░░░░▒▒▒▒▒▒▒░░ ░ ░░░▒▒▒▒▒░░░░▒░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░░▒▒▒▒▒▒▒▒░░░░░░░   ░░░▒▒▒▓▓▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒░░░░░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒░▒░░▒░░░░░░░░▒▒▒▒▒░▒░░░░ ░   ░ ░░▒▒▒▒▒▒▒▒░░░░ ░ ░░░░░▒▒▒░▒░░░░░   ░ ░░░░░░░░░░░ ░░░░░░░░░░░   ░░░░░   press ⏎ to skip ░ ░  [?7h[?25l[?7l ████████████████████████ ✧   ████████████████████████ ✦   ✧ ████ ████   ✦ ████ ████ ✧   · ████ ████ ✧   · · ████ ████ ·   ✧ ✦ ▒▒▒▒ ████ ✧   · ▒▒▒▒ ████ ✧   · · ████ ·   ████   ░░▒▒▒▒▒▒░░░ ░░░▒▒▓▓▓▒▒▒▒░░░░░░░▒▒▒▒▒▓▓▓▒▒▒▒░░░░░░▒▒▒▓▓▓▒▒▒▒░░░ ░░▒▒▒▒▒▒░░ ░   ░░▒▒▒▒▒▒░░░ ░░▒▒▓▓▓▒▒▒▒░▒░░░░▒▒▒▓▒▒▒▒▒▒░░░░░░░░▒▒▒▓▓▓▓▒▒▒░░ ░░░░▒▒▒▒░░   ░░░▒▒▒▒▒▒▒░ ░░▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░▒▒▓▓▓▓▓▓▒▒░░░ ░░░░░░░░░░░   ░░░▒▒▒▒▒▒░░░ ░▒▒▒▓▓▓█▓▓▓▒▒▒▒▒▒▒▒▒▒▒░ ░ ░░▒▒▓▓▓█▓▓▓▒▒░░ ░░░░░░   ░ ░░░░▒▒▒▒▒░░░ ░▒▒▓██████▓▓▓▒▒▒▒▒▒▒░░░░ ░▒▒▓▓████▓▓▒▒░░ ░░░░░ ░░ ░░   ░░░░░░░▒░░░░░░ ░░▒▓▓██████▓▓▒▒▒▒▒▒▒▒░░░░░▒▒▓▓▓████▓▒▒▒░░ ░ ░░░░░░░ ░░░░░   ░░░░░░░░░░░░░ ░▒▒▓███████▓▓▒▒▒▒▒▒▒▒░▒▒▒▓▓▓████▓▓▓▒▒░ ░░░░░░░░░░   ░░░░░░░░░░ ░░▒▒▓▓████▓▓▓▒▒▒▒▒▒▒▒▒▒▒▓▓██████▓▒▒░░ ░░░░░░░░   ░░░░░░░░░ ░░░▒▒▒▓▓▓▓▒▓▒▒▒▒▒▒▒▒▒▓▓▓▓▓██▓▓▒▒░░ ░░░░░░░░░░░░   ░ ░ ░░ ░░ ░░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒░░ ░░░░░░▒░░░░░   ░ ░ ░ ░░░░░░░░░░ ░░░░░▒░░░░░▒▒▒▓▓▓▓▓▒▒▒░ ░░░░░░░░▒░░░░   ░░░░░░░░░░░▒░░░░ ░░░░░░░▒▒▒▒▒▒▒░░ ░░░░░░░▒▒░▒░░░   ░░░░░░░░▒░▒▒▒▒▒░░░ ░░░░░░▒▒▒▒░░░ ░░░░░░░░░▒▒▒▒░░░░   ░░░░░░▒░▒▒▒▒▒░░░░░ ░░░░░░░░░░░░ ░ ░░░░░░░▒▒░▒░░░░   ░░░░▒▒▒▒▒▒░░░░░░░ ░░░░░░░░░░░ ░░░░░░░░░▒▒░░░░░░   ░░░░░░░░░░░░░░░░░░░░ ░░░▒▒▒▒▒░░░ ░░░▒░▒▒░▒░▒░░▒░░░░   ░░░░░░░ ░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒░░ ░░░▒▒▒▒▒░░░░▒░░░░░░   ░ ░░░░▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒░░░ ░ ░░░░▒▒▒▒▒▒▒░░░░░░░░░   ░░░▒▒▒▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒░▒▒▒▒▒▒░░░░░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒░▒░▒▒░░░░░░░░░▒▒▒▒▒▒░░░░ ░░   ░░░▒▒▒▒▒▒▒▒░░░░░░░ ░░░░░▒▒▒░▒░░░░░   ░░░░░▒░░░░░ ░░░░░░░░░░░   ░░░░░   press ⏎ to skip ░░ ░  [?7h[?25l[?7l ████████████████████████ ✧   ████████████████████████ ✦   · ████ ████ ✧   · · ████ ████ ·   ✧ ✦ ▒▒▒▒ ████ ✧   · ▒▒▒▒ ████ ✧   · · ████ ·   ░░░▒▒▒▒▒▒░░░ ░░▒▒▒▒▓▓▓▒▒▒░░░░░░░░▒▒▒▒▒▓▓▓▒▒▒▒░░░░░░▒▒▒▓▓▓▓▒▒▒░░░ ░░░▒▒▒▒▒▒░░░   ░░░▒▒▒▒▒▒░░░ ░░▒▒▒▓▓▒▒▒▒░░░░░░░▒▒▒▓▒▒▒▒▒▒░░░░░░░░░▒▒▓▓▓▓▓▒▒░░ ░░░░▒▒▒▒░░   ░░░▒▒▒▒▒░░░ ░▒▒▒▒▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▓▓▓▓▓▒▒▒░░░ ░░░░░░░░░░░   ░░░▒▒▒▒▒▒░░░ ░▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░▒▒▓▓▓█▓▓▓▒▒░░ ░░░░░░   ░ ░░░░▒▒▒▒▒░░░ ░▒▒▓█████▓▓▓▒▒▒▒▒▒▒▒░░░░ ░░▒▒▓▓▓██▓▓▒▒░░ ░░░░ ░░ ░░   ░░░░░░░▒░░░░░░ ░▒▒▓██████▓▓▓▒▒▒▒▒▒▒▒░░░░░░▒▒▓▓████▓▓▓▒▒░░ ░░░░ ░░░░░   ░░░░░░░░░░░░░ ░▒▓▓██████▓▓▓▒▒▒▒▒▒▒▒░░▒▒▒▒▓█████▓▓▒▒░░ ░░░░░░░░░░   ░░░░░░░░░░ ░░▒▓▓█████▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▓▓█████▓▒▒▒░ ░░░░░░░░   ░░░░░░░░░ ░░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▓▓▓▓███▓▓▒▒░░ ░░░░░░░░░░░░   ░░░░ ░ ░ ░░░ ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓█▓▓▒▒▒░ ░░░░░░░░░░░░   ░ ░ ░ ░░░░░░░░░ ░░░░░▒▒░░░░▒▒▒▒▓▓▓▓▓▒▒▒░░ ░░░░░░░░▒░░░░   ░░░░░░░░░░░▒░░░░ ░░░░░░░░▒▒▒▒▒▒▒░░ ░░░░░░░▒▒░▒░░░   ░░░░░░░░▒░░▒░▒▒░░░ ░░░░░░▒▒▒▒▒░░ ░░░░░░░░▒▒▒▒░░░░   ░░░░░░▒░▒▒▒▒▒░░░░░ ░░░░░░░░░░░░░ ░ ░░░░░░░▒▒░░▒░░░░   ░░░░░░▒▒▒▒▒▒░░░░░░ ░░░░░░░░░░░ ░░░░░░░░░░▒░░░░░░   ░ ░░░░░░░░░░░░░░░░░░ ░░░░▒░░▒░░░ ░░░░░▒▒░▒░▒░░▒▒░░░░   ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒░░ ░░░░░▒▒▒░░░░▒░▒░░░░   ░░ ░░░░▒▒▒▒▒▒▒░▒▒░▒▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▒▒░▒░░░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒░▒▒▒▒▒▒▒░░░░░░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒░▒░▒▒▒░░░░░░░░░▒▒▒▒▒░▒░░░░░   ░░▒▒▒▒▒▒▒▒▒░░░░░░░ ░░░░░▒▒▒▒▒▒░░░░░░   ░░░░░▒░░░░░ ░░░░░░░░░░░░   ░░ ░ ░░░░░░  [?7h[?25l[?7l ████████████████████████ ✧   ████████████████████████ ✦   ✧ ████ ████   ✦ ████ ████ ✧   · ████ ████ ✧   · · ████ ████ ·   ✧ ✦ ▒▒▒▒ ████ ✧   ░░░▒▒░▒▒░░ ░▒▒▒▓▓▓▓▓▒▒▒░░░░░░░░░▒▒▒▓▓▓▓▒▒▒▒░░░░░░░▒▒▒▓▒▓▒▒▒▒░░ ░░░▒▒▒▒▒▒░░   ░░░░▒▒▒▒▒░░ ░ ░░▒▒▒▓▓▒▒▒░░░░░░░░▒▒▓▓▒▒▓▒▒▒░░░░ ░░░░▒▒▒▓▓▓▓▒▒░░░ ░ ░░▒▒▒▒░░   ░░░▒▒▒▒▒░░░ ░░▒▒▒▒▓▓▒▒▒▒░░░▒▒▒▒▒▒▒▒▒▒░░ ░░▒▒▓▓▓▓▓▓▒▒░░░ ░░░░░░░░░   ░░░▒▒▒▒▒▒░░░ ░▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░▒▒▓▓█▓▓▓▓▒▒░ ░ ░░░░░   ░ ░░░░▒▒▒▒▒░░░ ░░▒▒▓███▓█▓▓▒▒▒▒▒▒▒▒▒░░░░ ░░░▒▓▓▓███▓▒▒▒░ ░░ ░░ ░░   ░░░░░░░▒░░░░░░ ░▒▒▓██████▓▓▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▓▓████▓▓▒▒░░ ░░ ░ ░░░░   ░░░░░░░░░░░░░ ░▒▓███████▓▓▓▒▒▒▒▒▒▒▒░░░▒▒▒▓▓████▓▓▒▒░░ ░░ ░░░░░░   ░░░░░░░░░░ ░▒▒▓▓█████▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓█████▓▓▒▒░░ ░░░░░░░   ░░░░░░░░░ ░░░▒▒▓▓▓█▓▓▓▓▒▒▒▒▒▒▒▒▒▒▓▓▓▓███▓▓▒▒▒░ ░░░░░░░░░░░░░   ░░░░░ ░ ░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓██▓▓▒▒▒░ ░ ░░░░░░░░░ ░   ░ ░ ░ ░░░░░░ ░ ░░░░░▒░▒▒░░░░▒▒▒▒▓▓▓▓▓▓▒▒░░ ░ ░░░░░▒░░░░░   ░░ ░░░░░░░░░░░░░ ░ ░░░░░░░░▒▒▒▒▒▒▒▒░░ ░░░░░░░▒░▒░░░   ░░░░░░░░░░░▒░▒░░░ ░░░░░░▒▒▒▒▒▒░░ ░░░░░░░░░▒▒░▒░░░   ░░░░░░░░▒▒▒▒▒░░░░ ░░░░░░░░░░░░░ ░░░░░░░░▒░░▒░░░░░   ░░░░░░░▒▒▒▒▒░░░░░░ ░░░░░░░░░░░░ ░░░░░░░░░░▒░░░░░░░   ░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░ ░░░░▒▒░░░▒░░▒▒░░░░   ░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░▒▒▒▒▒░░ ░░░▒░▒░░░░▒░▒░░░░░   ░ ░░░░ ░░░░░░▒▒░░░░░░░▒▒▒▒▒▒▒░░░ ░░░░░▒▒▒▒▒▒▒░░░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒░░░ ░░░░░▒▒▒▒▒▒▒▒░░░░░░░░   ░ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒░░░ ░░░░░▒▒▒▒▒▒▒░░░░░░   ░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░▒▒▒▒▒▒▒░░░░░   ░░░░▒▒▒░░░░ ░ ░ ░░░░░░░▒▒░░░░   ░░░░░░░░ ░░░░░░░   press ⏎ to skip ░░░  [?7h[?25l[?7l   ·   ✦ ✧ ✧· ✧   · ✦ ✧ ✦   ✧   ✦ · ✧   ✧ ✦ ✧ ████████████████████████   ████████████████████████ ✧ ✦   ✦ ████ ████ ·   ████ ████ · ·   ████ ████   ✦ ████ ████   ▒▒▒▒ ████ · ·   ▒▒▒▒ ████ ✦   ✦ ████ ·   ░░░▒▒░░░░ ░░▒▒▒▓▓▓▓▓▒▒▒░░░ ░ ░░░▒▒▒▓▓▓▓▒▒▒▒░▒░░░░░▒▒▒▓▒▓▒▓▒▒▒░ ░░▒▒▒▒▒▒░░   ░░░░▒▒▒░░░ ░░░▒▒▒▓▓▓▒▒▒░░░░░░░░▒▒▓▓▒▒▓▒▒▒▒░░░ ░░░▒▒▒▓▓▓▓▓▒▒░░ ░░░░▒▒░░░░   ░░░▒▒▒▒▒░░░ ░░▒▒▒▒▒▓▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒░░ ░▒▒▓▓▓▓▓▓▓▒░░░ ░░░░░░░░   ░░░▒▒▒▒▒░░░ ░▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░▒▒▒▓▓▓▓▓▓▒▒░░░ ░░░░░ ░   ░░░░▒▒▒▒▒░░ ░░▒▒▓▓█▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░ ░░▒▒▓▓███▓▓▒▒░░░ ░ ░ ░░   ░░░░░░░▒░░░░░░ ░▒▓▓██████▓▓▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▓▓████▓▓▒▒▒░░ ░░ ░ ░░░░   ░░░░░░░░░░░░░ ░▒▒▓███████▓▓▒▒▒▒▒▒▒▒▒░░░▒▒▒▒▓████▓▓▓▒▒░░ ░ ░░░░░░ ░   ░░░░░░░░░░ ░░▒▓▓██████▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓█████▓▓▓▒░░ ░░░░░░░░   ░░░░░░░░░ ░░▒▒▓▓▓▓█▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▓▓▓███▓▓▓▒▒░░ ░░░░░░░░░░░░   ░░░░░ ░ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓███▓▓▒▒░░ ░ ░░░░░░░░░░   ░ ░ ░░░░░ ░░░░▒▒▒▒▒▒▒░░░░▒▒▒▒▓▓▓▓▓▓▒▒▒░ ░ ░░░░░▒░░▒░░░   ░░ ░ ░░░░░░░░░░░ ░░░░░░░░░░░▒▒▒▒▒▒▒▒▒░░ ░░░░░░▒░▒░░░░░   ░░░░░░░░░░░░░░░░░ ░░░░░░▒▒▒▒▒▒░░ ░░░░░░░░░▒░░▒░░░   ░░░░░░░░▒▒▒▒▒░░░░ ░░░░░ ░░░▒░░░░ ░░░░░░░░░░▒░░░░░   ░░░░░░░░▒▒▒▒░░░░░░ ░░░░░░░░░░░ ░░░░░░░░░▒░░░▒░░░   ░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░ ░░░░░▒░░░▒░░▒▒░░░░   ░░░░░░░░░░░░░░░░░░░░ ░░░░░▒▒░▒▒░░ ░░░▒░░░░░░▒░▒░░░░░   ░░░░░░ ░ ░░░░░░░░░░░░░░░▒▒▒▒▒▒░░░░ ░░░▒▒▒▒▒▒▒░░░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒░░░ ░░░▒▒▒▒▒▒▒░▒░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒░░░ ░░░░░▒▒▒▒▒▒▒▒░░░░░   ░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒░░░░   ░░░▒░▒▒▒▒░░░░ ░░ ░░ ░░░░░░░▒▒░░░░░   ░░░░░░░░░ ░░░░░░░░░   press ⏎ to skip ░░░  [?7h[?25l[?7l ✧ ✦ ✧ ████████████████████████   ████████████████████████ ✧ ✦   ✦ ████ ████ ·   ████ ████ · ·   ████ ████   ✦ ████ ████   ▒▒▒▒ ████ · ·   ▒▒▒▒ ████ ✦   ░░░░▒░░░░░ ░░░▒▓▓▓▓▓▓▓▒▒▒░ ░ ░ ░░░▒▒▒▓▓▓▓▒▒▒▒░▒░░░░░░▒▒▒▒▒▒▓▒▒▒░░ ░░░▒▒▒▒▒░░   ░░░░▒░░░░░ ░░░▒▒▒▓▓▓▒▒▒░░ ░░░░░▒▒▓▓▓▒▓▒▒▒▒░░░ ░▒▒▒▓▓▓▓▓▒▒▒░ ░░░░▒▒░░░░   ░░░▒▒░▒▒░░░ ░░░▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒░░ ░▒▒▓▓▓▓▓▓▒▒░░ ░░░░░░░   ░░░▒▒▒▒░░░░ ░░▒▒▓▓▓▒▒▒▒▒▒░░▒▒▒▒▒▒▒▒▒░░░ ░░▒▒▓▓▓▓▓▓▓▒▒░░ ░░░░ ░   ░░░░▒▒▒▒▒░░ ░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░ ░░░▒▓▓███▓▓▒▒▒░░ ░ ░ ░░   ░░░░░░░▒░░░░░ ░░▒▓▓█████▓▓▒▒▒▒▒▒▒▒▒▒░░ ░░▒▒▒▓▓███▓▓▓▒▒░░ ░░ ░ ░░░░   ░░░░░░░░░░░░░ ░▒▒▓███████▓▓▒▒▒▒▒▒▒▒▒░░░░▒▒▒▓▓█████▓▓▒░░░ ░ ░░░░░░ ░   ░░░░░░░░░░ ░░▒▓▓██████▓▓▓▒▒▒▒▒▒▒▒░▒▒▒▒▓▓████▓▓▓▒▒░ ░░░░░░░░░   ░░░░░░░░░ ░░░▒▒▓▓▓▓█▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▓▓▓████▓▓▒▒░░░ ░ ░░░░░░░░░   ░ ░░░░░ ░ ░░▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓███▓▓▒▒░░░ ░░░░░░░░░░   ░ ░ ░░ ░░░░▒▒▒▒▒▒▒░░░▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒░ ░░░░░░░▒░░░   ░ ░ ░░░░░░░ ░ ░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░ ░░░░░░▒░▒░░░░░   ░░ ░░░░░░░░░░░░░░░ ░░░░░░▒▒▒▒▒▒▒░░ ░░░░░░░▒░░▒░░░░   ░░░░░░░░▒░▒▒░░░░░ ░░░░░ ░░░▒░▒░░ ░░░░░░░░░░░░░░░░   ░░░░░░░░▒▒▒▒░░░░░ ░░░░░░░░░░░ ░░░░░░░░▒░░░▒░░░   ░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░ ░░░░░░░▒░░▒▒░░░░   ░░░░░░░░░░░░░░░░░░ ░ ░░▒▒░░░░░ ░░░░░░░░░▒░▒░░░░░ ░   ░░ ░░░░░░ ░ ░░░░░░░░░░░░░░░░░▒▒▒▒▒░░░░ ░░░░▒▒▒▒▒▒░░░░░░░░   ░░░░░░▒▒▒▒▒▒░▒▒▒░▒▒▒▒▒░▒░░░ ░░░▒▒▒▒▒▒▒▒▒░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░▒▒▒▒▒▒▒▒░░░░░   ░░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░ ░░░▒▒▒▒▒▒▒▒░░░░   ░░░▒▒▒▒▒▒░░░░░░░░░░ ░░░░░░░▒▒░▒░░░   ░░░░░░░░░ ░░░░░░░░░   press ⏎ to skip ░░░ ░  [?7h[?25l[?7l ✧ ✦ ✧ ████████████████████████   ████████████████████████ ✧ ✦   ✦ ████ ████ ·   ████ ████ · ·   ✦ ████ ████   ▒▒▒▒ ████ · ·   ▒▒▒▒ ████ ✦   ✦ ████ ·   ████   ░░░░░░░░░ ░░▒▒▓▓█▓▓▓▒▒▒░░ ░░░▒▒▒▓▓▓▓▒▒▒▒░▒░░░░░░░▒▒▒▒▒▓▒▒▒░░░ ░░░▒░▒▒▒░░░   ░░░░░░░░░░ ░░░▒▒▓▓▓▓▒▒▒░░ ░░░░▒▒▓▓▓▒▓▓▓▒▒░░░░ ░░░▒▓▓▓▓▓▒▒▒░░ ░░░░▒▒░░░░░   ░░░▒░░▒░░░░ ░░░▒▒▓▓▒▒▒▒▒░░░ ░░░░▒▒▒▓▒▒▒▒▒▒░░ ░░▒▒▓▓▓▓▓▒▒▒░ ░░░░░░░ ░   ░░░▒▒▒▒░░░ ░░▒▒▓▓▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒▒░░░ ░░▒▒▒▓▓▓▓▓▓▒▒░░ ░░░░ ░   ░░░░▒▒▒▒▒░░ ░░▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░▒▒▓▓██▓▓▓▒▒░░ ░ ░   ░░░░░░░▒░░░░░ ░▒▒▓▓████▓▓▒▒▒▒▒▒▒▒▒▒░░░ ░░▒▓▓███▓█▓▒▒░░ ░ ░ ░░░░   ░░░░░░░░░░░░ ░▒▒▓██████▓▓▓▒▒▒▒▒▒▒▒░░░░░░░▒▓▓▓████▓▓▒▒░░ ░ ░░░░░░ ░   ░░░░░░░░░░ ░▒▒▓▓██████▓▓▒▒▒▒▒▒▒▒▒░░░▒▒▒▓█████▓▓▓▒░░ ░░░░░░░░░   ░ ░░░░░░░░░ ░░▒▒▓▓████▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▓▓█████▓▓▒▒░░ ░ ░░░░░░░░   ░░░░░░░ ░ ░░▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓███▓▓▓▒▒░░ ░░░░░░░░░░   ░ ░░ ░ ░ ░░░░▒▒▒▒▒▒▒▒▒▒░░▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒░░ ░░░░░░░▒░░░░   ░ ░░░░░░░ ░░░░░░░░░░░░░▒▒▒▒▒▒▒▓▒▒▒░ ░░░░░░░▒░░░░░░   ░░ ░░░░░░░░░░░░ ░ ░ ░░░░░░░▒▒▒▒▒▒▒░░ ░ ░░░░▒░░▒░░░░   ░░░░░░░░▒░░▒░░░░░ ░░░░░ ░░▒▒░▒░░ ░ ░░░░░░░░░░░░░   ░░░░░░░░▒▒▒▒░░░░░ ░░░░░░░░░░░░ ░░░░░░░░░░░▒░░░░   ░░░░░░░░░░░░░░░░ ░░░░░░░░░░░ ░░░░░░░░░░░▒░░░░░   ░░░░░░░░░░░░░░░░░ ░ ░░░▒░░░░░ ░░░░░░░░▒░▒░░░░░░░   ░░░░░░░░░ ░ ░░░░░░░░░░ ░ ░░░░▒▒▒▒▒░░░░ ░░░▒▒▒▒▒▒░░░░░░░░░░   ░ ░░░░░░░▒▒▒▒░░▒▒▒░▒▒▒▒▒░▒░░░ ░░░░▒▒▒▒▒▒▒▒░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░▒░▒▒▒▒▒▒░░░░░░ ░   ░░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░ ░░░▒▒░▒▒▒▒▒▒░░░░   ░░▒▒▒▒▒▒▒░░░░░░░░░░ ░░░░░░░▒▒▒▒░░░   ░░░░░░░░░░░ ░░░░░░░░░░   press ⏎ to skip ░░░░░  [?7h[?25l[?7l ✧ ✦ ✧ ████████████████████████   ████████████████████████ ✧ ✦   ✦ ████ ████ ·   ████ ████ · ·   ████ ████   ✦ ████ ████   ▒▒▒▒ ████ · ·   ████   ░░░░░░░░░ ░░▒▓▓▓█▓▓▓▒▒▒░ ░░░▒▒▒▓▓▓▓▒▒▒▒▒▒░░░░░░░▒▒▒▒▒▓▓▒▒▒░░░ ░░░░▒▒▒░░░   ░░░░░░░░░░ ░░▒▒▒▓▓▓▓▓▒▒░░ ░░░▒▒▓▓▓▓▓▓▓▒▒▒░░░ ░░▒▒▓▓▓▓▓▒▒░░░ ░░░▒░░░░░   ░░░░░░░░ ░░░▒▒▒▓▓▒▒▒▒░░░ ░░░▒▒▒▓▒▒▒▒▒▒░░ ░░▒▒▓▓▓▓▓▓▒▒░░ ░░░░░░░ ░   ░░░▒░░░░░ ░░░▒▒▓▓▒▒▒▒▒░░░░░▒▒▒▒▒▒▒▒▒░░░░ ░░▒▒▓▓▓▓▓▓▒▒░░ ░░░░ ░   ░░░░▒▒▒▒░░░ ░░▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░▒▒▓▓█▓▓▓▓▒░░ ░ ░   ░░░░░░░▒░░░░░ ░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░ ░░▒▒▓▓██▓█▓▓▒▒░░ ░ ░░░░   ░░░░░░░░░░░░ ░░▒▓▓█████▓▓▓▒▒▒▒▒▒▒▒▒░░░░░░░▒▒▓▓████▓▓▒▒░░ ░░░░░░░░   ░░░░░░░░░░ ░▒▒▓███████▓▓▒▒▒▒▒▒▒▒▒░░░░▒▒▓▓█████▓▓▒▒░░ ░░░░░░░░   ░░░░░░░░░░░ ░░▒▒▓▓████▓▓▒▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓████▓▓▓▒░░ ░░░░░░░░   ░░░░░░░ ░ ░░▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓██▓▓▓▓▒░░ ░░░░░░░░░   ░░░░ ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒░░▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒░░░ ░░░░░░░▒░░░░   ░ ░ ░░░░░ ░░░░░░░░░░░▒░▒▒▒▒▒▓▓▓▒▒▒▒░ ░░░░░░░░░░░░░   ░░ ░░░ ░░░░░░░░ ░░░░░░░░░░░▒▒▒▒▒▒▒▒░░ ░ ░░░░░░░▒░░░░   ░░░░░░░░░░░░▒░░░░ ░░░ ░ ░░▒▒▒▒▒░░ ░ ░░░░░░░░░░░░░   ░░░░░░░░▒▒▒▒░░░░░ ░░░░░░░░░░░░ ░ ░░░░░░░░░▒░░░░   ░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░ ░░░░░░░░░░░▒░░░░░░   ░░░░░░░░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░▒░▒░░░░░░░   ░░░░░░░░░ ░░░░░░░░░░░ ░░░░▒▒▒▒░░░░░ ░░░▒▒▒▒▒▒░░░░░░░░░░   ░░ ░░░░░░░░▒▒░░░░▒▒░▒▒▒▒▒░▒░░░ ░ ░░▒░▒▒▒▒▒▒░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░ ░░░░▒▒▒▒▒▒▒░░░░░ ░   ░░░▒▒▒▒▒▒▒▒▒▒▒░░░░▒░░░░ ░░░░░▒▒▒▒▒▒░░░░░░   ░░▒▒▒▒▒▒▒░░░░░░░░░░ ░░░░░░░▒▒▒▒░░░   ░░░░▒▒░░░░░ ░░░░▒░░░░░░   ░░ press ⏎ to skip ░░░░░  [?7h[?25l[?7l ✧ ✦ ✧ ████████████████████████   ████████████████████████ ✧ ✦   ████ ████   ✦ ████ ████   ▒▒▒▒ ████ · ·   ▒▒▒▒ ████ ✦   ░░░░░░░ ░░▒▒▓▓▓█▓▓▓▒▒░░ ░░▒▒▒▓█▓▓▓▒▒▒▒▒▒░░░░░░▒▒▒▒▒▓▓▒▒▒░░░ ░░░░▒▒▒░░░░   ░░░░░░░░ ░░▒▒▓▓▓▓▓▓▒▒░░ ░░░▒▒▓▓▓▓▓▓▓▒▒▒░░░ ░░░▒▒▓▓▓▓▒▒▒░░ ░░░░░░░░░   ░░░░░░░░ ░░▒▒▒▓▓▓▒▒▒▒░░░ ░░░▒▒▒▓▒▒▒▒▒▒░░░ ░░▒▒▒▓▓▓▓▓▒▒▒░░ ░░░░░░░ ░   ░░░▒░░░░░ ░░░▒▒▒▓▓▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒░░░░ ░░▒▓▓▓▓▓▓▓▒▒░░ ░░░ ░   ░░░░▒░░▒░░░ ░░▒▒▒▒▒▒▓▓▒▒▒▒░▒▒▒▒▒▒▒▒▒░░░░ ░▒▒▓▓█▓▓▓▓▒▒░░ ░ ░   ░░░░░░▒░░░░░ ░░▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░▒▓▓████▓▓▒▒░░ ░ ░░░░   ░░░░░░░░░░░░ ░░▒▓▓████▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒▓▓▓▓██▓▓▓▒▒░░ ░░ ░░░░░░   ░░░░░░░░░░ ░▒▒▓██████▓▓▓▒▒▒▒▒▒▒▒▒░░░░░▒▒▓▓████▓▓▒▒░░░ ░░░░░░░   ░░░░░░░░░░░ ░░▒▒▓█████▓▓▒▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓████▓▓▓▒▒░░ ░░░░░░░░   ░░░░░░░ ░ ░░▒▒▓▓█▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓████▓▓▒▒░ ░░░░░░░░   ░░░░░ ░ ░░░▒▒▒▒▓▒▒▒▒▒▒▒░░▒▒▒▒▒▒▓▓▓▓▓▓▓▓▒▒░░ ░░ ░░░░▒░░░░   ░ ░ ░ ░ ░░░ ░░░░▒▒░░░░░░▒░▒▒▒▒▒▓▓▓▓▓▒▒░ ░░░░░░░░░░░░░   ░ ░ ░ ░░░░░░░░ ░░░░░░░░░░░▒▒▒▒▒▒▒▒░░ ░ ░░░░░▒░░░░   ░░ ░░░░░░░░░░░░░░ ░░░ ░░░░▒▒▒▒▒░░ ░░░░░░░░░░░░░░░   ░░░░░░░░▒▒▒▒░░░░ ░░░░░░░░░░░░░ ░ ░░░░░░░░░▒░░░░   ░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░ ░░░░░░░░░▒░░░░░░   ░ ░░░░░░░░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░▒░▒░░░░░░░   ░░░░░░░░░░░░░░░░░░░ ░░░░▒░▒░░░░░ ░░░▒▒░▒░░░░░░░░░░   ░░░░ ░ ░░░░░░░░░░░░░░░░▒░▒▒▒▒▒░░░░░ ░░░░░▒▒▒▒▒░░░░░░   ░░░░▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒░░░ ░ ░░░▒▒▒▒▒▒▒░░░░░ ░   ░░░░▒▒▒▒▒▒▒▒▒▒▒░▒▒░▒░░░░ ░░░░░▒▒▒▒▒▒░░░░░░   ░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░▒▒▒▒▒░░░░   ░░▒▒▒▒░░░░░ ░ ░ ░ ░░░░▒░░░░░░░   ░░░░ press ⏎ to skip ░░░░░░░  [?7h[?25l[?7l ✧ ✦ ✧ ████████████████████████   ████████████████████████ ✧ ✦   ░░░░░░░ ░░▒▒▓█▓█▓▓▓▒▒░░ ░░▒▒▒▓█▓▓▓▒▒▒▒▒▒░░░░░░░▒▒▒▒▓▓▒▒▒░░░ ░░░░▒▒░░░░   ░░░░░░░ ░░▒▒▒▓▓▓▓▓▓▒▒░░ ░░░▒▒▓▓▓▓▓▓▓▓▒▒░░░░ ░░░▒▒▓▓▓▓▒▒▒░░░ ░░░░░░░░░   ░░░░░░░░ ░▒▒▒▓▓▓▓▒▒▒░░ ░░░▒▒▒▓▒▒▒▒▒▒░░░ ░▒▒▒▓▓▓▓▒▒▒░░ ░░░░░ ░   ░░░░░░░░░ ░░░░▒▒▓▓▓▒▒▒░░░ ░ ░░▒▒▒▒▒▒▒▒▒░░░ ░░▒▒▓▓▓▓▓▓▓▒▒░ ░ ░   ░░░░▒░░░░░ ░░▒▒▒▒▒▒▒▒▒░▒░░░▒▒▒▒▒▒▒▒░░░░ ░▒▒▓▓▓▓▓▓▓▓▒▒░░ ░ ░   ░░░░░░▒░░░░░ ░▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░▒▒▓▓█▓█▓▓▓▒▒░ ░ ░ ░   ░░░░░░░░░░░ ░░▒▒▓▓████▓▓▓▒▒▒▒▒▒▒▒▒▒░░░ ░░░░▒▒▓▓▓███▓▓▒▒▒░ ░ ░░░░░░   ░░░░░░░░░░ ░░▒▓▓██████▓▓▓▒▒▒▒▒▒▒░▒░░░░░▒▒▓▓█████▓▓▒▒░░ ░░░░░░░ ░   ░░░░░░░░░░░ ░░▒▒▓▓█████▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓█████▓▓▒▒▒░ ░░░░░░░   ░░░░░░░ ░░▒▓▓▓█▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓████▓▓▒▒▒░░ ░░░░░░░░░░   ░░░░░ ░ ░░░▒▒▒▒▓▓▒▒▒▒▒▒░░▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▒▒░ ░ ░░░░░░░░░   ░░ ░ ░ ░░░░░▒▒▒▒▒░░▒░░░▒▒▒▒▒▒▒▓▓▓▓▓▒▒░░ ░░░░░░░░░░   ░ ░ ░░░░░░░ ░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒░░ ░ ░░░░░▒░░░░░   ░░ ░░░░░░░░░░░░░ ░░░ ░░░░▒▒▒▒▒▒░░ ░░░░░░░░░░░░░░░   ░░░░░░░░░░▒▒░░░░ ░░░░░░░░░▒░░░ ░░░░░░░░░▒░▒░░   ░░░░░░░░░░░░░░░░ ░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░▒░▒░░░░░░░░   ░░░░░░░░░░░░░░░░░░░ ░░░░░░▒░░░░░ ░░░▒▒░▒░░░░░░░░░░   ░░░░░ ░░ ░░░░░░░░░░░░░░░░░░░▒▒▒▒▒░░░░░ ░░░░░▒▒▒▒░░░░░░░░   ░ ░░░░▒▒▒▒▒░░░▒▒▒▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒░░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒░░░░ ░░░░░▒▒▒▒▒▒▒░░░░░   ░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░▒▒▒▒▒▒░░░ ░   ░░░▒▒▒▒░░░░░ ░░░░ ░░ ░░░░▒░░░░░░░   ░ ░░░░ press ⏎ to skip ░░░░░░░  [?7h[?25l[?7l ✧ ✦ ✧ ████████████████████████   ████████████████████████ ✧ ✦   ░ ░░░░░░ ░▒▒▓▓███▓▓▓▒░░░ ░░▒▒▒▓███▓▓▒▒▒▒▒░░░░░░░▒▒▒▒▓▓▒▒▒▒░░ ░░░░▒░▒░░   ░░░░░░ ░░▒▓▓▓▓▓▓▓▓▒░░ ░░░▒▒▓▓▓▓▓▓▓▓▒▒▒░░░ ░ ░▒▒▒▓▓▓▓▓▒░░░ ░░░░░░░░░░   ░░░░░ ░ ░▒▒▒▒▓▓▓▓▒▒▒░ ░░░▒▒▒▓▒▒▒▓▒▒▒░░ ░░▒▒▓▓▓▓▓▒▒░░ ░░░░░░░   ░░░░░░░░ ░░▒▒▒▒▓▓▓▒▒░░░ ░░▒▒▒▒▒▒▒▒▒░▒░░ ░░▒▒▓▓▓▓▓▓▒▒░░ ░ ░   ░░░░░░░░░ ░▒▒▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒░░░░ ░▒▒▓▓▓▓▓▓▓▒▒░░ ░   ░░░░░░░░░░░░ ░░▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░▒▓▓▓▓██▓▓▒▒░ ░ ░   ░░░░░░░░░░░ ░░▒▒▓▓███▓▓▓▒▒▒▒▒▒▒▒▒▒░░░ ░░▒▒▓▓▓███▓▒▒▒░ ░░░░░░   ░░░░░░░░░░░ ░▒▒▓▓█████▓▓▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▓▓████▓▓▒▒░░ ░░░░░░░ ░   ░░░░░░░░░░░ ░░▒▒▓▓█████▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓████▓▓▒▒░ ░░░░░░░   ░░░░░░░ ░░░▒▓▓▓▓██▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓███▓▓▓▒▒░░ ░░░░░░░░░░   ░░░░░░ ░ ░░░▒▒▒▓▓▓▓▓▓▒▒▒▒░▒▒▒▒▒▒▒▒▓▓▓▓▓█▓▓▒▒▒░ ░░░░░░░░   ░ ░░ ░ ░░░░░▒▒▒▒▒░░▒░░░▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒░░ ░░░░░░░░░░   ░ ░░░░░░░ ░░░░░░░░░░░░░░▒▒▒▒▒▓▓▒▒▒░░ ░░░░░░░░░░░   ░░ ░░░░░░░░░░░░░ ░░ ░░░ ░░░▒▒▒▒▒▒▒░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░▒▒░░░ ░ ░░░░░░░▒▒░░ ░░░░░░░░░░░▒░░░   ░░░░░░░░░░░░░░░░░ ░░░ ░░░░░░ ░░░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░░ ░░░░░░░░░░ ░░░░░▒░░░░░░░░░░   ░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░ ░░░░░▒░░░░░░░░░░░░   ░░░░░ ░░░░░░░░░░░░░░░░░ ░░░░▒▒▒▒░░░░░ ░░░░▒▒▒▒░▒░░░░░░   ░ ░ ░░░░▒░▒░░░░░▒▒░▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒░░░░░░░   ░░░░░▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒░░░░ ░░░░░▒▒▒▒▒▒▒▒░░░░ ░   ░░░░▒▒▒▒▒▒▒▒▒░░░░▒░░░░░░░░░░░▒▒▒▒▒▒▒░░ ░   ░░▒▒▒▒▒░░░░░░░░░░░░░ ░░░░▒░░░░░░░   ░░░░░░ press ⏎ to skip ░░░░░░░░  [?7h[?25l[?7l ░░░░░░░░░ ░▒▒▓████▓▓▒▒░░░ ░░▒▒▓▓███▓▓▒▒▒▒▒░░░░░░░░░▒▒▓▓▒▒▒▒░░ ░░░░▒░▒░░   ░░░░░ ░░▒▒▓▓▓▓▓▓▓▒▒░░ ░░░▒▒▓▓▓▓▓▓▓▓▓▒▒▒░░ ░▒▒▒▒▓▓▓▓▒▒░░ ░░░░░░░░   ░░░░░ ░▒▒▓▓▓▓▓▓▒▒░░ ░░░▒▒▒▓▒▓▓▓▒▒▒▒░░ ░░▒▒▓▓▓▓▓▓▒▒░ ░░░░░░   ░░░░░░░ ░▒▒▒▓▒▓▓▒▒░░░ ░░▒▒▒▒▒▒▒▒▒▒▒░░░ ░▒▒▓▓▓▓▓▓▒▒░░ ░ ░   ░░░░░░░░░ ░░▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒░░░░ ░░▒▓▓▓▓▓▓▓▒▒░░ ░   ░░░░░░░░░░░░ ░░░▒▒▒▓▓▓▒▒▒▒▒░░▒▒▒▒▒▒▒░░░░ ░▒▒▓▓▓██▓▓▓▒▒░ ░   ░░░░░░░░░░░ ░▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░▒▒▓▓▓███▓▓▒▒░░ ░░░ ░░   ░░░░░░░░░░ ░▒▒▓▓███▓█▓▓▒▒▒▒▒▒▒▒▒░░░░░ ░░▒▒▒▓█▓███▓▓▒▒░ ░░░░░░░ ░   ░░░░░░░░░░░ ░░▒▒▓▓█████▓▓▒▒▒▒▒▒▒▒▒▒░░░▒▒▒▓▓▓▓████▓▒▒░░ ░░░░░░   ░░░░░░ ░░░▒▓▓▓▓██▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓█▓███▓▒▒░░ ░░░░░░░░░░   ░░░░░░░ ░░▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓█▓▓▒▒▒░░ ░░░░░░░░░   ░░░░░ ░ ░░░░░▒▒▒▒▒▒▒░▒░░▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒░░░ ░░░░░░░░░░░   ░ ░░ ░░ ░░░░░░░░░░░░░░░▒▒▒▒▒▓▓▓▒▒▒░░░ ░░░░░░░░░░░░   ░░ ░░ ░░░░░░░░░ ░░ ░░░ ░░░▒▒▒▒▒▒▒▒░░░ ░░░░░░░░░░░░░   ░ ░░░░░░░░░▒░░░ ░ ░░░░░░░▒▒░░░ ░ ░░░░░░░▒░░░   ░░░░░░░░░░░░░░░░ ░░░ ░░░░░░ ░░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░ ░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░░░ ░░ ░░░░░▒▒░░░░░░ ░░░░░▒▒▒░▒░░░░░░   ░░ ░ ░░░░▒░▒░░░░░░░░▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒░▒░░░░░░   ░░░░░▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒░░░░ ░░░░░░░▒▒▒▒▒▒░░░░░░   ░░░░▒▒▒▒▒▒▒░▒░▒░░▒░░░░░░░░░░░▒▒▒▒▒▒▒░░░░   ░░▒▒▒▒▒░░░░░░░░░░░░░ ░░░░▒░▒░▒░░░░░   ░░░░░░░ press ⏎ to skip ░░░░░░░░░  [?7h[?25l[?7l ✧   ✦ ✦ ✧     · · · ·   ✦ ✧ · ✧   · ████████████████████████ ✧   ████████████████████████ ✧   ✦ ✧ ✦ ████ ████ ·   ████ ████   · ✧████ ████ · ✧ ✦   ✧ · ████ ████   ✧ ▒▒▒▒ ████   ▒▒▒▒ ████ ✧   · ████   ░░░░░░░ ░░▒▒▓████▓▓▒▒░░░ ░░▒▒▓▓███▓▓▒▓▒▒▒░░░░░░░░░▒▒▒▓▒▓▒▒▒░░ ░░▒░▒░░   ░░░░ ░░▒▒▓▓██▓▓▓▒▒░ ░░░▒▒▓▓▓▓█▓▓▓▓▒▒▒░░ ░▒▒▒▓▓▓▓▒▒░░ ░░░░░░░░░   ░░░░ ░░▒▒▓▓▓▓▓▓▒░░░ ░░░▒▒▒▓▓▓▓▓▓▒▒▒░░ ░░░▒▓▓▓▓▓▓▒▒░░░ ░░░░░░   ░░░░░ ░ ░░▒▒▒▓▓▓▓▒▒░░ ░░▒▒▒▒▒▒▒▒▒▒▒░░░ ░▒▒▓▓▓▓▓▓▒▒░░ ░ ░   ░░░░░░░░░ ░░░▒▒▒▒▒▒▒▒▒░░ ░░░░░░▒▒▒▒▒▒░▒░░░ ░░▒▒▓▓▓▓▓▓▒▒▒░ ░   ░░░░░░░░░░░ ░░▒▒▒▒▓▓▒▒▒▒░░░░░▒▒▒▒▒▒░░░░ ░░▒▓▓▓███▓▓▒▒░ ░   ░░░░░░░░░░░ ░░▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░▒▒▓▓███▓▓▓▒▒░ ░ ░░   ░░░░░░░░░░ ░░▒▒▓▓███▓▓▓▓▒▒▒▒▒▒▒▒▒░░░░ ░░▒▒▓▓▓███▓▓▒▒▒░ ░░░░░░░░░   ░░░░░░░░░░░ ░▒▒▓▓▓███▓█▓▓▒▒▒▒▒▒▒▒▒▒░░░░░▒▒▓▓▓████▓▓▒▒░░ ░░░░░░   ░░░░░░░ ░░▒▒▓▓▓███▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓███▓▓▓▒░░ ░░░░░░░   ░░░░░░░ ░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓█▓▓▓▒▒░░ ░░░░░░░░░░   ░░░░░ ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒░░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒░░ ░ ░░░░░░░░   ░ ░░░░▒▒░░░░░░░░▒▒▒▒▒▒▓▓▓▓▒▒▒░░ ░░░░░░░░░░░░   ░ ░ ░░░░░░░ ░░░░░░░░░░░▒▒▒▒▒▒▒▒░░░ ░░░░░░░░░░░░   ░ ░░░░░░░░░░░ ░ ░░░░░░░▒▒▒░░░ ░░░░░░░░░░░░   ░░░░░░░░░░░░░░ ░░░ ░░░░░░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░░ ░░░░ ░░░░░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░░░ ░░░░░▒░░░░░░░ ░░░▒▒▒░▒░░░░░░░░   ░░░ ░ ░ ░░░░░░░░░░░░░░░░░▒▒▒▒▒▒░░░ ░░▒░▒▒▒░▒░░░░░░   ░░░░░▒▒▒▒░░░▒░▒▒▒▒▒▒▒░░░░ ░░░░░░▒▒▒▒▒▒▒░░░░░   ░░░░▒▒▒▒▒▒▒░▒░▒░▒▒░░░░░░░░░░░░▒▒▒▒▒▒░░░░   ░░░▒▒▒▒▒░▒░░░░░░░░░░░ ░░░░▒░▒▒▒░░░░░  [?7h[?25l[?7l ████████████   ██ ██   Setup step 1 of 5   ░░░░░░░ ░░▒▓▓████▓▓▒▒░░░ ░░▒▒▓▓███▓▓▓▓▒▒▒░░░░░░░░░▒▒▒▒▒▓▒▒▒░░░ ░░░░▒░░░   ░░░ ░░▒▓▓▓██▓▓▓▒░░ ░░░▒▒▓▓▓▓██▓▓▓▒▒▒░░░ ░░▒▒▓▓▓▓▒▒▒░░ ░░░░░░░░░   ░░ ░░▒▓▓▓▓▓▓▒▒░░ ░░░▒▒▒▓▓▓▓▓▓▓▒▒▒░ ░░▒▒▓▓▓▓▓▓▒▒░░ ░░░ ░   ░░░░ ░ ░▒▒▒▒▓▓▓▓▒▒░ ░░▒▒▒▒▒▒▒▒▒▒▒▒░░ ░▒▒▓▓▓▓▓▓▒▒░░ ░ ░   ░░░░░░░░░ ░░░░▒▒▒▒▒▒▒░░░ ░░░░░▒▒▒▒▒▒░▒░░░ ░▒▒▓▓▓▓▓▓▒▒░░░   ░░░░░░░░░░░ ░░░▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▒▒░░░░░ ░▒▒▓▓███▓▓▒▒░░ ░   ░░░░░░░░░░░ ░░▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░▒▒▓█▓█▓▓▓▒▒░ ░ ░░   ░░░░░░░░░░ ░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░ ░░▒▒▒▓▓███▓▓▒▒▒░ ░░ ░░░░░░   ░░░░░░░░░░░ ░░▒▒▓▓▓██▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░░▒▒▓▓▓███▓▓▓▒░░ ░░░░░░░   ░░░░░░░░ ░░▒▒▓▓▓███▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓███▓▓▓▒▒░░ ░░░░░░░   ░░░░░░░ ░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓█▓▓▓▓▒▒░░ ░░░░░░░░░░   ░░░░░ ░ ░░░▒▒▒▒▓▓▒▒▒▒▒▒░░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒░ ░ ░░░░░░░░   ░░░▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▓▓▓▓▒▒▒▒░ ░░░░░░░░░░   ░ ░░░░░ ░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░ ░░ ░░░░░░░░░░░   ░ ░░░░░░░░░░░ ░ ░░░░░▒░▒▒▒▒░░ ░░░░░░░░░░░░░   ░ ░ ░░░░░░░░░░░░ ░░░ ░░░░░░░░ ░░░░░░░░░░░░░░ ░   ░░░░░░░░░░░░░░░░░ ░ ░░ ░░░░░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░░ ░░░░▒░░░░░░░ ░░░░▒▒░▒░░░░░░░░   ░░░░░ ░░░ ░░░░░░░░░░░░░░░░░░▒░▒▒▒░░░ ░░░░▒▒▒░▒▒░░░░░░░   ░░░░░▒▒▒▒░░░▒░▒▒▒▒▒▒▒░░░░ ░ ░░░▒▒▒▒▒▒░░░░░   ░░░░░▒▒▒▒▒▒▒░▒░▒░▒▒░░░░░░░░░░░░▒▒▒▒▒▒░░░░   ░░░▒▒▒▒▒░▒░░░░░░░░░░░ ░░░░░░▒▒▒▒░░░░░   ░░░░▒▒░░ press ⏎ to skip ░░░░▒░░░░░  [?7h[?25l[?7l ██ ██   ▒▒ ██   omp   Sign in to your providers   Sign in to one or more providers. Press ⎋ when you're done.   ░░░░░░░ ░▒▒▓▓████▓▓▒▒░░░ ░░▒▒▓▓███▓▓▓▓▒▒▒▒░░░░░░░░░░▒▒▒▓▒▒▒▒░░ ░░░░░░░   ░ ░░░ ░▒▒▓████▓▓▒▒░░ ░░░▒▒▓▓▓████▓▓▓▒▒▒░░ ░░░▒▒▒▓▓▒▒▒▒░░ ░ ░░░░░░   ░ ░░░▒▓▓███▓▓▒▒░ ░░░▒▒▒▓▓▓▓▓▓▓▒▒▒░░░ ░░▒▒▓▓▓▓▓▒▒░░ ░ ░ ░   ░ ░░░▒▓▓▓▓▓▓▒▒░ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒░ ░░▒▒▓▓▓▓▓▓▒▒░░ ░   ░ ░ ░ ░▒▒▒▒▓▓▒▒▒░░░ ░░▒▒▒▒▒▒▒░▒░░░░ ░░▒▒▓▓▓▓▓▓▒▒░░░   ░░░░░ ░░░▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒░░░░░░ ░▒▒▒▓▓██▓▓▒▒░░   ░░░░░░░░░░ ░░▒▒▒▒▒▒▒▒▒▒░░▒░▒▒▒▒▒▒░░░░░ ░░▒▒▓▓██▓▓▓▒▒░░ ░░ ░   ░░░░░░░░░░ ░░▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒░░░░░ ░░░▒▒▓▓███▓▓▓▒░░░ ░ ░░   ░░░░░░░░░░░ ░▒▒▒▓▓▓█▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▓▓▓███▓▓▒▒░░ ░░░░░░░   ░░░░░░░░ ░░░▒▒▓████▓▓▓▒▒▒▒▒▒▒▒▒▒▒░▒░░▒▒▒▓▓▓▓█▓▓▓▒▒░░ ░░░░░░░   ░░░░░░░░ ░░▒▒▓▓▓█▓█▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓█▓█▓▓▓▒▒░░ ░░░░░░░░   ░░░░░░ ░░▒▒▒▒▓▓▓▒▓▒▒▒▒░░▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒░ ░░░░░░░░░░   ░░░ ░░░░▒▒▒▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒░░ ░ ░░░░░░░░░░   ░░░░ ░ ░░░░░░░░░░░░░▒▒▒▒▒▒▓▓▒▒▒▒░░ ░░░░░░░░░░░   ░ ░░░░░░░░ ░░░ ░░░░░▒▒▒▒▒▒░░░ ░ ░░░░░░░░░░░   ░ ░░░░░░░░░░ ░░░░░░░░ ░ ░░░░░░░░░░░░   ░░ ░░░░░░░░░░░░░░ ░░ ░░░░░░░ ░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░ ░░░░░ ░░░ ░░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░ ░░░▒░▒░░░░░░░░░   ░░░░░░░ ░░░ ░░░░░░░ ░ ░░░░░▒░░░░░░░ ░░▒▒▒░▒▒░░░░░░░   ░ ░░░░░░░░░░░░░░▒▒░▒▒░░░░░░ ░░░░▒▒▒▒▒░░░░░░   ░░░░░▒▒░░░▒░░▒░▒░▒▒░░░░░░ ░░ ░░░░▒▒▒▒░▒░░░░   ░░░▒▒▒▒▒▒░▒░░░░░░░░░░░ ░ ░░░░▒▒▒▒▒░░░   ░░░░▒▒░░ press ⏎ to skip ░░░░▒░▒░░░░  [?7h[?25l[?7l ██     Pick a provider to sign in — you can connect more than one.   │ ❯ ChatGPT Plus/Pro (Codex Subscription) █ │   │ Anthropic (Claude Pro/Max) │ │   ░░░░░ ░▒▓▓███▓▓▓▒▒░░ ░░░▒▒▓▓▓█████▓▓▓▒▒▒░ ░░▒▒▒▒▓▓▒▒▒░░ ░░░░░░░   ░ ░░▒▒▓▓███▓▓▒░░ ░░░▒▒▒▓▓█▓▓▓▓▓▒▒▒░░ ░▒▒▓▓▓▓▓▒▒░░░ ░ ░ ░   ░░▒▒▓▓▓▓▓▓▒▒░ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░ ░░▒▒▓▓▓▓▓▒▒░░ ░   ░ ░ ░░▒▒▒▓▓▓▒▒▒░ ░░▒▒▒▒▒▒▒░▒▒▒░░ ░▒▒▒▓▓▓▓▓▒▒░░░   ░░░░ ░░░▒▒▒▒▒▒▒▒░░ ░░░░▒▒▒▒▒░░░░░░ ░░▒▒▓▓██▓▓▓▒░░   ░░░░░░░░░ ░░▒▒▒▒▒▒▒▒▒▒░░░░░▒▒▒▒▒▒░░░░░ ░░▒▓▓██▓▓▓▒▒░░ ░░ ░   ░░ ░░░░░░░ ░░▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░░ ░░▒▒▒▓▓██▓▓▓▒▒░░ ░ ░░   ░░░░░░░░░░░ ░▒▒▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▓▓███▓▓▓▒▒░ ░ ░░░░░░   ░░░░░░░░ ░░▒▒▓▓█▓▓█▓▓▒▒▒▒▒▒▒▒▒▒▒▒░▒░░░▒▒▓▓▓▓█▓▓▓▓▒▒░░ ░░░░░░░   ░░░░░░░░ ░░▒▓▓▓██▓█▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓█▓█▓▓▓▒▒░░ ░░░░░░░   ░░░░░░ ░░▒▒▓▓▓▓▓▓▓▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▓▓▓▓█▓▓▒▒░░ ░░░░░░░░   ░░░░ ░░░░░▒▒▒▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒░░ ░░░░░░░░░   ░ ░░░░░░░░░░░░░░▒░▒▒▒▒▒▒▓▓▒▒▒▒░░ ░░░░░░░░░░░   ░░░░░ ░░░ ░░░░░▒▒▒▒▒▒▒▒░░ ░ ░░░░░░░░░░   ░░░░░░░░ ░░░░░░▒░░░░ ░ ░░░░░░░░░░░░   ░░ ░░░░░░░░░░░░░ ░░░░░░░ ░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░ ░░░ ░ ░░░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░ ░░░░░░░░ ░░░░ ░░░░░▒░░░░░░░░░   ░░░░░░░ ░░░░░░░░░ ░ ░░░░▒░░░░░░░ ░░░░░░▒▒░░░░░░░   ░░░░░▒▒░░░▒░░▒░▒▒▒▒░░░░░░ ░░░▒▒▒▒▒▒░░░░░   ░░░▒▒▒▒▒▒░░░░░░░▒░░░░░ ░ ░░░░░▒▒▒▒░░░░░   ░░░▒░▒▒▒░ press ⏎ to skip ░░░░▒░▒▒░░░  [?7h[?25l[?7l     ╭─ Select provider to login ───────────────────────────────────────────────────────────────────────────────────╮   │ Z.AI (GLM Coding Plan) │ │   │ Z.AI (GLM Coding Plan · Sign in) │ │   ░░░▒▓▓▓▓▓▓▓▒░░ ░▒▒▒▒▒▒▓▓▓▒▒▒▒▒▒░ ░▒▒▓▓▓▓▓▒▒▒░ ░   ░░▒▒▒▒▓▓▓▒▒░░ ░░▒▒▒▒▒▒▒▒▒▒▒░░ ░░▒▒▓▓▓▓▓▓▒▒░░   ░ ░░▒▒▒▒▒▒▒▒▒░░ ░ ░░▒▒▒▒▒░░░░░░░ ░▒▒▓▓███▓▓▒▒░░   ░░░░░ ░░░▒▒▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒░░░░░ ░░▒▒▓▓▓▓▓▓▓▒░░░ ░░ ░   │ Devin │ │   ░ ░░░░░░░░░ ░░▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░░░░ ░░▒▒▒▓▓██▓▓▓▒▒░░ ░░░░░░░   ░░░░░░░░ ░▒▒▒▓▓▓▓▓█▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░░▒▒▒▒▓▓█▓██▓▓▒▒░░ ░ ░░░░░   ░░░░░░░░ ░▒▒▓▓███▓█▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓█▓▓▓▒▒▒░░ ░░░░░░░   ░░░░░░ ░▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▓▓▓▓█▓▓▓▒▒░ ░░░░░░░   ░░░░ ░░░░▒▒▒▒▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒░░ ░░░░░░░░░   ░░░░░░▒▒▒░░░░░░▒▒▒▒▒▒▒▓▓▓▓▒▒▒░░ ░░░░░░░░░   ░░░░ ░░ ░ ░░░░░░░░▒▒▒▒▒▒▒▒▒░░░ ░ ░░░░░░░░░   ░░░░░░░░ ░░░░░░▒░░░░ ░░░░░░░░░░░░░   ░░ ░░░░░░░░░░░░ ░░░░░░░ ░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░ ░░░ ░░░░ ░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░ ░░░░░░░ ░░░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░ ░ ░░░░▒░░░░░░░ ░ ░░░░▒▒░░░░░░░   ░ ░░ ░ ░░░░░░░░░ ░░░░░░░▒░░░░░░ ░░░▒▒▒▒░░░░░░ ░░   ░░░░░░▒░░░░░░░░▒▒▒▒░░░░░░ ░░░▒▒▒▒▒▒░░░░░░   ░░░▒▒▒▒▒▒░░░░░░░▒░░░░░ ░░░░░▒▒▒▒░░░░░   ░░░▒░▒▒▒░ press ⏎ to skip ░░░░▒▒▒▒░░░ ░  [?7h[?25l[?7l │ Kimi Code │ │   │ OpenRouter │ │   │ GitHub Copilot │ │   │ Cursor (Claude, GPT, etc.) │ │   │ Antigravity (Gemini 3, Claude, GPT-OSS) │ │   │ Type to search │   ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────╯   ░░░░░░ ░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓█▓▓▓▓▒░░ ░░░░░░░   ░░░░ ░░░▒▒▒▒▓▓▒▒▒▒░░░░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒▒░ ░░░░░░░░░   ░ ░░░░░▒░▒▒▒░░░░░░▒▒▒▒▒▒▒▓▓▓▓▒▒▒▒▒░ ░░░░░░░░░   ░░░░ ░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒░░░ ░░░░░░░░░░   ░░░░░░ ░ ░░░░░▒▒░▒░░░ ░░░ ░░░░░░░░░   ░░ ░░░░░░░░░░░ ░░░░░░░ ░ ░░░░░░░░░░░░░   ░░░░░░░░░░░░░ ░ ░ ░░░░ ░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░ ░ ░░░ ░ ░ ░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░ ░░░░░░░░░░░░ ░░░░░▒░░░░░░░░░   ░ ░░ ░ ░░ ░░░░░░░░░ ░░░░░░░░░░░░░ ░░░▒░▒▒░░▒░░░░░░   ░░░░░░░░░░░░░░░▒▒▒▒░░░░░░ ░░░░▒▒▒▒░▒░░░░░   ░░░▒▒▒▒▒▒░░░░░░░▒░░░░░░ ░░░░░▒▒▒▒░▒░░░   ░░░░▒░▒▒▒░ press ⏎ to skip ░░░░▒░▒▒▒░░░░  [?7h[?25l[?7l     ░ ░░ ░░░▒▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▓▓▓▓▓▒▓▒▒░░ ░░░░░░░░   ░ ░░░░ ░░░░░░░░░░░░▒░▒▒▒▒▒▒▒▒▒▒▒░░ ░░░░░░░░░░       ░░░░░░░░░░░░░ ░░░░ ░ ░░░░░░░░░░░░   ░ ░░░░░░░░░░░░░░ ░ ░░░ ░ ░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░ ░ ░░░░░░░░░░░░ ░░░░░░░░░░░░░░░   ░░░░░░ ░ ░░ ░░░ ░ ░░░░░░░░░░░░ ░░░░▒▒░▒▒░░░░░░     ░░░░░▒▒▒▒░░░░░░░░▒░░░░░░ ░░░░▒▒▒▒▒▒░░░   ░░░░▒░▒▒░░ press ⏎ to skip ░░░░▒░▒▒▒░░░░░  [?7h[?25l[?7l         ░░░░░░░░░░░░░░ ░ ░░░░░░░░░░ ░ ░░░░░░░░░░░░░░░   ░░░░░░ ░ ░░ ░░░ ░░░░░░░░░░ ░ ░░░▒▒░▒▒░░░░░░░░   ░░░░░▒▒░░░░░░░░░░▒░░░░░░ ░ ░░░░▒▒▒▒░░░░░   ↑/↓ select · ⏎ confirm · ⎋ skip · ⌃C exit setup  [?7h[?25l[?7l     ░░░░░░░░░░░░░░░░░▒░░░░░ ░░░░▒▒▒▒▒░░░░  [?7h[?25l[?7l  [?7h[?25l[?7l Setup step 2 of 5   Choose your default model   Search configured models and save the model used for new sessions.   Discovering available models…   🔍 >        No models available in this scope                      [?7h[?25l[?7l Type to search. ⏎ saves the highlighted model as your default.  [?7h[?25l[?7l Setup step 3 of 5   Choose glyph mode   Pick the row that renders cleanly in your terminal.   If a row shows boxes, tofu, or misaligned icons, pick another.   1 Nerd Font    󰉋  ╭─╮ ├─ ◆ ✔ ✖   ❯ 2 Unicode ✔ ✖ 📁 ⬢ ╭─╮ ├─ • ⠋ →   3 ASCII [ok] [x] > + [D] +-+ |-- * ->  [?7h[?25l[?7l Setup step 4 of 5   Choose composer shape   Pick the prompt and status line layout for your workflow.   Select a layout; live preview updates below. Press ⏎ to confirm.   Preview:    π > ⬢ no-model > 📁 …6-09-28/XXXXXXXXXXXXXXXXXXX/capture-cwd > ◫ 6.4K/? ⟲ ▶────────────────────────────────────   ╰─ Ask anything, edit files, run tools    ❯ 1 Status Band (Default) Flush soft-capped status band above a curved prompt, no frame   2 Rounded Box Status line embedded in top border, compact 2-line prompt   3 Claude Code Full-width horizontal rules above and below, status line at bottom   4 Pi Framed horizontal rules with status line at bottom   5 Borderless Clean prompt glyph with status line at bottom, no box borders   6 Top Rule Dock Single top rule with status docked onto it and below   7 Compact Field Filled one-row field with accent end caps   8 Accent Rail Filled one-row field anchored by a single accent rail  [?7h[?25l[?7l Setup step 5 of 5   Pick a theme   Move through the list to preview; ⏎ saves the highlighted choice.   Theme changes preview live. Nothing is saved until you press ⏎.   ⎋ skips this step     Preview   ✔ success ⚠ warning ✘ error accent   Status line    ⬢ sonnet │ ~/project │ ⎇ main +2 ◫ 42% │ 💲 0.18    Editor   ╭──────────────────────────────────────────────────────────────────────────────────────╮   │> Ask anything, edit files, run tools  │   │⏎ send · ⇧⏎ newline · / commands │   ╰──────────────────────────────────────────────────────────────────────────────────────╯     Match terminal Titanium in dark terminals, Light in light terminals   ❯ Titanium Default dark theme   Light Default light theme   Colorblind colors Adjust red/green contrast   ANSI-safe ASCII glyphs with the dark terminal theme   Browse all… Show every built-in and custom theme  [?7h[?25l[?7l ✦ · ·   ✦ ·   ✦   · · · ·   ✦ ✦   · ✦ ✦ ✦   ✦ · ✦ ✦✦   ·   · ✦ ✦ ✦   · ✦ ✦   ✦ · ✦   ✦ ✦ ✦ ✦ ·   ✦ · ✦ · ·   ·   ✦ ·   ████████████   ██ ██   ██ ██   ▒▒ ██   ██     ✔ Setup saved   Handing off to the normal CLI…     ━───────────────────────────────────────────────────────────────────────────────────────────────────────────────   ✦ · · ✦   · ·     ✦ ✦ ✦   ✦ · ✦ ✦ ·  · ✦ ✦ ·   ✦ ✦   ✦ ✦   · ·   ✦ ✦ ✦   · · ·   · ·  · ✦ ·   · ✦   ✦ · ·  [?7h[?25l[?7l ████████████   ██ ██   ▒▒ ██   ━━━─────────────────────────────────────────────────────────────────────────────────────────────────────────────  [?7h[?25l[?7l ████████████   ▒▒ ██   ██   ━━━━━━──────────────────────────────────────────────────────────────────────────────────────────────────────────  [?7h[?25l[?7l ████████████   ██ ██   ▒▒ ██   ██   ━━━━━━━━━───────────────────────────────────────────────────────────────────────────────────────────────────────  [?7h[?25l[?7l ████████████   ██ ██   ██ ██   ██   ━━━━━━━━━━━━━───────────────────────────────────────────────────────────────────────────────────────────────────  [?7h[?25l[?7l ████████████   ██ ██   ██ ██   ━━━━━━━━━━━━━━━━────────────────────────────────────────────────────────────────────────────────────────────────  [?7h[?25l[?7l ████████████   ██ ██   ▒▒ ██   ██   ━━━━━━━━━━━━━━━━━━━━────────────────────────────────────────────────────────────────────────────────────────────  [?7h[?25l[?7l ████████████   ▒▒ ██   ██   ━━━━━━━━━━━━━━━━━━━━━━━─────────────────────────────────────────────────────────────────────────────────────────  [?7h[?25l[?7l · · · ✦   ✦ ·   · ✦ ·   · · ✦ ✦   ✦ · ·   ✦ · ·   · ✦ ·   ✦ ✦   · · ✦·   ✦ ✦ ·   ✦   ✦ · ✦   ·   · · ·     ████████████   ██ ██   ▒▒ ██   ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━──────────────────────────────────────────────────────────────────────────────────────   · · ✦   ✦ ✦   · ✦   ✦ · · · ✦ ✦·   ✦   · ✦ ·   ✦ ·   · ✦   ✦ ✦   ✦ · ··   · ✦ · ✦   ✦ ✦ ·   · · ✦ ·   ✦ · ✦ ✦   ✦ ✦  [?7h[?25l[?7l ████████████   ██ ██   ██ ██   ▒▒ ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━───────────────────────────────────────────────────────────────────────────────────  [?7h[?25l[?7l ████████████   ██ ██   ▒▒ ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━────────────────────────────────────────────────────────────────────────────────  [?7h[?25l[?7l ████████████   ██ ██   ▒▒ ██   ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━────────────────────────────────────────────────────────────────────────────  [?7h[?25l[?7l ████████████   ██ ██   ▒▒ ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━─────────────────────────────────────────────────────────────────────────  [?7h[?25l[?7l ████████████   ██ ██   ██ ██   ▒▒ ██   ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━──────────────────────────────────────────────────────────────────────  [?7h[?25l[?7l ████████████   ██ ██   ██ ██   ▒▒ ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━──────────────────────────────────────────────────────────────────  [?7h[?25l[?7l   ✦ ·   ✦   ✦ ✦ ✦   ✦ ·   ✦ ✦ · ✦   ·   · · ✦     ·   ·   ✦ ✦   ✦   · ·   ████████████   ██ ██   ██ ██   ▒▒ ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━───────────────────────────────────────────────────────────────   · ·   · ·   ✦ ·· ✦   · ✦ ✦   ·   · ✦✦ ·   ✦   ✦ ✦ ✦ ✦   ✦ ✦ ✦·   ✦   ·   ✦   ✦ ✦   ✦ · ✦   ✦  [?7h[?25l[?7l ████████████   ██ ██   ██ ██   ▒▒ ██   ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━────────────────────────────────────────────────────────────  [?7h[?25l[?7l ████████████   ██ ██   ██ ██   ▒▒ ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━─────────────────────────────────────────────────────────  [?7h[?25l[?7l ████████████   ██ ██   ██ ██   ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━─────────────────────────────────────────────────────  [?7h[?25l[?7l ████████████   ██ ██   ██ ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━──────────────────────────────────────────────────  [?7h[?25l[?7l ████████████   ██ ██   ██ ██   ▒▒ ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━───────────────────────────────────────────────  [?7h[?25l[?7l ████████████   ▒▒ ██   ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━────────────────────────────────────────────  [?7h[?25l[?7l ████████████   ██ ██   ▒▒ ██   ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━─────────────────────────────────────────  [?7h[?25l[?7l · ·   ✦ ✦ ✦ · · ·     · ·   · · ✦ ·   ✦ ✦ · ✦   · ✦ ·   ✦ ✦ ·· ·   ✦ · ·   · ·   ✦ ✦ ✦ ·   ✦ ✦   ·   · ·   ████████████   ██ ██   ██ ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━─────────────────────────────────────   ✦ ✦ · ·   ✦   ✦✦ ✦   · ✦   ✦ ·   ✦ ·   ✦ · ✦ ✦ · ·   · ✦ ·   ✦ · ✦ ✦   · ✦ · ·   · ✦   · ✦ ✦   ✦ ·   ✦ · ✦ ·   ✦ ✦  [?7h[?25l[?7l ████████████   ██ ██   ██ ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━──────────────────────────────────  [?7h[?25l[?7l ████████████   ██ ██   ▒▒ ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━───────────────────────────────  [?7h[?25l[?7l ████████████   ▒▒ ██   ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━────────────────────────────  [?7h[?25l[?7l ████████████   ██ ██   ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━────────────────────────  [?7h[?25l[?7l ████████████   ██ ██   ██ ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━─────────────────────  [?7h[?25l[?7l ████████████   ██ ██   ██ ██   ▒▒ ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━──────────────────  [?7h[?25l[?7l ████████████   ██ ██   ▒▒ ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━──────────────  [?7h[?25l[?7l ✦ ✦ ✦ · ✦   ·  · · · · ·   ✦·   · ·     ✦   ✦ ✦ ✦ ✦ ✦   · ✦   ✦ · · · ✦ ·   ·   · ✦         ████████████   ▒▒ ██   ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━───────────   ✦ ✦ ✦   · ✦   ✦ ·   · ✦ · · ✦ ✦     · · ✦ · ·   ·   · ✦   · · ✦ ✦   ✦ · ·   ·   · ✦   · ·   · ✦   · ✦ · ✦  [?7h[?25l[?7l ████████████   ██ ██   ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━────────  [?7h[?25l[?7l ████████████   ██ ██   ██ ██   ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━─────  [?7h[?25l[?7l ████████████   ██ ██   ██ ██   ▒▒ ██   ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━──  [?7h[?1006l[?1003l[?1000l[?1049l[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │  Warning: No models available. Use /login or set an API key environment variable. Then use /model to select a model.    π > ⬢ no-model > 📁 …6-09-28/XXXXXXXXXXXXXXXXXXX/capture-cwd > ◫ 6.4K/? ⟲ ▶──────────────────────────────────────────── ╰─  ⇧⇥ to change thinking effort [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ██ ██ │ $ to run python │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ [?25h[?7h[?25l[?7l│ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ [?25h[?7h[?25l[?7l│ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ [?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l│ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ [?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l│ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ [?25h[?7h[?25l[?7l│ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ [?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/omp-18-setup.meta.json b/src/main/runtime/__fixtures__/omp-18-setup.meta.json new file mode 100644 index 00000000000..4f1c6f0f17c --- /dev/null +++ b/src/main/runtime/__fixtures__/omp-18-setup.meta.json @@ -0,0 +1,15 @@ +{ + "capturedAt": "2026-10-01T05:49:50.289Z", + "platform": "darwin", + "command": [ + "/bin/zsh", + "-f", + "-c", + "cd && exec /dist/cli.js --no-session --no-extensions --no-skills --no-rules --no-lsp --no-prewalk" + ], + "cols": 120, + "rows": 40, + "note": "OMP 18.4.5 isolated HOME, no credentials; startup.checkUpdate=false, marketplace.autoUpdate=off; command paths redacted; HOME/XDG/config isolated, no provider credentials; startup.checkUpdate=false, marketplace.autoUpdate=off; identifying cwd labels scrubbed with same-length replacements", + "exitCode": 129, + "sends": [] +} diff --git a/src/main/runtime/__fixtures__/omp-18-setup.txt b/src/main/runtime/__fixtures__/omp-18-setup.txt new file mode 100644 index 00000000000..9598ad7b77f --- /dev/null +++ b/src/main/runtime/__fixtures__/omp-18-setup.txt @@ -0,0 +1,84 @@ +[?25l[?25l[?7l +╭─── omp v18.4.5 ──────────────────────────────────────────────────────────────────────────────────╮ +│ │ Tips │ +│ Welcome back! │ # for prompt actions │ +│ │ / for commands │ +│ ████████████ │ ! to run bash │ +│ ██ ██ │ $ to run python │ +│ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ +│ ▒▒ ██ │ LSP Servers │ +│ ██ │ No LSP servers │ +│ │ │ +│  │ │ +│  │ │ +│ │ ───────────────────────────────────────────────────────────────────── │ +│ │ Recent sessions │ +│ │ No recent sessions │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +╰──────────────────────────┴───────────────────────────────────────────────────────────────────────╯ + Tip: Press ⌃R to search your prompt history and reuse a past message + + + +╰─   [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ LSP Servers │ │ ██ │ No LSP servers │ [?25h[?7h[?2004h[?1l>[?u]11;?_25a1;s\_tsp;q;{"q":"hello","v":[1],"app":"omp"}\[?2031h[?2026$p[?2048$p[?2031$p[?2004$p[?1010$p[?1011$p[?2;1;0S[?25l[?7l╭─── omp v18.4.5 ──────────────────────────────────────────────────────────────────────────────────╮ │ │ Tips │ │ Welcome back! │ # for prompt actions │ │ │ / for commands │ │ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ │ │ │ │  │ │ │  │ │ │ │ │ ╰──────────────────────────┴───────────────────────────────────────────────────────────────────────╯  Tip: Press ⌃R to search your prompt history and reuse a past message    ╰─   [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[>4;2m[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h]0;π >]0;π > capture-cwd[0 q[?25l[?7l +╭─── omp v18.4.5 ──────────────────────────────────────────────────────────────────────────────────╮ +│ │ Tips │ +│ Welcome back! │ # for prompt actions │ +│ │ / for commands │ +│ ████████████ │ ! to run bash │ +│ ██ ██ │ $ to run python │ +│ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ +│ ▒▒ ██ │ Recent sessions │ +│ ██ │ No recent sessions │ +│ │ │ +│ Unknown │ │ +│ Unknown │ │ +│ │ │ +╰──────────────────────────┴───────────────────────────────────────────────────────────────────────╯ + Tip: Press ⌃R to search your prompt history and reuse a past message + + + π > ⬢ no-model > 📁 …6-09-28/XXXXXXXXXXXXXXXXXXX/capture-cwd > ◫ 6.4K/? ⟲ ▶──────────────────────────────────────────── +╰─  ⇧⇥ to change thinking effort [?25h[?7h[?25l[?1049h[>4;2m[?1000h[?1003h[?1006h[?25l[?25l[?7l✦ · ✧ ✦  + ✧  + · ✧ ✧ ·  + ✦ ✦ · ✧  +· ✦ ✦ ✧  +  + ████████████████████████ ✦  + · ████████████████████████ ✦  + ✦ ✦ ████ ████ ✦  + ████ ████ · ✧  + · ✧ ████ · ████ ✧  + · ✧ ████ ████ ✧  + ✦ ▒▒▒▒ ████ ✦  + ▒▒▒▒ ████ ✧  + ✧ ████  + ████  + ░ ░▒▒▒▒▓▓▓▓▒▒▒▒░░░ ░▒▓▓███████▓████████▓▒░ ░░▒▒▒▒▒▓▓▒▓▒▒▒▒░░ ░  + ░ ░░▒▒▒▒▓▓▓▒▒▒░░░ ░▒▓████████████████▓▒▒░ ░░░░▒▒▒▒▒▓▒▓▒▒▒▒░░ ░░  + ░░▒▒▒▓▓▓▒▒▒░░░ ░░▒▒▓█████████████▓▒▒░░ ░░░░░▒▒▒▒▒▒▓▒▒▒▒░ ░ ░  + ░░▒▒▒▒▓▓▒▒▒░░ ░░░░▒▒▓████████████▓▒▒░░ ░░░░░░▒▒▒▒▒▒▒▒▒▒▒░░ ░ ░░░  + ░░▒▒▒▒▒▒▒▒░ ░░░░▒▒▒▓▓▓▓███████▓▒▒░░░░ ░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░ ░░ ░  + ░░▒▒▒▒▒░░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒▓▓▓▒▒▒░░ ░ ░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░ ░░░  + ░░░▒▒▒▒░░░░░ ░░░░░▒▒░░▒░░░░░░░░▒░░ ░░░░░░░░░░░░▒▒▒▒▒▒▒░░ ░░ ░  + ░ ░░░▒▒▒▒▒▒░░░░░░░░░░▒░░░ ░░░░▒░░░░░░░▒▒▒▒▒▒▒▒░░ ░ ░░  + ░ ░ ░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░ ░░▒▒░░░░░░░▒░░▒▒▒▒▒░░ ░░  + ░ ░░▒▒▓▓▓▓▓▒▒▒▒░░░░░░ ░░▒▒▒▒▒░▒░░░░▒░▒▒▒▒▒░░░ ░  + ░░▒▒▓▓▓▓▓▒▒▒▒░░░░░░░ ░░░▒▒▒▒▒▒▒▒░░▒░░▒▒▒░░░░░░  + ░░▒▒▓▓▓▓▓▓▒▒▒░░░░░░░░░░░ ░░ ░▒▒▒▒▒▒▒▒▒▒▒░░░▒░░░▒▒░░░░░  + ░░▒▒▒▓▒▓▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒▒░░░░▒▒▒░░▒░░░  + ░░░░▒▒▒▒▒▒▒░░░░░▒▒▒▒▓▓▓▓▓▓▓███▓▓▓▓▒▒░▒░░░▒░▒░░░░░  + ░░░░░░░░░░░░░▒▒▓▓▓▓██████▓▓▓▒▒▒░░░░░░░░░░░░  + ░ ░░░░░░░░▒▒▒▒▓▓▓████▓▓▓▒▒░░░░░░░░░░░░░░  + ░░ ░ ░ ░░░░░▒▒▒▒▒▓▓▓▓▓▒▒▒░░░ ░ ░  + ░░░░░░░ ░░░░░░░▒▒▒▒▒▒░░░  + ░ ░░ ░░░░░ ░░░ ░░░ ░░░░  + ░ ░░ ░ ░  + ░ ░  + ░░░ ░ ░ ░ ░░ ░ ░ ░░  + ░ ░░░░░░░ ░░░░░ ░░░░ ░ ░ ░░░░░░ ░  + ░░░░░░░░░░░ ░░ press ⏎ to skip ░░░░░░░░░ ░ ░░ ░  [?7h[?25l[?7l ████████████████████████ ✦   · ████████████████████████ ✦   ✦ ✦ ████ ████ ✦   ████ ████ · ✧   · ✧ ████ · ████ ✧   · ✧ ████ ████ ✧   ✦ ▒▒▒▒ ████ ✦   ✧ ████   ████   ░ ░░▒▒▒▒▓▓▓▓▒▒▒▒░░░ ▒▒▓▓██████▓▓████████▓▒░░ ░░▒▒▒▒▒▓▒▓▒▒▒▒▒░░   ░ ░░▒▒▒▒▒▓▒▒▒▒░░░ ░▒▓████████████████▓▒▒░ ░░░▒▒▒▒▒▓▒▓▒▒▒▒░░ ░   ░░▒▒▒▓▓▒▒▒▒░░░ ░▒▒▓██████████████▓▓▒░░ ░░░░░▒▒▒▒▒▓▒▒▒▒░░ ░   ░░▒▒▒▒▓▓▒▒▒░░ ░░░▒▒▓████████████▓▒▒░░░░ ░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒░ ░░░   ░▒▒▒▒▒▒▒▒▒░ ░░░░▒▒▒▓▓▓████████▓▓▒░░░░ ░░░░░░░░░▒░▒▒▒▒▒▒▒▒░░ ░   ░░▒▒▒▒▒▒░░ ░ ░░░░▒▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒░░░░ ░ ░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░ ░░   ░░░▒▒▒▒░░░░ ░░░░▒▒░▒▒░░░░░░▒▒▒░░ ░░░░░░░░░░░░▒▒▒▒▒▒▒▒░░ ░░ ░   ░ ░░░░▒▒▒▒░░░░░░░░░░░░▒░░░░ ░░░░▒░░░░░░░░▒░▒▒▒▒▒░░ ░ ░░   ░ ░ ░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░ ░░▒░░░░░░░░░░░▒▒▒▒▒░░░ ░░   ░ ░░▒▒▓▓▒▒▒▒▒░▒░░░░░░ ░░░▒▒▒▒░▒░░░░░░▒▒▒▒▒░░░░ ░   ░▒▒▒▓▓▓▓▓▒▒▒▒░░░░░░░ ░░▒▒▒▒▒▒▒▒░░░░░░▒▒░░░░░░░   ░░▒▒▓▓▓▓▓▒▒▒▒░░░░░░░░░░ ░░▒▒▒▒▒▒▒▒▒▒░░░░░░░▒▒░░░░░░   ░░░▒▒▒▓▒▓▒▒▒░░░░░░░░▒░░▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒░░░░▒▒▒░░▒░░░░   ░░░░▒▒▒▒▒▒▒░░░░░▒▒▒▒▓▓▓▓▓▒▓▓▓▓▓▓▓▓▒▒░▒░░░░░▒▒░░░░   ░░░░░░░░░░░░░░▒▒▓▓▓▓██▓███▓▓▓▒▒▒▒░░░░░░░░░░░   ░░░░░░░░▒▒▒▓▓▓██████▓▓▒▒▒░░░░░░░░░░░░░   ░░ ░ ░░░░░░▒▒▒▒▓▓▓▓▓▓▓▒▒▒░░ ░░░░   ░ ░░░ ░░░░░░▒▒▒▒▒▒▒▒░░ ░   ░░ ░░░░░ ░░░ ░░░ ░░░░░   ░ ░░░ ░ ░   ░   ░ ░ ░░   ░ ░░░ ░ ░░░░░ ░░ ░ ░ ░ ░ ░░   ░░░░░░░░░░░ ░░ press ⏎ to skip ░░░░░░░░░ ░░ ░  [?7h[?25l[?7l ████████████████████████ ✦   · ████████████████████████ ✦   · ✧ ████ · ████ ✧   · ✧ ████ ████ ✧   ✦ ▒▒▒▒ ████ ✦   ▒▒▒▒ ████ ✧   ░ ░░▒▒▒▒▒▓▒▒▒▒▒▒░░░ ░▒▒▓███████▓▒▓████████▓▒░ ░░░▒▒▒▒▓▒▓▒▓▒▒▒░░   ░░▒▒▒▒▒▒▓▒▒▒▒░░░ ░░▒▓█████████████████▓▒░ ░░░░▒▒▒▒▒▒▓▒▒▒▒░░   ░░░▒▒▒▓▓▒▒▒▒░░░ ░▒▒▓███████████████▓▒▒░░ ░░░░░▒▒▒▒▒▓▒▒▒▒░░ ░   ░░░▒▒▓▒▓▓▒▒▒░░ ░░░▒▒▓████████████▓▓▒░░░░ ░░░░░░░▒▒▒▒▒▒▒▒▒▒▒░░ ░   ░░▒▒▒▒▒▒▒▒░░ ░░░░▒▒▒▓▓▓████████▓▓▒▒░░░ ░░░░░░░░░░░▒▒▒▒▒▒▒▒▒░ ░   ░░░░▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▒▒▓▓▓▓██▓▓▒▒░░░ ░ ░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒░░ ░ ░   ░░░▒▒▒▒░░░ ░░░▒▒▒▒▒░░░░░▒▒▒▒▒░░ ░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒░ ░░ ░   ░░░░▒▒▒▒░░░░░░░░░░░░▒░░░░ ░ ░░░░░░░░░░░░░▒░▒▒▒▒▒░░ ░░   ░ ░░░░▒▒▒▒▒▒▒░░░░░░░░░░░░ ░░░░░░░░░░░░░▒▒▒▒▒░░░░   ░ ░░░▒▒▒▒▒▒▒▒▒░▒░░░░░░ ░░▒▒░▒░░░░░░░░▒▒▒▒▒░░░░ ░   ░▒▒▒▓▓▒▓▒▒▒▒▒░░░░░ ░░▒▒▒▒▒▒▒░░░░░░▒▒░░░░░░░   ░░▒▒▒▓▓▓▓▓▒▒▒▒░░░░░░░ ░ ░░▒▒▒▒▒▒▒▒▒░░░░░░░▒▒░░░░░░   ░░░▒▒▓▓▒▓▒▒▒░░░░░░░░░░░▒░░░░▒▒▒▓▓▓▓▓▒▒▒░░░░░░░░░▒▒░░░░   ░░░▒▒▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▓▒▓▒▓▓▓▓▓▓▓▓▒▒▒▒░░░░░▒▒░░░░ ░   ░░░░░░░░░░░░░░░░▒▒▓▓▓▓█▓▓▓██▓▓▓▓▒▒▒░░░░░░░░░░░ ░   ░░░░░░░░░░▒▒▒▓▓▓██████▓▓▓▒▒▒░░░░░░░░░░░░░   ░░ ░░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▒▒░░░░░ ░░░░   ░ ░░░ ░░░░░░▒▒▒▒▒▒▒▒▒░░ ░   ░░ ░░░░░ ░░░░ ░░░░░░░░░░░   ░░░ ░   ░ ░ ░   ░ ░ ░ ░ ░░ ░ ░ ░ ░ ░ ░░   ░ ░░░░░░░░░░░ ░░ press ⏎ to skip ░░░░░░░░ ░░ ░ ░  [?7h[?25l[?7l ████████████████████████ ✦   · ████████████████████████ ✦   ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░▒▒▓██████▓▓▒▓████████▓▒░░ ░░░▒▒▒▒▓▒▓▒▓▒▒▒░░   ░░▒▒▒▒▒▒▓▒▒▒▒░░░ ░▒▓▓████████▓████████▓▒▒░ ░░░▒▒▒▒▒▒▓▒▒▒▒▒░░   ░░▒▒▓▒▓▓▒▒▒▒░░░ ░░▒▒▓███████████████▓▓▒▒░ ░░░░░▒▒▒▒▒▓▒▒▒▒▒░░   ░░▒▒▒▓▓▓▓▒▒░░░ ░░▒▒▓▓█████████████▓▓▒░░░ ░ ░░░░░▒░▒▒▒▒▒▒▒▒▒░░ ░   ░░░▒▒▒▒▒▒▒▒░░ ░░░░▒▒▓▓███████████▓▒▒░░░ ░ ░░░░░░░░░▒▒▒▒▒▒▒▒▒░ ░   ░░░▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▒▓▓██████▓▓▒░░░ ░ ░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒░░ ░   ░░░▒▒▒▒▒░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▓▒▒░░ ░░░░░░░░░░░░░░░▒▒▒▒▒▒░░ ░░ ░   ░░░░░▒▒░░░░░ ░░░░░░░▒▒░░░░ ░░░░ ░░░░░░░░░░░░░░░▒▒▒▒▒▒░░ ░░   ░░░░▒▒▒▒░▒░░░░░░░░░░░░░ ░░░░░░░░░░░░░▒▒▒▒▒▒░░░░   ░░░▒▒▒▒▒▒▒░░░▒░░░░░░ ░░░▒░▒░░░░░░░░░▒▒▒▒░░░░░   ░░▒▒▒▓▓▒▒▒▒▒░▒░░░░░ ░░▒▒▒▒▒▒▒░░░░░░░▒░░░░░░░   ░░▒▒▒▓▓▓▓▓▒▒▒▒░░░░░░░ ░░░░▒▒▒▒▒▒▒░░░░░░░▒▒░░░░░░░   ░░▒▒▒▓▓▒▓▒▒▒░░░░░░░░░░░░░░░░░▒▒▒▒▓▓▓▒▒▒░░░░░░░░░▒▒▒░░░   ░░░▒▒▒▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒░░░░░▒▒░░░░░░   ░░░░░▒▒▒░░░░░░░░░▒▒▒▓▓▓▓▓▓▓██▓▓▓▓▒▒▒░░░░░░░░░░░░░   ░░░░░░░░░░░▒▒▒▓▓▓██▓███▓▓▓▓▒▒░░░░░░░░░░░░░   ░ ░ ░░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▒▒▒░░░░ ░ ░░░░   ░ ░░░ ░░░░░▒▒▒▒▒▒▓▒▒▒▒░░ ░   ░░ ░░░░░ ░░░░ ░░░░░▒░░▒░░   ░░░ ░ ░   ░     ░ ░ ░░ ░ ░ ░ ░░   ░ ░░░░░░░░░░░ ░░ press ⏎ to skip ░░░░░░░░ ░░ ░  [?7h[?25l[?7l ████████████████████████ ✦   · ████████████████████████ ✦   ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░ ░▒▓▓█████▓▓▒▒▒████████▓▒▒░ ░░▒▒▒▒▒▒▒▒▓▒▒▒░░░   ░░▒▒▒▒▒▒▓▒▒▒▒░░░ ░▒▓▓███████▓▓████████▓▓▒░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░   ░░▒▒▓▒▓▓▒▒▒▒░░░ ░░▒▒▓████████████████▓▒▒░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒░░   ░░▒▒▒▓▓▓▓▒░░░░ ░░▒▒▓██████████████▓▓▒░░░ ░░░░░▒░▒▒▒▒▒▒▒▒▒░░ ░   ░░▒▒▒▒▓▒▒▒▒░ ░░░░▒▒▓▓████████████▓▒▒▒░░ ░░ ░░░░░░░░░▒▒▒▒▒▒▒▒▒░░   ░░░▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▓▓▓███████▓▒▒░░░░ ░░░░░░░░░░░░░▒░▒▒▒▒▒▒░░ ░   ░░░▒▒▒▒▒░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▓▓▓▒▒░░░░ ░░░░░░░░░░░░░░░░▒▒▒▒▒░░░ ░   ░░▒░░▒░░░░ ░░░░░░▒▒░░░░░░░░░▒░░ ░░░░░░░░░░░░░░░░▒▒▒▒▒░░░ ░░   ░░░░▒▒▒▒░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░▒▒▒▒░░░░   ░░░▒▒▒▒▒▒▒░░░░░░░░░░ ░░▒░░░░░░░░░░░░▒▒▒░▒░░░   ░░▒▒▒▓▒▒▒▒▒▒░▒░░░░░ ░░░▒▒▒░░░░░░░░░░░░░░░░░   ░░▒▒▒▓▓▒▒▒▒▒▒░░░░░░░░ ░░░░▒▒▒▒▒▒░░░░░░░░▒░▒░░░░░   ░░▒▒▒▓▓▒▓▒▒░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒░░░   ░░░▒▒▒▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒▒░░░░░░▒░░░░░░   ░░░░░▒▒▒░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░   ░░░░░░░░░░░░▒▒▒▓▓▓▓█▓▓██▓▓▓▓▒▒░░░░░░░░░░░░░   ░ ░░░░░░░▒▒▒▓▓▓█▓▓▓▓▓▒▒▒▒░░░ ░ ░░░░░░   ░░ ░░░░░▒▒▒▒▓▓▓▓▒▒▒▒░░░ ░░░░   ░░ ░ ░░ ░░░░░░░░░░▒▒▒▒░░░   ░░░ ░ ░ ░ ░░   ░ ░ ░ ░ ░░   ░ ░░░░ ░░░░░░ ░░ press ⏎ to skip ░░░░░░░ ░  [?7h[?25l[?7l ████████████████████████ ✦   · ████████████████████████ ✦   ✦ ✦ ████ ████ ✦   ████ ████ · ✧   ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▓█████▓▓▒▒▒▒▓▓███████▓▒░░ ░░░▒▒▒▒▒▒▒▓▒▒▒░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░▒▓████████▓▒▓████████▓▒░░ ░░░▒░▒▒▒▒▒▒▒▒▒▒▒░   ░░▒▒▓▒▓▓▒▒▒▒░░░ ░▒▒▒▓████████████████▓▒▒░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒░░   ░░▒▒▒▓▒▒▒▒░░░ ░░▒▒▓███████████████▓▒▒░░░░ ░░░░░░░▒▒▒▒▒▒▒▒▒▒░   ░░▒▒▓▒▓▒▒▒░░ ░░░░▒▒▓▓████████████▓▒▒▒░░░░░ ░░░░░░░░░▒░▒▒▒▒▒▒▒▒░░   ░░▒▒▒▓▒▒▒░░ ░░░░▒▒▒▒▓▓▓████████▓▒▒▒░░░ ░░░░░░░░░░░░░░░▒▒▒▒▒▒▒░░ ░   ░░░▒▒▒▒▒▒░░░ ░░░▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▒▒░░░░ ░░░░░░░░░░░░░░░░▒▒▒▒▒░░░ ░   ░░░░▒░░▒░░░ ░░░░░▒▒░░░░░░░▒▒▒░░ ░░░░░░░░░░░░░░░░▒▒▒▒▒░░░ ░   ░░░░▒░▒░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░▒▒▒░░░░░   ░░░░▒▒▒▒▒▒▒░░░░░░░░░░ ░░░░░░░░░░░░░░░▒░▒░▒░░░   ░░▒▒▒▒▒▒▒▒▒▒░░░░░░░ ░░░░░▒░░░░░░░░░░░░░░░░░░   ░░▒▒▒▓▓▒▒▒▒▒▒░░░░░ ░ ░░░░▒▒▒▒▒░░░░░░░░▒░░░░░░░░   ░░▒▒▒▓▓▒▓▒▒░░░░░░░░░░ ░ ░░░▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒░░░   ░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░▒▒░▒▒▒░▒░▒▒▒▓▒▓▓▓▒▒▒▒░░░░░░▒░░░░░░░   ░░░░▒▒▒▒▒░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░▒▒▒▓▓▓▓▓▓▓██▓▓▓▓▒▒▒▒░░░░░░░░░░░░   ░░ ░░░░░░░▒▒▒▓▓▓█▓▓▓▓▓▓▒▒▒░░░ ░ ░░░░░░   ░░ ░░░░░▒▒▒▓▓▓▓▓▓▓▒▒░░░ ░ ░░░░░   ░░ ░ ░░ ░░░░░░░░░▒▒▒▒▒▒▒░░░   ░░░ ░ ░░ ░░░░░     ░ ░   ░ ░░░░ ░ ░ ░░ press ⏎ to skip ░░░ ░ ░  [?7h[?25l[?7l ████████████████████████ ✦   · ████████████████████████ ✦   ✦ ✦ ████ ████ ✦   ████ ████ · ✧   · ✧ ████ · ████ ✧   · ✧ ████ ████ ✧   ✦ ▒▒▒▒ ████ ✦   ▒▒▒▒ ████ ✧   ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░▒▓▓████▓▓▒▒░░▒▓▓███████▓▒▒░ ░░▒▒▒▒▒▒▒▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░▒▓███████▓▒▒▓▓███████▓▒▒░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒░   ░░▒▒▒▒▒▒▒▒░▒░░░ ░▒▒▓▓████████████████▓▓▒▒░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒░░   ░░▒▒▒▓▒▒▒▒░░░ ░░▒▓▓███████████████▓▒▒▒░░░ ░░░░░░░▒░▒▒▒▒▒▒▒▒░░   ░░▒▒▓▒▓▒▒▒░░ ░░░░▒▓▓█████████████▓▓▒▒░░░░░ ░░░░░░░░░░░▒▒▒▒▒▒▒▒░░   ░░░▒▒▒▓▒▒▒░░ ░░░░▒▒▒▒▓▓██████████▓▒▒▒░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒░░░   ░░░▒▒▒▒▒▒░░ ░░░▒▒▒▒▒▒▒▓▓▓▓███▓▓▒▒░░░ ░░░░░░░░░░░░░░░░▒▒▒▒▒░░░░ ░   ░░░▒▒▒░▒░░░ ░░░░░▒▒░░░░░░▒▒▒▒░░░░ ░░░░░░░░░░░░░░░░░▒▒▒▒▒░░   ░░░░▒░░░░░░░░░░░░░░░░░░░░ ░ ░░░░░░░░░░░░░░░░▒▒▒░░░░░   ░░░░░▒▒▒▒░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░▒░▒░░░   ░░░▒▒▒▒▒▒▒▒▒▒░░░░░░░ ░░░░▒░░░░░░░░░░░░░░░▒░░   ░░▒▒▒▓▓▒▒▒▒▒▒░░░░░ ░ ░░░▒▒▒▒▒░░░░░░░░░░░░▒░░░░   ░░▒▒▒▒▓▓▒▒▒▒░░░░░░░░░░ ░░▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒░░░░   ░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░▒░░░░░▒▒▒▒▒▓▓▓▒▒▒▒░░░░░░░░░▒▒░░░   ░░░▒░▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▓▒▒▒▓▓▓▓▓▓▓▒▒░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░   ░░░░░░░░░░░▒▒▒▒▓▓▓█▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░   ░ ░░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒░░░ ░ ░░░░░   ░░ ░ ░░░░░░░▒▒▒▒▒▒▒▒▒░░░   ░░░ ░ ░░░░░░░░░░   ░   ░ ░ ░ ░░ press ⏎ to skip ░░ ░ ░  [?7h[?25l[?7l ·   ✧ ✦ ✧   · ✧   ✧   ✦   ✧ · · ✧   ✦ ████████████████████████ ·   ✦ ████████████████████████   · ████✧ ████   ✦ ✦ · ████ ████   ✧ ████ ████   ████· ████   ✦ · ✧ ▒▒▒▒ ████ ✦ ✦ ·   ✧ ▒▒▒▒ ████ ✦ ✦   ✦ · ████   ████   ░░░░░▒░░▒▒▒▒▒▒▒░░░░░░░░▒▒▒▓▓▓▓▓▓▒▒░░ ░▒▒▒▓███████▓▒░░ ░░░▒▒▒▒▒▒▒▒▒▒▒░   ░░░░░▒▒░▒▒▒▒▒▒░░░░ ░░░░▒▒▒▓▓██▓▓▒▒▒▒░░▒▒▓▓███████▓▒▒░ ░░░░▒▒▒▒▒▒▒▒▒▒░░   ░░░▒▒▒▒▒▒▒▒░░▒░░░░░░ ░░░▒▓▓▓█████▓▓▒▒▒▓▓██████▓▓▒▒░░ ░░░░░░▒▒▒▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒▒▒░░░░░ ░░▒▒▓▓████████▓███████▓▓▒▒░░░ ░░░░░░░░░▒▒▒▒▒▒▒░░   ░▒▒▒▓▓▒▒▒▒░░░ ░░░▒▒▓▓██████████████▓▓▓▒▒░░░░░░░░░░░░░░░░░▒▒▒▒▒▒░░░░   ░░▒▒▒▒▓▒▒▒░ ░░░░▒▒▓▓█████████████▓▓▒▒▒░░░░░░░░░░░░░░░░░░░▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒▒░░ ░░░▒▒▒▒▓▓▓█████████▓▓▒▒▒░░░░░░░░░░░░░░░░░░░░░░▒▒░░░░   ░░░▒▒▒▒▒░░░ ░░░░░▒▒▒▒▒▒▓▓▓▓▓▓▓▓▒▒░░░░░░░░░░░░░░░░░░ ░░░░▒▒▒░░░░   ░░░▒▒▒░░░░ ░░░░░░░▒░░░░░░▒▒▒▒▒░░░ ░░░░░░░░░░░ ░░░░░░░░░░░░   ░░░░▒░░░░░░ ░ ░░░░░░░░ ░░░░░░░░░ ░ ░░░░░░▒░▒░░░   ░░░░▒░▒▒░░░░░░░░░░░░░ ░░░░░░░░░░░░░ ░░░░▒▒░░░░   ░▒▒▒▒▒▒▒▒░▒░░░░░░░░ ░░░░▒░░░░░ ░░░░░░▒▒░░░   ░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░ ░░▒▒▒▒░░░░░░░░░░░▒▒░░░░░░   ░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░ ░░░░░▒▒▒▒▒▒▒░░░░░░░░░░▒░░░░░░   ░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░▒▒▒▓▓▓▒▒▒▒▒░░░░░░░░▒░░░░░░░   ░░░▒▒▒▒▒░▒░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░░   ░ ░░░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░   ░░ ░ ░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▓▒▒▒░░░ ░░ ░░░░░░░   ░░░░░▒▒▒▒▓▓▓▓▓▒▓▒▒▒░░░ ░ ░   ░ ░ ░ ░░░░▒▒▒▒▒▒▒▒░░ ░ ░   ░ ░ ░░░░░░░░     press ⏎ to skip  [?7h[?25l[?7l ✦ ████████████████████████ ·   ✦ ████████████████████████   ░░░░░░░░▒▒▒▒▒▒▒░░░░░░░░▒▓▓▓█▓▓▓▒▒░░ ░░░▒▓███████▓▓▒░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░   ░░░░░░▒▒░░▒▒▒▒▒░░░░░░░░░░▒▒▒▓▓▓▓▓▒▒░▒░░░░▒▒▓▓██████▓▒▒░ ░░░░▒░▒▒▒▒▒▒▒▒░░   ░░░░▒▒▒▒▒▒░░░░░░░░░░ ░░░▒▒▓▓▓██▓▓▓▒▒▒▒▒▓▓▓█████▓▓▒▒░░ ░░░░░▒▒▒▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒▒▒░░░░░ ░░▒▒▓▓███████▓▓▓▓▓████▓▓▓▒▒░░ ░░░░░░░░░▒▒▒▒▒▒▒░░░   ░▒▒▒▓▓▒▒▒░░░░ ░░░▒▒▓▓███████████████▓▓▒▒░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒▒░ ░░░░▒▒▓▓█████████████▓▓▓▒▒░░░░░░░░░░░░░░░░░░░▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒▒░░ ░░░▒▒▒▒▓▓██████████▓▓▓▒▒░░░░░░░░░░░░░░░░ ░░░░░▒░░░░░░   ░░░▒▒▒▒▒░░░ ░░░░░▒▒▒▒▓▓▓▓██▓█▓▓▒▒▒░░░░░░░░░░░░░░░░░ ░░░░░░▒░░░░   ░░░░▒▒▒░░░░ ░░░░░░▒░▒░▒▒▒▒▒▒▒▒▒░░░░ ░░░░░░░░░░░░ ░░░░░░░░░▒░░   ░░░░▒░░░░░ ░░░░░░░ ░░░ ░ ░░░░░░░░ ░ ░░░░░░░░▒▒░░░   ░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░ ░░ ░░░░▒▒░░░░   ░░▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░░▒░░░░░ ░░░░░░░▒░░░░░   ░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░ ░░░░░▒░░░░░░░░░░░░░░▒░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░ ░░░▒▒▒▒▒▒▒░░░░ ░░░░░▒░▒░░░░   ░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░   ░░▒▒▒▒▒▒░▒░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▒▒▒▒▒░░░░░░░░░░░░░░   ░░░░▒▒░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░   ░░░░░ ░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░   ░░░░░▒▒▒▒▓▓▓▓▓▒▓▒▒▒░░░ ░ ░ ░░░░   ░ ░ ░░▒▒▒▒▒▒▒▒▒▒▒░░░ ░ ░░   ░░░░░░░░░░ ░  [?7h[?25l[?7l ✦ ████████████████████████ ·   ✦ ████████████████████████   · ████✧ ████   ░░░░░░░░░▒▒▒░░░░░░░░░▒▒▒▓▓▓█▓▓▒▒░░ ░░░▒▒▓███████▓▒▒░ ░░▒░▒▒▒▒▒▒▒▒▒▒░░   ░░░░░░▒░░░▒▒▒▒▒░░░░░░░░░▒▒▒▓▓▓▓▓▓▒▒░░░ ░░░▒▒▓██████▓▒▒░░░ ░░░░░░▒▒▒▒▒▒▒▒░░░   ░░░░░▒▒░▒▒░░░░░░░░░░ ░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▓▓█████▓▒▒░░ ░░░░░▒▒▒▒▒▒▒▒▒░░   ░░▒▒▒▒▒▒▒▒▒░░░░░ ░ ░ ░░▒▒▒▓▓█████▓▓▓▓▓▓▓████▓▓▓▒▒░░░░ ░░░░░░░░░░░▒▒▒▒▒░░░░   ░▒▒▒▒▒▒▒▒░░░░ ░░░▒▓▓████████████████▓▓▓▒▒░░░░░░░░░░░░░░░░░▒▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒░░ ░░░░▒▒▓▓█████████████▓▓▓▒▒░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒░░░ ░░░▒▒▒▒▓▓███████████▓▓▓▒▒░░░░░░░░░░░░░░░ ░░░░▒░░▒░░░   ░░▒▒▒▒▒▒░░░ ░░░░░▒▒▒▒▓▓▓█████▓▓▓▒▒░░░░░░░░░░░░░░░░░ ░░░░░▒░░░░   ░░▒▒▒▒▒░░░ ░░░░░░▒░▒░▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░░░░░░░░ ░ ░░░░░░░▒░░░   ░░░░▒░▒░░░░ ░░░░░░░░ ░ ░░░░░░ ░░░░░░░░ ░ ░░░░░░▒▒░░░   ░░░░░░░░░░ ░░░░░░░░░░░ ░░░░░░░░░ ░░░░▒░░░░   ░░░▒░▒▒▒▒░░░░░░░░░░░░ ░░░░▒░░░░░ ░░░░░░░▒░░░░░░   ░░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░ ░░░░░░░░░░░░░ ░░░░░▒░░░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░ ░░▒▒▒▒▒▒▒░░░░ ░░░░░▒░▒░░░░   ░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░   ░░░▒▒▒▒▒▒░▒░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▒▒▒▒▒░░░░░░░░░░░░░░░░   ░░░▒▒▒░░░░░░░░░░▒▒▒▒▓▒▓▓▒▒▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░ ░   ░░░░░░░ ░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░   ░░░░░▒▒▒▒▓▓▓▓▓▒▓▒▒▒▒░░ ░ ░ ░░░░░   ░ ░░░░▒▒▒▒▓▒▒▒▒▒▒▒░░ ░ ░░   ░░░░░░░░░░░░   ░  [?7h[?25l[?7l ✦ ████████████████████████ ·   ✦ ████████████████████████   · ████✧ ████   ✦ ✦ · ████ ████   ✧ ████ ████   ████· ████   ░ ░░░░░░░░░░░░░░░░░░░░▒▒▒▓▓▓▓█▓▓▒▒░ ░ ░░░▒▓▓██████▓▒▒░ ░░░▒▒▒▒▒▒▒▒▒▒░░   ░░░░░░░░░░▒░░▒▒░░░░░░░░▒▒▒▒▓▓▓▓▓▒▒░░░░ ░░░▒▒▓▓█████▓▓▒▒░░ ░░░░▒▒▒▒▒▒▒▒░░░░   ░░░░░░▒▒░░▒░░░░░░░░░░░░░░░▒▒▓▓▓▓▓▓▓▒▒▒▒░▒▒▒▒▓▓█████▓▒▒▒░░ ░░░░░▒░▒▒▒▒▒▒▒░░   ░░▒▒▒▒▒▒░░▒░░░░░ ░ ░░░ ░░░▒▒▒▓▓████▓▓▓▓▒▓▓▓████▓▓▓▓▒▒░░░ ░░░░░░░░░░▒▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒░░░░░ ░░░▒▓▓██████████▓█████▓▓▓▒▒▒░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒▒░░ ░░░░▒▒▓▓█████████████▓▓▓▒▒▒░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒░░ ░░░▒▒▒▒▓▓███████████▓▓▓▒▒░░░░░░░░░░░░░░░ ░░░░░░░▒░░░   ░░░▒▒▒▒▒▒░░ ░░░░▒▒▒▒▓▓▓██████▓▓▓▒▒▒░░░░░░░░░░░░░░░░ ░░░░░░░░░░░   ░░▒▒▒▒▒▒░░ ░░░░░░▒▒▒▒▒▒▒▓▒▓▓▒▒▒▒░░░ ░░░░░░░░░░░░ ░ ░░░░░░▒░░░   ░░░▒▒▒░░░ ░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░ ░░░░▒▒░░░░   ░░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░ ░░░░▒░░░░   ░░░▒░▒░░▒░░░░░░░░░░░░ ░░░░░░░░ ░ ░░░░▒░░░░░░   ░░░▒▒▒▒▒▒▒░▒░░░░░░░░ ░░░░░░░░░░░ ░░░░░░░░░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒░░░░░ ░ ░░░▒▒▒▒▒░░░░ ░░░░░░░▒░░░░   ░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░ ░ ░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░   ░░▒▒▒▒▒▒▒░▒░░░░░░░░▒▒▒░▒░░░░▒▒▒▒▒▓▒▒▒▒▒░░░░░░░░░░░░░░░░░   ░ ░░▒▒▒▒░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░ ░   ░░░░░░░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░░   ░░░░░▒▒▓▒▓▓▓▓▓▓▓▓▒▒▒▒░░░ ░ ░░░░░   ░ ░░░░▒▒▒▒▓▒▓▒▒▒▒▒░░░ ░ ░░░   ░░░░░░▒▒░░░░░   ░ ░░  [?7h[?25l[?7l ✦ ████████████████████████ ·   ✦ ████████████████████████   ✦ ✦ · ████ ████   ✧ ████ ████   ████· ████   ✦ · ✧ ▒▒▒▒ ████ ✦ ✦ ·   ✧ ▒▒▒▒ ████ ✦ ✦   ✦ · ████   ████   ░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▓█▓▓█▓▓▒░ ░ ░░░▒▓▓███████▓▒░ ░░░▒▒▒▒▒▒▒▒▒▒░░   ░░░░░░░░░░░░░░▒░░░░░░▒▒▒▒▒▓▓▓▓▓▒▒░░ ░░░▒▒▓██████▓▓▒░░ ░░░▒░▒▒▒▒▒▒▒░░░   ░░░░░░▒░░░░░░░░░░░░░░░░░░▒▒▒▓▓▓▓▓▓▒▒▒░░░░░▒▒▒▓▓████▓▓▒▒▒░░ ░░░░░░░▒▒▒▒▒▒▒░░░   ░░▒▒▒▒▒▒░░░░░░░░░░ ░░░░░░░▒▒▒▓▓████▓▒▒▒▒▒▒▓▓▓██▓▓▓▓▒▒░░░ ░░░░░░░░░░░▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒░░░░░ ░░▒▒▓▓████████▓▓▓█████▓▓▓▒▒▒░░░░░░░░░░░░░░░░░░▒▒▒░▒▒░░░   ░░▒▒▒▒▒▒▒▒░░ ░░░░▒▒▓▓██████████████▓▓▒▒▒▒░░░░░░░░░░░░░░░░░░░░▒▒▒▒░░░   ░░░▒▒▒▒▒▒▒░░ ░░░▒▒▒▓▓█████████████▓▓▒▒▒░░░░░░░░░░░░░░ ░░░░░░░▒░░░   ░░░▒▒▒▒▒▒▒░░ ░░░░▒▒▒▓▓████████▓█▓▓▒▒░░░░░░░░░░░░░░░░ ░░░░░░░░░░░   ░░▒▒▒▒▒▒░░ ░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒░░ ░░░░░░░░░░░░ ░ ░░░░░░▒░░░   ░░▒▒▒▒░░ ░░░░░░░░░░░░░▒▒▒▒░▒░░ ░░░░░░░░░░ ░░░░░▒░░░░░   ░░░░░░░░░ ░ ░░░░░░░░░░ ░ ░░░░░░░░ ░░░░░░░░░   ░░░▒░░░░░░░░░░░░░░░░░ ░░░░░░░░ ░░░░░░░░░░░   ░░░▒▒▒▒▒▒▒░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░░░░   ░░▒▒▒▒▒▒▒▒▒░░░░░░░ ░ ░░▒░░░▒░░░░ ░░ ░░░░▒░░░░░   ░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░ ░░░▒▒▒▒▒▒▒▒░░░░ ░░░░░░░░░░░   ░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░   ░░░░▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▓▒▓▓▓▒▒▒░░░░░░░░░░░░░░░░░   ░ ░░░░░░░░░░░░░░░▒▒▒▓▓▓▓▓▒▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░░   ░ ░ ░░░░░▒▒▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░ ░ ░░░░░ ░   ░ ░░░░▒▒▒▒▓▒▓▓▒▒▒▒░░░ ░ ░░░   ░░░▒▒▒▒▒▒▒░░░░ ░   ░░░░░░░░░  [?7h[?25l[?7l   ✧ ✧ · ✦   ✧ ✧ ✦✧   · ✧   ✧   · ✦   ✧ · ████████████████████████·✧   ████████████████████████ ✦ ·   ✦ · ████ ████ ✦   ████ ·████   ████ ████   ✧ ████ ████ ✧ ✧ ✦ ✦   ✦ ▒▒▒▒ ████   · ▒▒▒▒ ✦ ████ ✧   ✧ ████   ████   ░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▓▓██▓█▓▒░░ ░░░ ░░▒▓███████▓▒▒░ ░░░░▒▒▒▒▒▒▒▒▒░░░   ░ ░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▒░░ ░░░▒▒▓▓█████▓▓▒░░ ░░░░░▒▒▒▒▒▒▒░░░░   ░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▒▒░░░░░░░▒▒▒▒▓▓███▓▓▓▒▒░░ ░░░░░░░░▒▒▒▒▒▒░░░░   ░░░▒░░▒░░░░░░░░░░░░░░░░░░░▒▒▒▒▓▓▓█▓▓▒▒▒▒▒▒▒▒▓▓▓█▓▓▓▓▒▒░░░ ░░░░░░░░░░░▒▒▒▒▒░░░░   ░░░▒▒▒▒▒▒▒░░░░░ ░░▒▒▓▓███████▓▓▓▓▓▓███▓▓▓▒▒▒▒░░░░░░░░░░░░░░░░░░▒▒░▒▒░░░   ░░▒▒▒▒▒▒▒░░ ░░░░▒▒▓▓██████████████▓▓▓▓▒▒░░░░░░░░░░░░░░ ░░░░▒▒▒▒░░░   ░░▒▒▒▒▒▒▒▒░░ ░░░▒▒▒▓▓█████████████▓▓▓▒▒▒░░░░░░░░░░░░░ ░░░░░░░░░░░   ░░░▒▒▒▒▒▒▒░ ░░░▒▒▒▓▓▓██████████▓▓▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░░░░   ░░░▒▒▒▒▒▒░░ ░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░░ ░ ░ ░░░░░░░░   ░░░▒▒▒▒░░ ░░░░░░░░░░░▒▒▒▒▒▒▒▒░░░ ░ ░░░░░░░░ ░░░░░░░░░   ░░░░░░░░░ ░░░░░░░░░ ░░░░ ░░░░░░░░ ░░░░░░░░   ░░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░ ░░░░░░░░░░   ░░░▒▒▒▒░▒▒░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░░░░   ░░▒▒▒▒▒▒▒▒▒░░░░░░░ ░░░░░░▒░░░░ ░░ ░░░░░░░░░░░   ░░▒▒▒▒▒▒▒▒▒░░░░░░░░░ ░░░▒▒▒▒▒▒▒░░░░ ░░░░░░░░░░░░   ░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒░░░░░ ░░░░░░░░░░░░   ░░▒░▒▒▒▒▒░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▒▒▒░░░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░░▒▒▒▓▓▓▒▓▒▒▓▓▓▓▒▒▒▒▒░░░░░░░░░░░░░░   ░░░░ ░░░░░▒▒▓▓▓▓▓▓▓▒▓▓▓▒▒▒░░░ ░ ░░░░░ ░   ░ ░░░░▒▒▒▒▓▒▓▓▓▒▒▒▒▒░░ ░ ░░░ ░   ░░░▒▒▒▒▒▒▒▒░▒░░ ░░   ░░░░░░░░░  [?7h[?25l[?7l ✧ · ████████████████████████·✧   ████████████████████████ ✦ ·   ✦ · ████ ████ ✦   ████ ·████   ████ ████   ✧ ████ ████ ✧ ✧ ✦ ✦   ✦ ▒▒▒▒ ████   · ▒▒▒▒ ✦ ████ ✧   ░░░░░░░ ░░░ ░░░░░░░░░▒▒▒▓▓▓███▓▓▒▒░ ░░░ ░░▒▒▓██████▓▓▒░░ ░░░░▒▒▒▒▒▒▒▒░░░░   ░░░░░░░░ ░░ ░░░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▒░░ ░░░░░▒▒▓▓█████▓▓▒▒░ ░░░░▒░▒▒▒▒▒▒░░░   ░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▓▓▓▒▒▒▒░░░ ░░░░▒▒▒▓▓▓██▓▓▒▒░░ ░ ░░░░░░▒▒▒▒▒▒▒░░   ░░░▒░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▓▓▓▓▒▒▒░░░▒▒▒▒▒▓▓▓▓▓▓▓▓▒▒░░░░ ░░░░░░░░░░░░▒▒▒▒░░░░   ░░░▒▒▒▒▒░░░░░░░ ░░░░░░░░░▒▒▒▓▓▓██▓▓▓▓▓▒▒▒▓▓▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░░░░░░▒░░▒░░░░   ░░▒▒▒▒▒▒▒░░ ░░░░▒▒▓▓▓███████▓▓▓██▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░ ░░░░░░▒▒░░   ░░▒▒▒▒▒▒▒▒░░░ ░░░▒▒▒▓██████████████▓▓▓▒▒▒▒░░░░░░░░░░░░ ░ ░░░░░░░░░░   ░░░▒▒▒▒▒▒▒░░ ░░░▒▒▒▓▓▓██████████▓▓▓▒▒▒▒▒░░░░░░░░░░░░ ░░░░░░░░░░░   ░░▒▒▒▒▒▒░░ ░░░░░▒▒▒▓▓█████▓▓▓▓▓▓▒▒░░░░░░░░░░░░░░░░ ░░░░░░░░   ░░▒▒▒▒▒░░░ ░░░░▒░░░▒▒▒▒▒▓▓▓▒▒▒▒▒░░░░░░ ░░░░░░░░░ ░░░░░░░░░   ░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░ ░░░░░░░░░   ░░░░░░░░░ ░ ░░░░░░░░░ ░░░░░░░░ ░░░░░░░░░   ░░░░░▒░░░░░░░░░░░░░░░░ ░░░░░░░░ ░░░░░░░░░░░   ░░░▒▒▒▒▒░▒░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░░░   ░░▒▒▒▒▒▒▒▒▒░░░░░░░░ ░░░░░▒░▒░░░ ░░░░░░░░░░ ░   ░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░░░░░░▒▒▒▒▒▒░░░░░ ░░░░░░░░░░░░   ░░░▒▒▒▒▒▒▒░░░░░░░░░░▒▒░░░░░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░   ░░░░▒░░▒░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▓▓▒▒▒▒▒░░░░░░░░░░░░░░░   ░░░░░░ ░░░░░▒▒▓▒▓▓▓▓▒▒▓▓▓▓▒▒▒░░░░ ░ ░░░░░░░░   ░░░░░▒▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒░░ ░ ░░░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░   ░░░░░▒░▒▒░░░░░   ░  [?7h[?25l[?7l ✧ · ████████████████████████·✧   ████████████████████████ ✦ ·   ✦ · ████ ████ ✦   ████ ·████   ████ ████   ✧ ████ ████ ✧ ✧ ✦ ✦   ✦ ▒▒▒▒ ████   · ▒▒▒▒ ✦ ████ ✧   ✧ ████   ░░░░░░░ ░░░ ░░░░░░░░░▒▒▓▓▓████▓▓▒▒ ░░░ ░░▒▓▓██████▓▓▒░ ░░░░▒▒▒▒▒▒▒▒░▒░░   ░░░░░░░░ ░ ░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▒▒░ ░░░░░░▒▒▓██████▓▒▒░░ ░░░░▒░▒▒▒▒▒▒░░░░   ░░░░░░░░ ░░░░░░░░░░▒▒▒▒▒▒▓▓▓▓▓▒▒▒░░ ░░░░░▒▒▓▓▓██▓▓▒▒▒░░░ ░ ░░░░░░▒░▒▒▒▒▒░░   ░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▓▒▒▒▒▒░░░░░░░░▒▒▓▓▓▓▓▓▓▒▒▒░░░░ ░░░░░░░░░░░░▒▒▒▒░░░░░   ░░░▒▒▒▒▒░░░░ ░░░░░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░░░░░░░░░▒░░░░   ░░░▒▒▒▒▒▒░░░ ░░░░▒▒▓▓▓██████▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░ ░░░░░░▒▒░░░   ░░▒▒▒▒▒▒▒░░░ ░░░▒▒▒▓██████████████▓▓▓▒▒▒▒░░░░░░░░░░░░ ░ ░░░░░░░░░   ░░░▒▒▒▒▒▒▒░ ░░░▒▒▒▓▓███████████▓▓▓▓▒▒▒▒░░░░░░░░░░░░ ░░░░░░░░░░░   ░░░▒▒▒▒▒░░░ ░░░░▒▒▒▓▓▓██████▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░░ ░░░░░░░   ░░░░▒▒▒▒▒░░ ░░░░▒░▒▒▒▒▒▓▓▓▓▓▒▒▒▒▒░░░░░░░░░░░░░░░░░ ░░░░░░░░░   ░░░░░░░░ ░░░░░░░░░░░░░░░░░▒▒░░░░ ░ ░░░░░░░░░ ░░░░░░░░░   ░░░░░░░░░ ░░░░░░░░░ ░ ░░░░░░░ ░░░░░░░░ ░   ░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░ ░░░░░░░░░░   ░░░░▒▒▒▒▒░░░░░░░░░░░ ░░░░░░░░░ ░░░░░░░░░░░   ░░░▒▒▒▒▒▒▒▒░░░░░░░░░ ░░░░░▒░░░░░ ░░░░░░░░░░░░   ░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░░░▒▒▒▒▒▒░░░░░ ░░░░░░░░░░░░   ░░░░▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒░░░░░ ░ ░░░░░▒░░░   ░░░░▒░░▒░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░   ░░░░░░░░ ░░░░░▒▒▓▒▓▓▓▒▒▒▓▒▓▓▒▒▒░░░░░░ ░░░░░░░   ░ ░ ░░░░░▒▒▒▓▓▓▒▓▓▓▓▒▒▒▒▒░░ ░ ░ ░░░░░░░   ░ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░░   ░░░░░▒▒▒▒░░░░░░   ░░ ░░░  [?7h[?25l[?7l ✧ · ████████████████████████·✧   ████████████████████████ ✦ ·   ✦ · ████ ████ ✦   ████ ·████   ████ ████   ✦ ▒▒▒▒ ████   · ▒▒▒▒ ✦ ████ ✧   ✧ ████   ████   ░░░░░░░░░ ░░░ ░░░▒▒▓▓█████▓▓▒░ ▒░░ ░░▒▓▓██████▓▒░░ ░░▒▒▒▒▒▒▒▒░▒░░   ░░░░░░ ░ ░░░░░░░░▒▒▒▓▓▓▓█▓▓▒▒░░ ░░░░░░░▒▓▓█████▓▒▒░░ ░░░░░░▒▒▒▒▒▒░░░░   ░░░░░░ ░░ ░░░░░░░░░░▒▒▒▒▒▒▓▓▓▓▓▒▒░░ ░░░░░░▒▒▓▓█▓▓▓▓▓▒░░░ ░░░░░░░░▒▒▒▒▒░░   ░░░░░░░░░ ░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▒▒░░░░░░░░ ░░░░░░░░░░▒░░░░░░░   ░░░▒░░░░░░░░ ░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░░░░░░░░▒░░░░   ░░░▒▒▒▒░░░░░ ░ ░░░░░░▒▒▓▓▓█████▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒░░░░░░░░░░░ ░░░░░░▒▒░░░   ░░▒▒▒▒▒▒░░░░ ░░░▒▒▒▓██████████▓█▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░ ░ ░░░░░░░░░░   ░░░▒▒▒▒▒▒░░ ░░░▒▒▓▓██████████▓█▓▓▓▓▒▒▒▒░░░░░░░░░░░░ ░░░░░░░░░░   ░░▒▒▒▒▒▒░░ ░░░▒▒▓▓▓▓██████▓▓▓▓▓▓▒▒░░░░░░░░░░░░░░░ ░░░░░░░   ░░░░▒▒▒▒▒░░ ░░░░▒▒▒▒▒▒▓▓▓▓▓▓▓▒▓▒▒▒░░░░░░░░░░░░░░░░ ░░░░░░░░   ░ ░░░░░░░░ ░░░░░░░░░░░░▒▒▒▒▒▒▒▒░░░░░ ░░░░░░░░░ ░░░░░░░░░░   ░░░░░░░░░ ░░░░░░░░ ░░░░░ ░░░░░░░ ░░░░░░░░░░   ░░░░░░░░░ ░░ ░░░░░░░░░ ░░░░░░░ ░░░░░░░░░ ░   ░░░░░▒▒▒▒░░░░░░░░░░░░ ░░░░░░░░░ ░ ░░░░░░░░   ░░░▒▒▒▒▒▒░░░░░░░░░░░ ░░░░░░░░░░ ░ ░░░░░░░░░░   ░░░▒▒▒▒▒▒▒▒░░░░░░░░░ ░░░░░▒▒▒▒░░░░░ ░ ░░░░░░░░░░   ░░░░▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░▒░▒▒▒▒▒▒░░░░░ ░ ░░░░░▒░░░░░░   ░░░░▒▒▒▒░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░   ░░░░░░░░ ░░░░░▒▒▒▒▒▓▒▒▒▒▓▒▒▓▒▒▒░▒░░░░ ░░░░░░░░   ░ ░ ░ ░ ░░░░░▒▒▒▓▓▓▒▓▓▓▓▒▓▒▒▒░░░░ ░ ░░░░░░░   ░ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░ ░░░░░   ░░░▒▒▒▒▒▒░▒▒░░░░ ░   ░░░░░░░ ░  [?7h[?25l[?7l ✧ · ████████████████████████·✧   ████████████████████████ ✦ ·   ✦ · ████ ████ ✦   ████ ·████   ████ ████   ✧ ████ ████ ✧ ✧ ✦ ✦   ✦ ▒▒▒▒ ████   · ▒▒▒▒ ✦ ████ ✧   ░░░░░░░░░░ ░░░ ░░░▒▒▓██████▓▓▒░ ░▒░░ ░░▒▓▓██████▓▒▒░ ░░░▒▒▒▒▒▒▒░▒░░   ░░░░░░░ ░░░░░░░░▒▒▒▓▓███▓▓▒▒░ ░░░░░░░▒▒▓▓████▓▓▒▒░░ ░░░░▒▒▒▒▒▒░░░░   ░░░░░ ░░░░░░░░▒▒▒▒▒▒▓▓▓▓▓▒▒░░ ░░░░░░▒▒▒▓▓▓▓▓▓▓▒▒░░ ░ ░░░░░░▒▒▒░▒░░░░   ░░░░░░░ ░░░░░░░░▒░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░░░▒▒▒▓▓▓▓▓▓▓▒▒▒░░░░░░░ ░░░░░░░░░░▒░░░░░░░   ░░░░░░░░░░░ ░ ░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▒▒▒▒▒░▒░▒▒▒▒▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░░░░░░░░▒░░░░░   ░░░▒▒░░░░░░░ ░ ░ ░░░░░░░▒▒▒▓▓▓▓██▓█▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒░░░░░░░░░░░░ ░░░░░░░░░░░   ░░▒▒▒▒▒▒░░░ ░░▒▒▒▓▓█████████▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░ ░ ░ ░░░░░░░░   ░░░▒▒▒▒▒▒░░ ░░░▒▒▓▓██████████▓█▓▓▓▓▒▒▒▒▒░░░░░░░░░░░░ ░ ░░░░░░░░   ░░▒▒▒▒▒▒░░ ░░▒▒▒▓▓████████▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░ ░░░░░░░░   ░░░▒▒▒▒▒▒░░ ░░░░▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░░░ ░░░░░░░░   ░░░░▒▒░░░░ ░░░░░░░░░▒░▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░░░░░░░ ░░░░░░░░░   ░░░░░░░░ ░░░░░░░░ ░ ░ ░░░░░░ ░░░░░░░ ░░░░░░░░░   ░░░░░░░░ ░ ░░░░░░░░░ ░░░░░░░ ░░░░░░░░░░   ░░░░░▒░░░░░░░░░░░░░░░ ░░░░░░░ ░ ░░░░░░░░░   ░░░▒▒▒▒▒▒░░░░░░░░░ ░ ░░░░░░░░░ ░░░░░▒░░░░   ░░▒▒▒▒▒▒▒░░░░░░░░░░ ░░░░░░▒▒░░░░░ ░░░░░░░░░░░   ░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░ ░ ░░░░░▒▒▒▒▒▒░░░░░ ░ ░░░░▒░░░░░░   ░░▒░▒▒▒▒░░░░░░░░░░░░▒░▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░   ░░░░░░░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▓▒▒▒▒▒░░░░ ░░░░░░░░░░   ░ ░ ░ ░ ░ ░░░░░▒▒▒▒▓▓▒▓▒▓▓▒▓▒▒▒▒░░░ ░ ░░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░░░░   ░░░░▒▒▒▒▒▒░▒▒░░░░░ ░   ░░░░░░░░░░░░  [?7h[?25l[?7l ✧ · ████████████████████████·✧   ████████████████████████ ✦ ·   ✦ · ████ ████ ✦   ████ ·████   ████ ████   ✧ ████ ████ ✧ ✧ ✦ ✦   ✦ ▒▒▒▒ ████   · ▒▒▒▒ ✦ ████ ✧   ░░░░░░░░░░ ░░░▒▒▓██████▓▒░░ ░▒▒░ ░░▒▒▓▓█████▓▒▒░░ ░░░░▒▒▒▒▒▒░▒░░   ░░░░░░░ ░ ░░░░░▒▒▒▓▓▓███▓▓▒▒░ ░░░░░░░░░▒▓▓████▓▓▓▒░░ ░░░░░░▒▒▒▒░▒░░░   ░░░░░░░ ░ ░░░▒░▒▒▒▒▒▒▓▓▓▓▓▒▒░░ ░░░░░░░▒▒▓▓▓▓▓▓▓▒▒░░░ ░░░░░░░▒░░▒░░░░   ░░░░░░░ ░░░░░░░▒░▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░░░▒▒▒▒▓▓▓▓▓▒▒▒░░░░░░░ ░ ░░░░░░░░▒░░░▒░░░   ░░░░░░░░░ ░ ░░░░░░░░░░░░▒▒▒▒▓▓▓▓▒▒▒▒░░░▒░▒▒▒▒▒▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░░░░░░░░▒░░░░░   ░░░░▒░░░░░░░ ░ ░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▒▒▒▒▒▓▒▓▓▓▓▓▓▒▒▒▒▒░░░░░░░░░░░ ░░░░░░░░░░░░░   ░░▒▒▒▒▒▒░░░ ░░░▒▒▒▓▓████████▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░ ░ ░░░░░░░░   ░░▒▒▒▒▒▒░░░ ░░░▒▒▓▓██████████▓█▓▓▓▓▓▒▒▒▒▒░░░░░░░░░░░░ ░░░░░░░░   ░░▒▒▒▒▒▒░░ ░░▒▒▒▓▓████████▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░▒░░░░ ░░░░░░░   ░░░▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▓▓████▓▓▓▓▒▓▒▒▒░░░░░░░░░▒░░░░ ░░░░░░░   ░░░░▒▒░░░ ░░░░░░▒░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░░░░░░░ ░ ░░░░░░░░   ░░░░░░░░░░ ░░░░░░░░ ░░ ░░░░░░░░░░ ░░░░░░░ ░░░░░░░░░   ░░░░░░░ ░░░░░░░░░ ░░░░░░░ ░░░░░░░░░░   ░░░░░▒░░░░░░░░░░░░░░░ ░░░░░░ ░░░░░░░░░░   ░░░░▒▒▒▒░░░░░░░░░░░ ░░░░░░░░ ░░░░░░░░░░░░   ░░▒▒▒▒▒▒▒░░░░░░░░ ░ ░░░░░▒░░░░░░ ░░░░░░░░░░░░   ░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░░░░░░▒▒▒▒▒░░░░░ ░ ░░░░░▒░░░░   ░░░▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░   ░░░░░▒░░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░░░░░░░   ░░░░░░ ░ ░ ░░░░░▒▒▒▒▒▓▒▒▒▒▒▒▓▒▒▒▒░░░ ░ ░░░░░░░░   ░░░░▒▒▒▓▒▒▒▒▒▒▒▒▒▒░▒░░░ ░░░░░░░░   ░░░▒▒▒▒▒▒▒▒▒▒░░░░░ ░  [?7h[?25l[?7l ✧ · ████████████████████████·✧   ████████████████████████ ✦ ·   ✦ · ████ ████ ✦   ✧ ████ ████ ✧ ✧ ✦ ✦   ✦ ▒▒▒▒ ████   · ▒▒▒▒ ✦ ████ ✧   ✧ ████   ████   ░░░░░░░░░░ ░░▒▒▓██████▓▓▒░ ░▒▒▒░ ░░▒▒▒▓██████▓▒░░ ░░░░▒░▒▒▒░▒░░░   ░░░░░░░░ ░░░░░▒▒▒▓█████▓▒▒░░ ░░▒░░░░░░▒▒▓▓▓██▓▓▓▒▒░░ ░░░░░░▒▒░▒░▒░░░   ░░░░░░ ░░░▒▒▒▒▒▒▓▓▓▓▓▓▓▒░░ ░░░░░░░▒▒▒▓▓▓▓▓▓▒▒░░░░ ░░░░░░░▒░░▒░░░░   ░░░░░░░ ░░░░▒▒░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░░░▒▒▒▒▓▓▓▓▒▒▒░░░░░░░ ░ ░░░░░░░░░░░░▒░░░░   ░░░░░░░░░ ░░░░░░░░░░░░▒▒▒▒▓▓▒▒▒▒░░░░░▒░▒░▒▒▒▒▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░▒░░   ░░░░░▒░░░░░░░ ░░░░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▓▓▓▓▓▒▒▒▒▒░░░░░░░░░░░ ░░░░░░░░░░░░░   ░░▒▒▒░░░░░░ ░░░░▒▒▒▓▓███████▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░ ░░░░░░░   ░░▒▒▒▒▒░░░░ ░░░▒▒▓▓█████████▓▓▓▓▓▓▓▓▓▒▒▒▒▒░░░░░░░░░░░ ░░░░░░░░   ░░▒▒▒▒▒▒▒░░ ░░░▒▒▒▓▓████████▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░▒░░░░ ░░░░░░░░   ░░░░▒▒▒▒▒░░ ░░░░▒▒▒▒▓▓▓████▓▓▓▓▓▓▒▒▒▒░░░░░░░░▒░░░░░ ░ ░░░░░░   ░░▒░▒▒░░░ ░░░░░░▒▒▒▒▒▒▓▓▓▒▓▒▒▒▒▒░░░░░ ░░░░░░░░░░ ░░░░░░░░░   ░░░░░░░░░░ ░░░░░░░░░░░░░░░░▒▒░░░░░░ ░░░░░░░ ░░░░░░░░░   ░░░░░░░░░ ░░░░░░░░░ ░ ░░░░░░░ ░░░░░░░░   ░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░ ░░░░░░░░░░░   ░░░░▒▒▒▒░░░░░░░░░░░ ░░░░░░░░ ░░░░░░░░░░░   ░░▒▒▒▒▒▒░░░░░░░░░ ░ ░░░░▒░░░░░░ ░░░░░░░░░░   ░░▒▒▒▒▒▒▒▒░░░░░░░░ ░ ░ ░░ ░░░░▒░▒▒░░░░░ ░ ░░░░▒░░░░   ░░▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░   ░░░▒▒▒░░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░░░░░░░   ░░░░░░░ ░ ░ ░░░░░▒▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒▒░░░ ░ ░ ░░░░░░░░   ░░░▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░░░░   ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░ ░   ░░░░░░▒░░░░░  [?7h[?25l[?7l · ✧     ✦ ✧ ·     · · ·   ✧   · ████████████████████████   ✦ ✦ ████████████████████████   ████ ████ ·  ████ ████ ✦   ████ ████ ·   ████ ████ ·   ✧ ▒▒▒▒ ████ ✦  ▒▒▒▒ ████   ✦ ✧ ✧ ████ · ✦   ████   ░░░░░░░░░░ ░░▒▓▓██████▓▒▒░ ░▒▒▒▒░░ ░ ░▒▒▓▓█████▓▒▒░░ ░░░▒░▒▒▒░▒░░░   ░░░░░░░░ ░░░░▒▒▓▓█████▓▒▒░ ░▒▒░░░░░░▒▒▓▓▓▓█▓▓▓▒▒░░ ░ ░░░░▒▒░▒░▒░░░   ░░░░░░░ ░░░▒▒▒▒▒▓▓▓▓▓▓▓▓▒░ ░░░░░░░░▒▒▓▓▓▓▓▓▒▒▒░░░ ░░░░░░░░░░▒░░░░░   ░░░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▓▒▒▒▒░░ ░░░░░░░▒▒▒▒▓▓▓▓▒▒▒▒░░░░[0;38;5;170m░░ ░ ░░░ ░░░░░░░░░░░░░   ░░░░░░░░ ░░░░░▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░▒░░░▒▒▒▒▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░░░░░░░░░▒░░░   ░░░░░░░░░░░ ░░░░░░░░░░▒▒▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▒▒▒▒▒░░░░░░░░░░░ ░░ ░░░░░░░░░░   ░░░▒░▒░░░░░ ░░ ░░░░▒▒▒▓▓████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░ ░░░░░░░░   ░▒▒▒▒▒▒░░░░ ░░░▒▒▓▓████████▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░ ░░░░░░░░   ░░▒▒▒▒▒▒░░░ ░░░▒▒▒▓▓████████▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░▒░░░░ ░░░░░░░░   ░░░░▒▒▒▒▒░░ ░░░░▒▒▒▓▓██████▓▓▓▓▓▓▒▒▒▒▒░░░░░░░▒░░░░░ ░░░░░░░   ░░░▒▒▒▒░░░ ░░░░░░▒▒▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒░░░░░░░░░░░░░░░ ░░░░░░░░   ░░░▒░░░░ ░░░░░░░░░░░░░▒▒░▒▒░▒▒░░░ ░░░░░░░ ░░░░░░░░   ░░░░░░░░░░ ░░░░░░░░░ ░░░░░ ░░░░░░ ░░░░░░░░░   ░░░░░░░░░░ ░░░░░░░░ ░░░░░░ ░░░░░░░░░░   ░░░░░▒▒▒░░░░░░░░░░░░ ░░░░░░ ░░░░░░░░░   ░░░▒▒▒▒▒▒░░░░░░░░░ ░ ░░░░░░░░░ ░░░░░░░░░░░   ░░░▒▒▒▒▒▒▒▒░░░░░░░░ ░ ░░░░▒▒░░░░░ ░ ░░░░▒░░░░   ░░▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░░░░░░░░░▒▒▒▒▒▒▒░░░░░░ ░░░░░░░░░░░░   ░░▒▒▒▒▒░░░░ ░░░░░▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░░░░░░   ░░░░░░░ ░ ░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░ ░ ░░░░░░░░░░   ░ ░░░▒▒▒▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░ ░░░░░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░ ░ ░   ░ ░░▒▒▒▒▒░░░░░░  [?7h[?25l[?7l · ████████████████████████   ✦ ✦ ████████████████████████   ████ ████ ·   ████ ████ ·   ✧ ▒▒▒▒ ████ ✦  ▒▒▒▒ ████   ░░░░░░░░░░ ░▒▒▓▓██████▓▒▒░ ░▒▓▒▒░░ ░ ░▒▒▒▓█████▓▓▒▒░ ░░░░░▒▒▒░▒░░░   ░░░░░░░░░ ░░░▒▒▒▓▓█████▓▒░░ ░░▒▒▒░░░░░░▒▒▓▓▓██▓▓▓▒░░ ░░░░▒▒░░░▒▒░░░   ░░░░░░░ ░░░▒▒▒▒▓▓▓▓▓█▓▓▒▒░ ░▒░░░░░░░▒▒▒▓▓▓▓▓▓▒▒▒░░ ░░░░░░░░░░▒░▒░░░   ░░░░░░░ ░░░░▒▒▒▒▒▒▒▓▒▓▓▓▒▒▒░░ ░░░░░░░░▒▒▒▓▓▓▓▓▒▒▒░░░░░░ ░ ░░░ ░░░░░░░░░░░░░   ░ ░░░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░░░░░░▒▒▒▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░   ░░░░░░░░ ░ ░░░░░░░░░▒▒▒▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▒▓▓▒▒▒▒▒░░░░░░░░░░░░ ░░ ░░░░░░░░░   ░░░░░▒░░░░ ░ ░░░░░░░░▒▒▒▓▓▓▓██▓▓▓▓▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░ ░░░░░░░░░   ░▒▒▒▒▒▒░░░░ ░░░░▒▒▓▓████████▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░▒░░░ ░░░░░░   ░░▒▒▒▒▒░░░ ░░░▒▒▒▓████████▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░▒░░░░░ ░░░░░░░ ░   ░░░░▒▒▒▒▒▒░░ ░░░░▒▒▒▓▓██████▓▓▓▓▓▓▓▒▒▒▒▒░░░░░░▒░░░░░░ ░░░░░░   ░░░▒▒▒▒░░░ ░░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▒▓▒▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░░░   ░░░░▒▒░░░ ░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░ ░ ░ ░░░░░░░ ░ ░░░░░░░░   ░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░ ░░░░░░ ░ ░░░░░░░░   ░░░░░░░░ ░░░░░░░ ░░░░░ ░░░░░░░░░   ░░░░░░░░░░░░░░░░░░░ ░░░░░░ ░░░░░░░░░   ░░░░▒▒▒▒░░░░░░░░░░ ░░░░░░░░░ ░░░░░░░░░░   ░░░▒▒▒▒▒▒▒▒░░░░░░░ ░ ░░░░░▒░░░░░ ░░░░▒▒░░░░   ░▒▒▒▒▒▒▒▒░░░░░░░░░░ ░░ ░░░░░░░░▒▒▒▒░░░░░░ ░░ ░░░░░░░░░   ░░░▒▒▒▒▒░░░░ ░░░░░░░▒▒░░▒░░▒░▒▒▒▒▒▒▒▒▒░░░░ ░░░░░░░░░   ░░░░░░░░░ ░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░░░░░░░░░░   ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░ ░░░░░░░░   ░░▒▒▒▒▒▓▒▒▒▒▒▒▒▒▒░░░░ ░ ░ ░   ░░░░▒▒▒▒▒░░░░░░░░ ░  [?7h[?25l[?7l · ████████████████████████   ✦ ✦ ████████████████████████   ░░░░░░░░░░ ░▒▒▓▓██████▓▒▒ ░▒▓▒▒░░ ░ ░░▒▒▓█████▓▓▒▒░ ░░░░░░▒▒░▒░░░░   ░░░░░░░░ ░░░▒▒▓▓██████▓▒░░ ░░▒▒▒▒░░░░░▒▒▒▓▓█▓▓▓▓▒▒░░ ░░░░░▒░░░▒▒░░░   ░░░░░░░░ ░░░▒▒▒▒▓▓▓▓██▓▓▒░ ░▒░░░░░░░░▒▒▒▓▓▓▓▓▒▒▒░░ ░ ░░░░░░░░▒░▒░░░   ░░░░░░░ ░░░░▒▒▒▒▒▒▓▓▓▓▓▓▒▒░░ ░░░░░░░░▒▒▒▒▓▓▓▓▒▒▒▒░░░░░ ░ ░░░ ░░░░░░░░░░░░░   ░░░░░ ░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░░░▒▒▒▒▒▒▓▓▒▒▒▒░░░░░░░░░░░░░░░░ ░░░░░░░░   ░░░░░░░ ░ ░░░░▒░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░▒▒▒▒▒▒▒▒▒▓▒▒▒▒▒▒▒░░░░░░░░░░░ ░░ ░░░░░░░░░   ░░░░░░░░░ ░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▓▓▓▓▓▒▒▒▒░▒░░░░░▒░░░░ ░░░░░░░░   ░▒░▒░░▒░░ ░░░░░▒▒▒▓▓███████▓▓▓▒▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░▒▒░░░░ ░░░░░░   ░░░▒▒▒▒▒░░░ ░░░░▒▒▒▓████████▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░▒░░░░░░ ░░░░░░░░   ░░░░▒▒▒▒▒░░ ░░░░▒▒▓▓▓██████▓▓▓▓▓▓▓▒▒▒▒▒░░░░░░▒░░░░░░ ░░░░░░░   ░░░▒▒▒▒░░░ ░░░░░░▒▒▒▓▓▓██▓▓▓▓▓▓▒▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░░░   ░░░░░▒▒░░░ ░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░░░░░░░░░ ░░░░░░ ░   ░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░ ░░░░░░ ░░░░░░░░   ░░░░░░░░░ ░░░░░░ ░░░░░ ░ ░░░░░░░░ ░   ░░░░░░░░░ ░ ░░░░░░░ ░░░░░░ ░░░░░░░░░   ░░░░░▒▒░░░░░░░░░░░░ ░░░░░░░░░ ░░░░░░░░░   ░░░▒▒▒▒▒▒▒░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░ ░   ░░▒▒▒▒▒▒░▒░░░░░░░░ ░░░░░░░░▒▒▒▒░░░░░░ ░░░░░░░░░░   ░░░▒▒▒▒▒░░░░ ░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒░░░░ ░░░░░░░░░░   ░░░░░░░░░░ ░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░░░░░░░░░░   ░░ ░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░░░░░░   ░░▒▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒░░░ ░ ░ ░   ░░░░▒▒▒▒▒▒▒░░▒░░░░ ░  [?7h[?25l[?7l · ████████████████████████   ✦ ✦ ████████████████████████   ░░░░▒░░░░░░ ░▒▒▓███████▓▒░ ░▒▒▓▓▒▒░░░ ░░░▒▒▓█████▓▒▒░░ ░░░░░░▒░▒░░░░   ░░░░░░░░░ ░░░▒▒▓▓█████▓▒▒░ ░░▒▒▒▒▒░░░░░░▒▒▒▓█▓▓█▓▒▒░░ ░░░░░░░░░▒▒░░░   ░░░░░░░░ ░░▒▒▒▓▓▓▓▓██▓▒▒░ ░░▒▒▒░░░░░░░▒▒▒▓▓▓▓▓▒▒░░░ ░ ░░░░░░░▒░░░░░   ░░░░░░░ ░░░░▒▒▒▒▒▓▓▓▓▓▓▓▒▒░░ ░░░░░░░░░░▒▒▓▓▓▓▒▒▒▒░░░░░ ░ ░░░ ░░░░░░░░░░░░   ░░░░ ░░▒░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░░░░░░▒▒▒▒▒▓▒▒▒▒░░░░░░░░░░░░░░░░ ░░░░░░░░   ░░░░░░░ ░ ░░░░▒░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░▒░░▒░▒▒▒▒▓▒▒▒▒▒▒▒░▒░░░░░░░░░░░░ ░░░░░░░░   ░░░░░░░░░░ ░░░░░░░░░░░░░▒▒▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▓▓▒▓▓▒▒▒▒░▒░░░░░▒░░░░░ ░░░░░░░░   ░░░░░░░░░░ ░ ░░░░░▒▒▒▓▓█████▓▓▓▓▓▒▓▒▓▓▓▓▓▓▓▒▒▒░░░░░░░▒▒░░░░ ░░░░░   ░░░▒▒▒▒▒░░ ░░░░▒▒▓▓███████▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒░░░▒░░▒░░░░░░ ░ ░░░░░░   ░░░░▒▒▒▒▒░░ ░░░░▒▒▓▓███████▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░▒░▒░░░░ ░░░░░░   ░░░░▒▒▒░░░░ ░░░░▒▒▒▒▓▓▓███▓▓▓▓▓▓▓▒▒▒▒▒░░░░░░░▒░░░░░ ░░░░░░░   ░░░░▒▒▒░░ ░░░░░░▒░▒▒▒▒▓▒▒▓▒▒▒▒▒▒░░░░░░░░░░░░░░░░░ ░░░░░░░   ░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░ ░░░░░░░░   ░░░░░░░░░░ ░░░░░░ ░ ░░ ░░░░░ ░ ░░░░░░░░░░   ░░░░░░░░░ ░░░░░░ ░░░░ ░░░░░░░░░   ░░░░░░░░░░░░░░░░░░░ ░░░░░░░ ░░░░░░░░░   ░░░░▒▒▒▒▒▒▒░░░░░░░░ ░░░░░░░░░ ░░░░░░░░░░   ░░▒▒▒▒▒▒░▒░░░░░░░ ░ ░░░░░░░░▒░░░░░░ ░░░░░░░░░░░   ░░░▒▒▒▒▒░░░░ ░░░░░░░░░░░░░░░░░░░░▒▒▒▒░▒░░░░ ░░░░░░░░░░   ░░░░▒░░░░░░ ░ ░░░░░░▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░░░░░░░░░░░   ░░░░ ░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░░░░░░   ░░▒▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒▒░░░ ░ ░░░   ░░░░▒▒▒▒▒▒▒░░▒▒░░░░ ░░  [?7h[?25l[?7l · ████████████████████████   ✦ ✦ ████████████████████████   ████ ████ ·  ████ ████ ✦   ░░░░▒░░░░░░ ░▒▒▓███████▒▒░ ░▒▒▓▓▓▒░░░ ░░░▒▒▓▓████▓▓▒▒░░ ░░░░░░▒░▒░░░░░░   ░░░░░░░░░ ░░░▒▒▓▓█████▓▒░░ ░▒▒▒▓▒▒░░░░░░▒▒▒▓▓▓▓█▓▓▒▒░ ░ ░░░░░░░░▒░░░   ░░░░░░░░░ ░░▒▒▒▓▓▓████▓▒▒░ ░░▒▒▒░░░░░░░▒▒▒▓▓▓▓▓▒▒▒░░ ░░░░░░░░░░░░░   ░░░░░░░ ░░░░▒▒▒▒▒▓▓▓▓▓▓▓▒▒░░ ░░░░░░░░░░░▒▒▒▓▓▒▒▒▒▒░░░░ ░ ░░░ ░░░░░░░░░░░░░   ░░░░░ ░░▒░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░░░░░░▒▒▒▒▒▓▒▒▒▒░░░░░░░░░░░░░░ ░ ░░░░░░░░   ░░░░░ ░░░░░░▒░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒░░░░░▒▒▒▒▒▒▒▒▒▒▒░▒░░░░░░░░░░░░ ░░░░░░░░   ░░░░░░░ ░░░░░░░░░░░░░▒▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▒▒▒▒░▒░░░░░▒░░░░░ ░░░░░░░   ░░░░░░░░░ ░ ░░░░░░░░▒▒▒▓▓█▓██▓▓▓▓▒▓▒▒▒▒▒▒▒▓▓▓▒▒▒░░░░░░░▒▒░░░░ ░░░░░   ░░░░▒▒▒▒░░░ ░░░░▒▒▒▓████████▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒░░░▒░░▒▒░▒░░░ ░░░░░   ░░▒░▒▒▒░░░░ ░░░▒▒▒▓▓███████▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░▒░▒░░░░ ░░░░░░   ░░░▒░▒▒▒░░ ░░░░▒▒▒▒▓▓▓████▓▓▓▓▓▓▓▒▒▒▒░░░░░░░▒░░░░░ ░░░░░   ░░░░░▒▒░░░ ░░░░░░▒▒▒▒▒▒▓▓▒▓▓▒▒▒▒▒▒░░░░░░░░░░░░░░░░ ░░░░░░░   ░░░░░░░░ ░░░░░░░░░░░░░░▒▒▒▒░░░░░░░░░ ░░░░░░░ ░░░░░░░░   ░░░░░░░░ ░░░░░ ░ ░░ ░░░░░ ░ ░░░░░░░░░░   ░░░░░░░░░░ ░░░░░░ ░░░░ ░░░░░░░░░   ░░░░░░░░░░░░░░░░░░░ ░░░░░░░ ░░░░░░░░░░   ░░░░▒░▒░▒░░░░░░░░░░ ░ ░░░░░░░ ░░░░░░░░░░   ░░░▒▒▒▒▒░░░░░░░░░░ ░░░░░░░░░░░░░░░ ░░░░░░░░░   ░░░▒▒▒▒▒▒░░░░ ░░░░░░░░░░ ░░░░░░░░▒░▒▒░▒░░░░ ░░░░░░░░░░   ░░░░▒▒░░░░░░ ░ ░░░░░░▒▒░░▒░▒▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░░░░░░░░░░░   ░░░░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░░░░░░   ░ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░ ░ ░░░ ░   ░░░▒▒▒▒▒▒▒▒░░▒▒░░░░ ░░ ░  [?7h[?25l[?7l · ████████████████████████   ✦ ✦ ████████████████████████   ████ ████ ·  ████ ████ ✦   ████ ████ ·   ████ ████ ·   ✧ ▒▒▒▒ ████ ✦  ▒▒▒▒ ████   ░░░░▒░░░░░░ ░░▒▒███████▓▒░░ ░▒▒▓▓▓▓▒▒░░ ░░░░▒▓▓▓████▓▓▒░░ ░░░░░▒░▒░░░░░░   ░░░░░░░░░ ░░░▒▒▓██████▓▒░ ░▒▒▒▓▒▒▒░░░░░░▒▒▒▓▓▓█▓▓▒▒░░░ ░░░░░░░░▒░░░░   ░░░░░░░░░░ ░░▒▒▒▓▓█████▓▒▒░ ░░▒▒▒░░░░░░░▒▒▒▓▓▓▓▓▒▒▒░░ ░░░░░░░░░░░░░   ░░░░░░░ ░ ░▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒░ ░▒░░░░░░░░░▒▒▒▓▒▒▒▒▒▒░░░░ ░ ░ ░ ░░░░░░░░░░░░░   ░░░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░ ░░░░░░░░░▒▒▒▒▒▓▒▒▒▒░░░░░░░░░░░░░░ ░ ░░░░░░░░░   ░░░░░ ░░░░▒░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░ ░░▒░░░░░░▒▒▒▒▒▒▒▒▒▒░▒░░░░░░░░░░░░ ░░░░░░░░░   ░░░░░░░ ░░░░░░░░░░░░▒▒▒▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒░▒░░░░░▒░░░░░░ ░ ░░░░░   ░░░░░░░░░ ░░░░░░░░░░░░▒▒▒▓▓▓▓█▓▓▓▓▓▒▒▒▒▒▒▒▒▒▓▓▓▒▒▒░░░░░░░▒▒░░░░ ░░░░░░   ░░░░▒░▒▒░░░ ░░░░░░░▒▒▒▓████████▓▓▓▓▓▓▓▓▓▓▒▓▒▒▒▒░░▒▒░▒▒░▒░░░░ ░░░░   ░░▒░▒▒▒░░░ ░░░▒▒▓▓▓███████▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░▒░▒░░░░░ ░░░░░░   ░░░▒▒▒▒▒░░ ░░░░▒▒▒▓▓██████▓▓▓▓▓▓▓▒▒▒▒▒░▒░░░░▒░░░░░ ░░░░░   ░░░▒░▒▒░░░ ░░░░░▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒░░░░░░░░░░░░░░░ ░░░░░░░   ░░░░░░░░░ ░░░░░░░░░░░░░▒▒▒▒▒░▒▒▒▒░░░░ ░░░░░░░ ░░░░░░░░   ░░░░░░░░░ ░░░░░ ░ ░░░░░░░░░░ ░ ░░░ ░ ░░░░░░░░░   ░ ░░░░░░░░ ░░░░░░ ░░░░░ ░░░░░░░░   ░░░░░░░░░░░░░░░░░░░ ░░░░░░░ ░░░░░░░░░   ░░░░▒░▒░░░░░░░░░░░░ ░░░░░░░ ░░░░░░░░░░   ░░░▒▒▒▒▒░░░░░░░░░░ ░░░░░░░░░░░░░ ░░░░░░░░   ░░░░▒▒▒▒▒▒░░░░░░░░░░░ ░░ ░ ░░░░░░░░░░░▒░░░░ ░░░░░░░░░   ░░░▒▒▒░░░░░░ ░░░░░░░░▒░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░░░ ░░░░░░░░   ░░░░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░░░░░░░░   ░ ░ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░░░░░░   ░░░▒▒▒▒▒▒▒▒▒░▒▒░░░░░░ ░░░░  [?7h[?25l[?7l · ████████████████████████   ✦ ✦ ████████████████████████   ████ ████ ·  ████ ████ ✦   ████ ████ ·   ✧ ▒▒▒▒ ████ ✦  ▒▒▒▒ ████   ✦ ✧ ✧ ████ · ✦   ████   ░░░░▒░░░░░░ ░▒▒▒██████▓▓▒░░ ░░▒▒▓▓▓▓▒▒░░ ░░░░▒▒▓▓████▓▓▒░░ ░░░░░▒░▒░░░░░░   ░░░░░░░░░ ░░░▒▓▓█████▓▓▒░ ▒▒▒▓▓▓▒▒▒░░░░░▒▒▒▓▓▓█▓▓▒▒░░░ ░░░░░░░░░░░░░   ░░░░░░░░░ ░░▒▒▓▓█████▓▓▒░ ░▒▒▒▒▒░░░░░░░▒▒▒▓▓▓▓▒▒▒▒░░░ ░ ░░░░░░░░░░░   ░░░░░ ░ ░▒▒▒▒▓▓▓▓▓▓▓▓▒▒░ ░▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░ ░ ░░░ ░░░░░░░░░   ░░░░░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▓▒▒░░░ ░▒░░░░░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░░░░   ░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░▒░░░░░░▒▒▒▒▒▒▒▒▒▒░▒░░░░░░░░░░░░ ░░░░░░░░░   ░░░░░ ░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒░░░▒▒▒▒▒░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░░▒░░░░░░ ░ ░░░░░   ░░░░░░░ ░ ░░░░░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▒▒▒▒▒░░░░░▒▒░░░░░░ ░░░░░   ░░░░░░░░░░░ ░ ░░░░░░░▒▒▒▓███████▓▓▓▓▒▒▒▓▓▓▓▒▓▒▒▒▒░▒▒▒░▒▒░▒░░░░ ░░░░   ░░▒░░▒▒░░░ ░░░▒▒▓▓████████▓▓▓▓▓▓▓▓▓▓▒▒▒▒░▒░░▒░▒░▒░░░░ ░░░░░░   ░░░▒▒▒▒▒░░ ░░░░▒▒▓▓▓██████▓▓▓▓▓▓▓▓▓▒▒▒░▒░░░░▒░░░░░░ ░░░░░   ░░░▒▒▒▒░░░ ░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒░░░░░░░░░░░░░░░ ░░░░░░ ░   ░░░░░░░░ ░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░ ░ ░░░░░░░ ░░░░░░░░   ░░░░░░░░ ░░░░░ ░ ░░ ░░░░░░░░░░ ░ ░ ░░░ ░░ ░░░░░░░   ░░░░░░░░░ ░░░░░ ░░░░░ ░░░░░░░░░   ░░░░░░░░░ ░ ░░░░░░░ ░░ ░░░░ ░░░░░░░░░   ░░░░▒░▒░░░░░░░░░░░░ ░░░░░░░ ░ ░░░░░░░░   ░░░▒▒▒░▒░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░░   ░░░░▒▒▒▒▒▒░░░░░░░░░░ ░░░░░░░░░░▒░░░░ ░ ░░░░░░░░   ░░░▒▒▒░░░░░░░░░░░░░░░░░░░░░░▒▒▒░▒░▒▒▒░░░░░ ░ ░░ ░░░░░░░░░   ░░░░░░░░░░ ░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░ ░░ ░░░░░░░░   ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░ ░░░░   press ⏎ to skip ░  [?7h[?25l[?7l · ████████████████████████   ✦ ✦ ████████████████████████   ████ ████ ·  ████ ████ ✦   ████ ████ ·   ████ ████ ·   ✧ ▒▒▒▒ ████ ✦  ▒▒▒▒ ████   ████   ░░░░░░░░░░░ ░▒▒▓██████▓▓▒░░ ░▒▒▓▓▓▓▓▒▒░░░░░ ░░▒▓▓████▓▓▒▒░░ ░░░░░░▒░░░░░░   ░░░░░░░░░ ░░░▒▓▓█████▓▒▒░ ░▒▒▓▓▓▓▓▒▒░░░░░░░▒▓▓▓█▓▓▓▒▒░░ ░░░░░░░░░░░░░   ░░░░░░░░░ ░░▒▒▓▓█████▓▓▒░ ▒▒▒▒▒▒▒░░░░░░░▒▒▒▓▓▓▒▒▒▒░░░ ░ ░░░░░░░░░░░   ░ ░░░░░ ░ ░▒▒▒▒▓▓▓██▓▓▓▒▒░ ░▒▒░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░ ░ ░ ░░░ ░░░░░░░░░   ░░░░░░░ ░░░▒▒▒▒▒▓▒▓▓▓▒▓▒▒░░ ░▒░░░░░░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░░░░░░░░░░   ░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░▒░░░░░░░░▒▒▒▒▒▒▒▒░▒░░░░░░░░░░░░ ░░░░░░░   ░░░░░░ ░░░░░▒░▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░▒▒░░░░▒▒▒▒▒▒▒▒▒▒▒░▒░░░▒░░░░░░ ░░░░░   ░░░░░░ ░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░▒▒░░░░░░ ░░░░   ░░░░░░░░░ ░░ ░░░░░░░▒▒▒▓▓▓████▓▓▓▒▒▒▒▒▒▒▓▒▒▓▒▒▒▒▒▒▒▒░▒▒░▒░░░░ ░░░░░   ░░▒░░░░░░░ ░░░▒▓▓▓███████▓▓▓▒▓▒▓▓▓▓▓▒▒▒▒░▒░░▒░▒▒▒░░░░ ░░ ░░░   ░░░▒░▒░░░ ░░░░░▒▒▓▓▓██████▓▓▓▓▓▓▓▓▓▒▒▒▒▒░░░░▒░░░░░░░ ░░░░░   ░░░▒▒▒▒░░░ ░░░░░▒▒▒▓▓▓▓█▓▓▓▓▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░ ░░░░ ░   ░░░▒▒░░░░ ░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░ ░░░░░░░ ░ ░░░░░░░░   ░░░░░░░░ ░░░░░░░ ░░░░░░░░░░░░░░░░░░░ ░ ░░░░░░ ░░░░░░░ ░   ░░░░░░░ ░░░░░ ░ ░░░░░ ░░░░░░░   ░░░░░░░░ ░░░░░░░ ░░░░ ░░░░░░░░░░   ░░░░░░░░░░░░░░░░░░ ░ ░░░░ ░░░░░░░░░   ░░░▒▒▒░▒░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░   ░░░░▒▒▒▒▒▒░░░░░░░░░░ ░ ░░░░░░░░░░░░░ ░ ░░░░░░░░░   ░░░░▒▒▒░░░░░░░░░░░░░░░░░░░░░░▒░░░▒░▒▒░░░░░░ ░ ░ ░░░░░░░░░   ░░░░░░░░░░ ░░░░░░░▒▒▒▒░▒░░▒▒░▒▒▒▒▒▒▒░░░░░ ░░ ░░░░░░░░░   ░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░ ░░░░░░░░   ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░▒░░░░ ░░░░   ░ press ⏎ to skip ░ ░  [?7h[?25l[?7l ✦ · ✧ ·   ✦ ·   · ✧   ·     ✧ ✦ ✧   ✦ · ████████████████████████   ✧ ████████████████████████   ████ ████ ✧   ✦ ████ ████ ✧   ✧ ████ ████   ✧ ✧ · ████ ████ ✦   · ▒▒▒▒ ████ ·   ▒▒▒▒ ████ ·   · ████ ✦ ✧ ✧ ✦   ░░░░░░░░░░░░ ░▒▒▓█████▓▓▒░░░ ░░▒▒▓▓▓▓▓▓▒▒▒░░░ ░░▒▒▓▓███▓▓▓▒▒░ ░ ░░░░░░░░░░░░   ░░░░░░░░░░ ░░▒▒▓▓█████▓▒░░ ░░▒▓▓▓▓▓▓▒▒░░░░░░░▒▒▓▓█▓▓▓▒▒░░ ░░░░░░░░░░░░   ░░░░░░░░░░ ░▒▒▒▓▓█████▓▒▒░ ░▒▒▓▒▓▒▒░░░░░░░░▒▒▓▓▓▓▓▒▒░░░░ ░ ░░░░░░░░░░░   ░░░░░░░ ░ ░▒▒▒▒▓▓▓██▓▓▓▒▒░ ░░▒▒▒░░░░░ ░░▒▒▒▒▒▒▒▒▒▒░░░░░ ░ ░ ░░░ ░░░░░░░░░   ░░░░░░ ░░░▒▒▒▒▒▓▓▓▓▓▒▓▒▒░░ ░▒░░░░░░░░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░ ░ ░░░░░░░   ░░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░▒░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░ ░ ░░░░░   ░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░▒░░░░░▒▒▒▒▒▒▒▒▒▒▒░▒░░░▒░░░░░░░ ░░░░░   ░░░░░░░ ░░░░░░░░░░░▒▒▒▒▒▓▓▓▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░▒▒░░░░░░ ░░░░   ░░░░░░░░░ ░░░░░░░░░░▒▒▒▓▓▓█▓█▓▓▓▒▒▒▒▒▒▒▒▒▒▒▓▒▒▒▒▒▒▒▒░▒▒▒▒▒░░░ ░░░░   ░░▒░░░░░░░ ░ ░░░▒▓▓▓███████▓▓▒▒▓▒▓▓▓▓▓▒▒▒▒▒▒░░▒░▒▒▒▒░░░ ░░░ ░   ░░░▒░▒░░░ ░░░░░▒▒▓▓███████▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░▒░░▒▒░░░ ░░░░░ ░   ░░░▒▒▒░░░ ░░░░░▒▒▒▓▓▓███▓▓▓▒▒▓▒▒▒▒▒▒░░░░░░░░░░░░░░ ░ ░░░░░░░   ░░░░▒▒░░░ ░░░░░░░▒▒▒▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░ ░░░░░░░░   ░░░░░░░░ ░░░░░░░ ░░░░░▒░▒░▒░░░░░░░░░ ░ ░░░░░░ ░░░░░░ ░   ░░░░░░░ ░░░░░ ░░ ░░ ░ ░░░░░ ░░░░░░   ░ ░░░░░ ░░░░░░░ ░░ ░ ░░░░░░░░░   ░░░░░░░░░░░░░░░░░ ░ ░░░░ ░░░░░░░░░   ░░░▒▒▒░░░░░░░░░░░ ░░░░░░░░ ░░░░░░░░░░   ░░░░▒▒▒▒░░░░░░░░░░░ ░ ░ ░░░░░░░░░░░ ░░░░░░░░░   ░░░░▒▒▒░░░░░░░░░░░░░░ ░░ ░ ░░░░░░░░░░░░░░░ ░ ░░░░░░░░░   ░░░░░░░░░░░ ░░░░░░░▒░▒░░░░░░░░▒░░▒▒▒▒░░░░░░░ ░░░░░░░░░░   ░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░ ░░░░░░░░   ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░▒░░░░ ░░░░░░   ░ press ⏎ to skip ░ ░░  [?7h[?25l[?7l ✦ · ████████████████████████   ✧ ████████████████████████   ░░░░░░░░░░░░ ░░▒▓▓████▓▓▒▒░ ░ ░░░▒▒▓▓▓▓▓▓█▓▓▒▒░░░ ░░▒▒▓▓▓███▓▒▒░░ ░░░░░░░░░░░░░   ░░░░░░░░ ░▒▒▒▓▓███▓▓▒▒░ ░░▒▓▓▓▓▓▓▓▓▒▒░░░░░░░▒▒▓▓▓▓▓▓▒▒▒░ ░░░░░░░░░░░░   ░░░░░░░░ ░ ░▒▒▒▓▓███▓▓▓▒░░ ░░▒▒▓▓▓▓▒▒░░░░░░░▒▒▒▒▓▓▓▒▒▒░░░ ░ ░░░░░░░░   ░░░░░░░ ░ ░▒▒▒▒▓▓███▓▓▒▒░░ ░▒▒▒▒▒▒░░░ ░░░▒▒▒▒▒▒▒▒▒░░░░ ░ ░ ░░░ ░░░░░░░░   ░░░░░░ ░░░▒▒▒▒▓▓▓▓▓▓▒▒▒░░ ░░▒░▒░░░░ ░░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░░   ░░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▓▒▒▒▒░░ ░▒░░░░ ░░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░ ░ ░░░░   ░░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░▒░░░░░░░░▒▒▒▒▒▒▒▒░▒░░░▒░░░░░░░░ ░░░   ░░ ░░ ░░░░▒░▒░░░░░▒▒▒▒▒▒▒▒▒▒▒░░░▒░▒░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░▒▒░▒░░░░░ ░░░   ░░░░░░░░ ░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒░░░ ░░░░   ░░░░░░░ ░ ░░ ░░░░▒▒▓▓▓▓████▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░▒░▒▒▒▒▒░░░ ░░ ░   ░░░▒░░░░░ ░░░░▒▒▒▓▓██████▓▓▓▓▒▓▒▓▒▓▓▒▒▒▒▒▒░░▒░▒▒▒▒░░░ ░░░░░   ░░░░▒░░░░ ░░░░▒▒▓▓▓█████▓▓▓▒▒▒▓▒▒▒▒▒▒▒░▒░░░░░░░░░░░ ░░░░░░░   ░░░░▒░░░░ ░░░░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░ ░░░░░   ░░░░▒░░░ ░░░░░░░░░▒▒▒▒▒▒▒▒░▒▒▒▒░░░░░ ░░░░░░░░ ░░░░░░░   ░░░░░░ ░░░░ ░░░░░░░░░░░░░░ ░░░░░ ░░░░░░   ░ ░░ ░░░░░░ ░ ░ ░░░░░░░░   ░░░░░ ░ ░░░░░░░░░ ░ ░░ ░░░░░░░░░   ░░░░░░░░░░░░░░░░ ░ ░░░░░ ░░░░░░░░   ░░░░░▒▒▒░░░░░░░░░ ░░ ░ ░ ░░░░░ ░░░░░░░░░░   ░░░░░▒▒▒░░░░░░░░░░░░ ░░░░░░░░░░░░░░░ ░ ░░░░░░░░░░   ░░░░▒░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░▒▒▒░░░░░░░ ░░░░░░░░░░░   ░░░░░░░ ░░░░░▒▒▒▒▒▒░▒▒▒▒▒░▒▒▒▒▒░░░░░░ ░ ░░░░░░   ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░▒░░░░░ ░░░░░░   ░░░ press ⏎ to skip ░░░ ░░  [?7h[?25l[?7l ✦ · ████████████████████████   ✧ ████████████████████████   ░░░░░░░░░░░░ ░░▒▓▓█▓█▓▓▒▒▒░ ░ ░░▒▒▓▓▓▓▓▓▓█▓▓▒▒░░░ ░░▒▓▓▓███▓▓▒▒░ ░░░░░░░░░░░░░   ░░░░░░░░ ░▒▒▒▓▓███▓▓▒▒░ ░░▒▒▓▓▓▓█▓▓▓▓▒▒░░░░░░▒▒▒▓▓▓▓▓▒▒▒░ ░░ ░░░░░░░░░   ░░░░░░░░ ░▒▒▒▓████▓▓▒▒░░ ░░▒▒▒▓▓▓▓▒▒▒░░░░░░░▒▒▒▓▒▓▒▒▒░░░ ░ ░░░░░░░░   ░░░░░░░ ░ ░▒▒▒▒▓▓███▓▓▒▒░ ░░▒▒▒▒▒▒▒░░ ░░░▒▒▒▒▒▒▒▒▒▒░░░ ░ ░ ░ ░ ░░░░░░░░   ░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▓▒▒▒░ ░░▒▒▒░░░░ ░░░░░▒▒▒▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░░   ░░░░░░ ░░░░▒▒▒▒▒▒▓▓▓▓▓▒▒░░ ░▒▒░░░ ░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░░░   ░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░▒░░░░░░░░░▒▒▒▒▒▒▒░▒░░░▒░░░▒░░░░ ░░░ ░   ░░ ░░ ░ ░░░▒▒▒▒▒░▒▒░▒▒▒▒▒▒▒▒░░░░░░░░▒░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░▒▒░▒▒░░░░ ░░░   ░░░░░ ░░░░░░░░░░░░▒▒▒▒▓▓▓▒▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒░░ ░░░░   ░ ░░░░░░░ ░░ ░░ ░░░░▒▒▓▓▓▓▓█▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░▒░▒▒▒▒▒░░░░ ░░ ░   ░░░░░░░░░ ░░░░▒▒▒▓▓██████▓▓▓▓▒▒▒▒▒▓▓▒▒▒▒▒▒░░▒░▒▒▒▒░░░░ ░░░░░   ░░░░░▒░░░░ ░░░░▒▒▓▓██████▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░░▒░░░░░░░ ░░░░   ░░░░░▒░░░░ ░░░░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░   ░░░░▒░░░ ░░░░░░░░▒▒▒▒▒▒▒▒▒░▒▒▒▒░▒░░░░░░░░░░░░░ ░░░░░░   ░░░░░░░░ ░░░ ░ ░░░░░░░░░░░░░░░░░ ░░░░░ ░ ░░░░░░   ░░░░░░ ░ ░░░░ ░ ░ ░ ░ ░░░░░░ ░   ░░░░░░ ░ ░░░░░░ ░░░░░░░ ░   ░░░░░░░░░░░░░░░░ ░░ ░ ░░░░░░░░   ░░░░░░▒░░░░░░░░░░ ░░░░░ ░░░░░░░░   ░░░░░░▒▒▒░░░░░░░░░░ ░░░░░░ ░░░░░░░░ ░ ░░░░░░░░░░   ░░░░▒░░░░░░░ ░░░░░░░░░░░ ░░░░░░░░░░░▒▒░░░░░░░ ░░░░░░░░░░░   ░░░░░░░░ ░░░░░▒░░▒░▒░░▒░▒▒░▒▒▒▒▒░░░░░░ ░░░░░░░   ░ ░░░░░░▒▒▒▒▒▒▒▒▒░▒▒░░▒░░░░░░ ░░░░░░░░   ░░░ press ⏎ to skip ░░░ ░░ ░  [?7h[?25l[?7l ✦ · ████████████████████████   ✧ ████████████████████████   ████ ████ ✧   ✦ ████ ████ ✧   ░░░░░░░░░░░ ░░▒▒▓▓█▓▓▓▒▒▒░░ ░░░░▒▒▓▓▓▓▓▓▓█▓▓▒▒▒▒░ ░░▒▒▓▓▓██▓▓▓▒░░ ░░░░░░░░░░░░   ░░░░░░░░ ░░▒▒▓▓▓▓█▓▓▒▒░░ ░░░▒▒▓▓▓▓█▓█▓▓▒▒░░░░ ░▒▒▒▓▓▓▓▓▓▒▒░░ ░░ ░░░░░░░░░ ░   ░░░░░░░ ░░▒▒▒▓███▓▓▓▒▒░ ░▒▒▓▓▓▓▓▓▓▒▒░░░░░░░░▒▒▒▒▓▒▒▒▒░░░ ░ ░ ░░░░░░   ░░░░░░░ ░ ░▒▒▒▒▓▓███▓▓▒▒░ ░▒▒▒▓▒▒▒▒░░ ░░░░▒▒▒▒▒▒▒▒▒░░░░░ ░ ░ ░ ░░░░░░░░   ░░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▓▒▒▒░ ░░▒▒▒▒▒░░ ░░░░░▒▒▒▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░░░░   ░░░░░░ ░░░░▒▒▒▓▒▒▓▓▓▓▓▒▒░░ ░▒▒▒░░░░░ ░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░░░   ░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░▒░░░░░░░░░░▒▒▒▒▒▒▒▒░░░▒░░░▒░░░░ ░░░ ░   ░░ ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒░░░░░░░░▒▒▒▒▒▒▒▒▒░░░▒▒░▒▒▒░░░░ ░   ░░ ░░░░░░░░░░░░░▒▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒▒▒░░▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒░░░ ░ ░░   ░ ░░░░░ ░░░░░░ ░░░░▒▒▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░▒░▒▒▒▒▒░░░░ ░ ░   ░░░░░░░░░ ░░░░▒▒▒▒▓▓██████▓▓▓▒▒▒▒▒▒▓▓▒▒▒▒▒▒░░▒░▒▒▒▒░░░░ ░░ ░░   ░░░░░▒░░░░ ░░░░░▒▒▓▓██████▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░░▒░░░▒░░░░ ░░░░   ░░░░░▒░░░░ ░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░   ░░░░░▒░░░ ░░░░░░░▒▒▒▒▒▓▒▒▒▒░▒▒▒▒▒▒▒░░░░░░░░░░░░ ░░░░░░   ░ ░░░░░░░ ░░ ░░░░░░░░░░░░░░░░░░░░ ░ ░░░░░░░ ░░░░░░ ░   ░░░░░░ ░░░░ ░░░░ ░ ░ ░░░░░░░░   ░░░░ ░ ░░░░░ ░░░░░░░░░   ░ ░░░░░░░░ ░░░░░░░ ░ ░ ░░░░░░░   ░░░░░░░░░░░░░░░░░ ░ ░░░ ░░░░░░░░░   ░░░░░░▒▒▒░░░░░░░░ ░ ░░░░░ ░░░░░░░░ ░ ░░░░░░░░░░   ░░░░░▒░░░░░░░ ░░░░ ░░░ ░ ░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░   ░░░░░░░░░░ ░░░░░░░░░░░░░▒░░▒░░▒▒▒▒░░░░░░ ░░░░░░░░   ░ ░ ░ ░░░░░░▒▒▒▒▒▒▒▒▒░▒▒░░▒░░░░░░ ░░░░░░░░░   ░░░░ press ⏎ to skip ░░░ ░░ ░  [?7h[?25l[?7l ✦ · ████████████████████████   ✧ ████████████████████████   ████ ████ ✧   ✦ ████ ████ ✧   ✧ ████ ████   ✧ ✧ · ████ ████ ✦   · ▒▒▒▒ ████ ·   ▒▒▒▒ ████ ·   ░░░░░░░░░░░ ░░▒▒▓▓▓▓▓▒▒▒░░ ░░▒▒▒▓▓███▓▓▓█▓▓▓▓▒▒░░ ░░░▒▓▓▓▓██▓▓▒░░ ░░░░░░░░░░░   ░░░░░░░░ ░░▒▒▓▓▓▓▓▓▓▒▒░ ░░░▒▒▒▓▓▓▓▓▓██▓▓▒▒▒░░░ ░░░▒▓▓▓▓▓▓▒▒░░ ░ ░░░░░░░░░ ░   ░░░░░░░ ░░▒▒▓▓▓█▓▓▓▒▒░░ ░░▒▒▓▓▓▓▓▓▓▒▒▒░░░ ░░▒▒▒▒▓▒▒▒▒▒░░ ░ ░░░░░░ ░   ░░░░░░░ ░ ░▒▒▒▒▓▓██▓▓▒▒░░ ░░░▒▒▓▓▓▒▒▒▒░░ ░░░░░▒▒▒▒▒▒▒▒▒░░░░ ░ ░ ░ ░░░░░   ░░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▓▒▒▒░ ░░▒▒▒▒▒░░░░░ ░░░░▒▒▒▒▒▒▒░░░░░░░░░░░░ ░░░░░░   ░░░░░░ ░░░░▒▒▒▓▒▓▓▓▓▓▒▒▒░░ ░░▒▒▒░░░░░ ░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░░░   ░░░ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░ ░░▒░░░░░░░░░░▒░▒▒▒▒▒▒░░░▒░░░▒░░░░ ░ ░   ░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒░░░░ ░░░▒░░░░░░░░▒▒▒▒▒▒▒▒▒░░░▒▒░▒▒▒░░░░   ░░ ░░░░▒▒░▒░░░░░▒▒▒▒▒▒▒▒▒▒▒░▒░▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒░▒▒░▒▒▒▒▒░░ ░ ░░   ░ ░░░░ ░░░░░░░░░░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒░▒▒▒▒▒░░░░ ░ ░   ░░░░░░░ ░░░░▒▒▒▒▓▓█████▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒░▒▒▒▒░░░░ ░ ░░   ░░░░░░░░░ ░░░░░▒▒▓▓██████▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░░▒░░░▒▒░░░ ░ ░░   ░░░░░▒░░ ░░░░░▒▒▒▓▓▓█▓▓█▓▓▓▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░░░░░░   ░░░░░░░░░░ ░░░░░░░▒▒▓▓▒▓▓▒▒▒░▒▒▒▒▒▒▒▒░░░░░░░░░░░░ ░░░░░   ░░░░░░░░░ ░░░░░░░░░▒▒▒░▒░░░░░░░░░░░░ ░░░░░░░░ ░░░░░░░░   ░ ░░░░░ ░░░░ ░░░ ░░░░░░ ░ ░ ░░░░░░░   ░ ░░░░ ░░░░ ░░░░░░░░░░   ░░ ░░░░░░ ░ ░░░░░ ░ ░ ░░░░░░░░   ░░░░░░░░░░░░░░░░ ░ ░░ ░░░░░░░░   ░░░░░░▒▒░░░░░░░░░ ░ ░░░ ░ ░░ ░░░░░ ░ ░░░░░░░░░   ░░░░░▒░░░░░░░ ░░░░ ░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░   ░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░▒▒░░░░░░ ░░░░░░░░   ░ ░░░ ░ ░░░░░░▒▒▒▒░▒▒░▒░▒▒░░▒░░░░░░ ░░░░░░░░░   ░░░░ press ⏎ to skip ░░░ ░ ░░░░ ░  [?7h[?25l[?7l ✦ · ████████████████████████   ✧ ████████████████████████   ████ ████ ✧   ✦ ████ ████ ✧   ✧ ✧ · ████ ████ ✦   · ▒▒▒▒ ████ ·   ▒▒▒▒ ████ ·   · ████ ✦ ✧ ✧ ✦   ████   ░░░░░░░░░░░ ░░▒▒▓▓▓▓▓▒▒░░░ ░░░▒▒▓▓████▓▓▓▓▓▓▓▓▒▒░░ ░░░▒▒▓▓▓██▓▓▒▒░░ ░ ░░░░░░░░░   ░░░░░░░░ ░░▒▒▓▓▓▓▓▓▒▒░░ ░░▒▒▒▓▓██▓▓▓▓██▓▓▒▒░░░ ░░▒▒▓▓▓▓▓▒▒▒░░ ░░░░░░░░░   ░░░░░░░ ░▒▒▒▓▓▓█▓▓▒▒▒░ ░░▒▒▒▓▓▓▓█▓▓▓▒▒░░░ ░░▒▒▒▒▓▒▓▒▒▒░░░ ░ ░ ░░░░ ░   ░░░░░░░ ░ ░▒▒▒▒▓▓██▓▓▒▒░░ ░░▒▒▒▓▓▓▓▓▒▒▒░ ░░░░░░▒▒▒▒▒▒▒▒░░░░░░ ░ ░ ░░░░░   ░░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▓▒▒▒░ ░░▒▒▒▒▒▒░░░░░ ░░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░░   ░░░░░░ ░░░░▒▒▒▓▒▓▓▓▓▓▒▒░░ ░░▒▒▒▒░░░░ ░░░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░   ░░░ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░▒▒░░░░░░ ░░░░▒░░▒▒▒░░░▒░░░▒░░░░░ ░ ░   ░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░▒░░░░ ░░░▒▒▒▒▒▒▒▒░░░▒▒░▒▒▒░░░░   ░ ░░░░░▒▒░▒▒░░░░▒▒▒▒▒▒▒▒░▒░░░░░▒▒░░░░░░░▒▒▒▒▒▒▒▒▒░▒▒░▒▒▒▒▒░░ ░░   ░ ░░░ ░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒░▒▒░▒▒▒▒▒▒▒░░░ ░   ░░░░░░░ ░ ░░░░░▒▒▒▒▓▓▓████▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒░░░░░░ ░░   ░░░░░░░░░ ░░░░░▒▓▓▓██████▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░░▒░░░▒▒░░░░ ░ ░░   ░░░░░░░░ ░░░░▒▒▒▓▓▓███▓█▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░ ░░░░   ░░░░░░░░░ ░░░░░▒▒▒▒▓▓▓▓▓▓▒▒░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░░░░░ ░   ░░░░░░░░░ ░░░░░░░░▒▒▒▒▒▒░░░░▒░▒░░░░░░░░░░░░░░ ░░░░░░░   ░ ░░░░ ░░░░░ ░░░░░░░░░░░░░ ░ ░ ░ ░░░░░░░   ░░ ░ ░ ░░░ ░ ░░░░░░░░░   ░░ ░ ░░░░ ░░░░░ ░ ░ ░░░░░░░░   ░░░░░░░░░░░░░░░ ░ ░░░░░░░░░   ░░░░░░▒░░░░░░░░░░ ░ ░░░ ░ ░ ░ ░░░░░░░░░   ░░░░░▒░░░░░░░ ░░░░ ░░░░░ ░░░░░░░░░░ ░░░░░░░░░░   ░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ░ ░░░░░░   ░░ ░░░ ░ ░░░░░░░▒▒░░░▒░▒░▒░░░░░░░░░░░░ ░░░░░░░░░   ░░░░ press ⏎ to skip ░░░░░ ░░░░░░  [?7h[?25l[?7l ✦ · ████████████████████████   ✧ ████████████████████████   ████ ████ ✧   ✦ ████ ████ ✧   ✧ ████ ████   ✧ ✧ · ████ ████ ✦   · ▒▒▒▒ ████ ·   ▒▒▒▒ ████ ·   ████   ░░░░░░░░░░ ░░▒▒▒▓▒▓▒▒▒░░░░ ░░▒▒▓▓█████▓▓▓▓▓▓▓▓▒▒▒░░ ░░▒▒▒▓▓██▓▓▓▒░░ ░ ░░░░░░░░░░   ░░░░░░░░ ░░▒▒▒▓▓▓▓▓▒▒▒░░ ░░▒▒▒▓▓▓██▓▓▓▓██▓▓▒▒▒░░ ░░░▒▒▓▓▓▓▓▒▒░░ ░░░░░░░░░   ░░░░░░░ ░▒▒▒▓▓▓▓▓▓▒▒▒░ ░░▒▒▓▓▓▓▓▓█▓▓▓▓▒▒░░░ ░░░▒▒▒▓▒▓▒▒▒▒░░ ░ ░ ░░░░ ░   ░░░░░░░ ░░░▒▒▒▓▓▓██▓▓▒▒░ ░░▒▒▒▓▓▓▓▓▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒░░░░░░ ░ ░ ░░░░░   ░░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▓▒▒░ ░▒▒▒▒▓▒▒▒░░░░ ░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░░   ░░░░░ ░░░░▒▒▒▓▓▓▓▓▓▓▒▒░░ ░░▒▒▒▒░░░░ ░ ░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░ ░░   ░░ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░▒▒▒░░░░░ ░░░░░░░▒░▒░░░▒░░░▒░░░░░ ░   ░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░▒░░░░ ░░░░▒▒░▒▒▒░░░▒▒░▒▒▒░▒░░   ░ ░ ░░░░▒▒▒▒▒▒▒▒░▒░▒▒▒▒▒▒░░░░░░░░░▒░░░░░░░░▒▒▒▒▒▒▒▒▒░▒▒░▒▒▒▒▒░░░ ░░   ░ ░░░ ░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒░░▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒░░░ ░   ░░░░░ ░ ░░░ ░░░░░▒▒▒▒▓▓▓█▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒░░░░░░ ░░   ░░░░░░░░░ ░░░░░▒▒▓▓▓█████▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░▒░░░▒▒░░░░ ░░   ░░░░░░░░ ░░░░▒▒▒▓▓████▓█▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░▒░░░░░░░░ ░░░░░ ░   ░░░░░░░░░ ░░░░░▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░░░░░ ░░   ░░░░░░░░░░ ░░░░░░░▒▒▒▒▒▒▒▒░░▒▒░▒▒░░░░░░░░░░░░░ ░░░░░░░   ░░░░░░ ░░░░░ ░░░░░░░░░░░░░░░░░ ░ ░ ░ ░ ░░░░░░░   ░░ ░ ░ ░░ ░ ░░░░░░░░░   ░░ ░ ░░░░░ ░░░░░░░   ░░░░░░░░░░░░░░░░ ░ ░░░░░░░░░   ░░░░░░░░░░░░░░░░░░ ░░░ ░ ░ ░ ░░░░░░░░   ░░░░░▒░░░░░░░ ░░░░ ░░ ░ ░░░░░░░░░ ░░░░░░░░░   ░░░░░░░░░░░ ░ ░░ ░░░░░░ ░░░░░░░░░░░░░░░░░░░ ░ ░░░░░░░   ░░░░░░ ░ ░░░░░░░░░░░░░░▒░▒░░░░░░░░░░░░ ░░░░░░░░░   ░░░░ press ⏎ to skip ░░░░░ ░ ░░░░░░  [?7h[?25l[?7l · ✦ · ✦ ✦ ·   ✦   ✧   ✧ ✧   ✦ ✦ ·   ✧   ✧ ████████████████████████   ✦ ████████████████████████✦ ✧  ████ ████ ✦   ████ ████   · ████ ████ · ·   ✦ ████· ████   ▒▒▒▒ ████ ✧ ✧ ✦   · ▒▒▒▒ ████ · ✧   ✧ ✧ ✧ ████   ░░░░░░░░░░ ░░░▒▒▓▓▒▓▒▒░░░░░ ░▒▒▓▓██████▓▓▓▓▓▓▓▓▒▒▒░░ ░░▒▒▓▓██▓▓▓▒▒░ ░ ░░░░░░░░░░   ░░░░░░░ ░░▒▒▒▒▓▓▒▒▒▒░░░ ░▒▒▒▓▓▓▓██▓▓▓▓███▓▓▒▒░░ ░░░▒▒▓▓▓▓▓▓▒░░░ ░░░░░░░░░   ░░░░░░░ ░▒▒▒▓▓▓▓▓▒▒▒░░ ░░▒▒▒▓▓▓▓▓▓█▓▓▓▓▒▒░░░ ░░░░▒▒▒▒▓▒▒▒▒░░ ░ ░ ░░░░ ░   ░░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▒▒░░ ░░▒▒▒▒▓▓▓▓▓▓▓▒▒░░░░ ░░░▒▒▒▒▒▒▒▒░░░░░░ ░ ░ ░░ ░░   ░░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▒▒▒░ ░░▒▒▒▒▓▒▒▒▒░░░ ░░░░▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░░   ░░░░░ ░░░░▒▒▒▓▓▓▓▓▓▓▒▒░ ░░▒▒▒▒▒░░░░ ░ ░░░▒▒░▒▒▒▒░░░░░░░░░░░░ ░░   ░░░░ ░░▒▒▒▒▒▒▒▓▒▒▒▒▒▒░░ ░░▒▒▒░░░░░ ░░░░░░▒░▒░░░░░░░▒░░░░░ ░   ░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░▒░░░░ ░░░░░▒░▒▒▒░░░░▒░▒▒▒░▒░░   ░ ░ ░░░░▒▒▒▒▒▒▒▒▒▒░▒▒▒░░▒░░ ░░░░░░▒░░░░░░░░░▒▒▒▒▒▒▒▒░▒▒░▒▒▒▒▒░░░░ ░░   ░ ░ ░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒░░░░ ░   ░░░░░ ░░░ ░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒░░░░░░░ ░░   ░░░░░░░░ ░░░░░▒▒▓▓▓█████▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░▒░░░▒▒░░░░░ ░░   ░░░░░░░░ ░░░░▒▒▒▓▓████▓█▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░▒░▒▒░░░░░░░ ░░░░ ░   ░░░░░░░░ ░░░░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░ ░░░ ░░   ░░░░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒░░▒▒▒▒▒▒░░░░░░░░░░░░ ░ ░░░░░░░   ░░░░░░ ░ ░░░ ░░░░░░░░░░░░░░░░░░░░ ░ ░ ░ ░ ░░░░░░░   ░░ ░░ ░ ░ ░░░░░░░   ░░ ░░ ░ ░░░░░░░░░   ░░░░░░░░░░░░░░░ ░ ░░░░░░░   ░░░░░░░░░░░░░░░░░░ ░░ ░ ░ ░ ░░░░░░░░░   ░░░░░▒░░░░░░░ ░░ ░░ ░ ░░░░ ░░░░ ░░░░░░░░░ ░   ░░░░░░░░░░░ ░ ░░ ░ ░ ░░░░░░░░░░░░░░░░░ ░ ░░░░░░░   ░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░   ░ ░░░░ press ⏎ to skip ░░░░░ ░ ░░░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████   ✦ ████████████████████████✦ ✧  ░░░░░░░ ░░ ░░▒▒▒▒▓▒▒▒▒░░ ░░░▒▒▓▓█████▓▓▒▓▓▓▓▓▓▓▓▒░░ ░▒▒▒▓▓█▓▓▓▒▒░░ ░░░░░░░░░░   ░░░ ░░░ ░░░▒▒▒▒▒▒▒▒▒░░░░ ░░▒▒▓▓▓████▓▓▓▓███▓▓▓▒▒░ ░░░▒▒▒▓▓▓▓▓▒▒░░ ░░░░░░░░░   ░░░░░░░ ░▒▒▒▒▓▓▓▓▒▒▒░░ ░ ░░▒▒▒▓▓▓▓▓▓▓███▓▓▓▒▒░░ ░ ░░▒▒▒▒▓▒▒▒▒░░ ░ ░ ░░░ ░   ░░░░░░░░ ░░░▒▒▒▒▓▓▓▓▓▒▒░░ ░░▒▒▒▒▓▓▓▓▓▓▓▓▒░░░░ ░░░░░▒▒▒▒▒▒░░░░░░ ░ ░ ░░ ░░   ░░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▒▒▒░ ░░▒▒▒▒▓▓▓▒▒░░░ ░░░░▒▒▒▒▒▒░░░░░░░░░░░ ░ ░   ░░░░ ░░░░░▒▒▒▓▓▓▓▓▓▓▒▒░ ░░▒▒▒▒▒▒░░░ ░ ░░░░░░▒▒▒▒░░░░░░░░░░░░ ░   ░░░░ ░░▒▒▒▒▒▒▒▓▓▒▒▒▒▒░░ ░░▒▒▒░░░░░ ░░░░░░▒░▒░░░░░░░▒░░░░░░░ ░   ░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░▒░░░░ ░░░░▒░░▒▒░░░░▒░░▒▒▒▒░░░   ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░ ░ ░░░▒░░░░░░░░░░░░▒▒▒▒▒░▒░░▒▒▒▒▒░░░░░   ░ ░ ░░░░░▒▒░░░░░░▒▒▒▒▒▒▒▒▒▒░▒░░▒▒▒░▒░░░░░░▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒░░░░ ░   ░ ░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒░░▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒░░░░░ ░   ░░░░░░░ ░░░░▒▒▒▓▓▓██▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░▒▒░░░░░ ░░ ░   ░░░░░░░░ ░░░░▒▒▒▓▓████▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒░░░▒░░░ ░░░ ░   ░░░░░░░░ ░░░░░▒▒▓▓▓█▓█▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░▒░░░░░░░░░░░ ░ ░ ░░   ░░░░░░░░ ░░░░▒▒▒▒▒▓▒▒▒▒▒░░▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░░░░░░   ░ ░░░░░░ ░ ░░░ ░░░░░▒░▒░░░░░░░░░░░░░░░ ░ ░ ░ ░░░░░░░   ░░░ ░ ░ ░ ░ ░░░ ░ ░░░░░░░   ░░░ ░░ ░ ░░░░░░░   ░░░░░ ░░ ░░░░░ ░ ░░░░░░░   ░░░░░░░░░░░░░░░░░░ ░ ░░░░░░░░░░   ░░░░░░░░░░░░░ ░ ░░ ░ ░░░ ░ ░ ░ ░░░░░░░░░   ░░░░░░░░░░░░ ░ ░░ ░░░░░░░░░░░░░░░░░ ░░░░░░░░   ░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░   ░ ░ ░ ░░░░ press ⏎ to skip ░░░░░░░ ░░░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████   ✦ ████████████████████████✦ ✧  ░░ ░░░░ ░░ ░░░▒▒▒▒▒▒▒░░░ ░░░▒▓▓██████▓▓▒▓▓▓▓▓▓▓▓▒▒░░░ ░░▒▒▒▓▓▓▓▓▒▒▒░ ░ ░░░░░░░░   ░░ ░░░▒▒▒▒▒▒▒▒▒░░ ░ ░░▒▓▓██████▓▒▓▓███▓▓▓▒▒░░ ░ ░▒▒▒▒▓▓▓▓▒▒░░ ░░░░░░░ ░   ░░░░░ ░░▒▒▒▒▓▓▓▒▒░░░░ ░░░░▒▒▓▓▓▓▓▓▓▓▓██▓▓▓▒▒░░ ░ ░░░▒▒▒▒▒▒▒▒░░░░ ░░░ ░   ░░░░░░░░ ░░░▒▒▒▒▓▓▓▓▓▒░░░ ░░▒▒▒▒▓▓▓▓▓▓▓▓▓▒▒░░░ ░ ░░░▒▒▒▒▒▒░▒░░░░ ░ ░ ░   ░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▒▒░░ ░░▒▒▒▒▒▓▓▓▒▒▒░░ ░░░░░░▒▒▒▒░░░░░░░░░░░ ░ ░   ░░░░░ ░░░░░▒▒▒▓▓▓▓▓▓▒▒░░ ░░░▒▒▒▒▒▒▒░░ ░ ░░░░░▒▒▒▒░░░░░░░░░░░░░   ░░░░ ░░▒▒▒▒▒▒▓▓▓▒▒▒▒▒░░ ░░░▒▒▒▒░░░░ ░░░░░▒░▒░░░░░░░▒░░░░░░░ ░   ░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░▒░░░░ ░░░░░░░░▒░░░░▒░░▒▒▒▒░░░░   ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░▒░░░░░░ ░░░░░░▒▒▒░▒░░▒▒▒▒▒░░░░░   ░ ░░░░░▒▒▒░░▒░░░▒▒▒▒▒▒▒░░░░░░░▒▒▒░▒░░░░░░░▒░▒▒▒░▒▒░▒▒▒▒▒▒▒▒▒░░░   ░ ░░░░░░░░░░░░▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░   ░░░░░░░ ░ ░░░░▒▒▒▓▓▓██▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒░▒▒▒░░░░░ ░░ ░   ░░░░░░░░ ░░░░░▒▒▓▓▓████▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒░░▒░░░░ ░ ░ ░   ░░░░░░░░ ░░░▒▒▒▒▓▓██▓█▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒░░░░░░░░░ ░ ░ ░░   ░░░░░░░░ ░░░░▒▒▒▒▓▓▓▓▒▒▒░░▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░░░   ░ ░░░░░░ ░ ░░░░░░░▒▒▒▒▒▒░░░░░░░░▒░░░░░ ░░ ░ ░ ░░░░░░   ░░░ ░ ░ ░░ ░░░░░░░ ░ ░░░░░░░░   ░░ ░░ ░ ░░░░░░░░   ░░░ ░░░░ ░░░░░░░░   ░░░░░░░░░░░░░░░░░░ ░ ░░░░░░░░   ░░░░░░░░░░░░░ ░ ░ ░ ░ ░░░░░░░░░   ░░░░░░░░░░░░░░ ░ ░░ ░░░░░░░░░░░░░░░░ ░░░░░░░░   ░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░   ░░ ░░░░ ░░░░ press ⏎ to skip ░░░░░░░ ░░░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████   ✦ ████████████████████████✦ ✧  ████ ████ ✦   ████ ████   · ████ ████ · ·   ░░ ░░░░ ░ ░░▒▒▒▒▒▒▒▒░░ ░░░▒▓███████▓▓▒▒▓▓▓▓▓▓▓▒▒░░░ ░░░▒▒▓▓▓▓▓▓▒▒░░ ░ ░ ░░░░░   ░░ ░░▒▒▒▒▒▒▒▒░░░ ░░░▒▓▓██████▓▓▒▓▓▓██▓▓▓▒▒░░ ░ ░░▒▒▒▓▓▓▓▒▒▒░░ ░░░░░ ░   ░░░░░ ░ ░░▒▒▒▒▒▓▒▒▒░░░░ ░░░▒▒▓▓▓█▓▓▓▓▓▓▓█▓▓▓▒▒▒░░░ ░░░░▒▒▒▒▒▒▒░░░░ ░░ ░   ░░░░░░ ░░░▒▒▒▒▓▓▓▓▒▒░░ ░▒▒▓▒▒▓▓▓▓▓▓█▓▓▓▒░░░ ░ ░░░▒▒▒▒▒▒▒▒░░░░ ░ ░ ░   ░░░░░ ░░░▒▒▒▓▓▓▓▓▒▒░░░ ░░▒▒▒▒▓▒▓▓▓▓▒▒▒░░ ░ ░░░░░▒▒▒░▒░░░░░░░░░ ░   ░░░░░ ░░░░░▒▒▒▓▓▓▓▓▓▒▒░░ ░░▒▒▒▒▓▓▒▒▒░░░ ░░░░░▒▒▒▒░░░░░░░░░░░░░░   ░░░░ ░░░▒▒▒▒▒▒▓▓▓▒▒▒▒▒░ ░░▒▒▒▒▒░░░░ ░ ░░░░░▒░░░░░░░░░░░░░░░   ░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░▒░▒░░ ░░░░░░░░░░░░▒░░▒▒▒▒░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░▒░░░░░ ░░░░░░▒▒▒░▒░░▒▒▒▒▒▒░░░░░   ░░░░░▒▒▒░▒▒░░░▒▒░▒▒▒░░░░░░░░░▒▒░░░░░░░░░░░▒▒▒░▒▒░▒░▒▒▒▒▒▒▒░░░   ░░░░░░░░░░░░▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░   ░░░░░ ░░ ░░░░▒▒▒▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒░░░░ ░ ░   ░░░░░░░ ░░░░░▒▒▓▓████▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒░░▒▒░░░ ░   ░░░░░░░░ ░░░▒▒▒▒▓▓██▓█▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒░░░░░░░░░ ░ ░ ░░   ░░░░░░░ ░░░▒▒▒▒▓▓▓▓▓▒▒▒▒░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░   ░ ░░░░░░ ░ ░░░░░░▒▒▒▒▒▒▒▒░░░░░▒▒▒░░░░░░░░░░ ░ ░ ░░░░░░   ░░░░░░ ░ ░░ ░░░░ ░░░░░░░ ░░ ░ ░░░░░░░░   ░ ░░ ░ ░░ ░░░░░░░░   ░░░ ░░░░ ░░░░░░░   ░░░░░░░░░░ ░ ░░░ ░ ░░░░░░░░░   ░░░░░░░░░░░░░░ ░ ░ ░ ░░░░░░░░░   ░░░░░░░░░░░░░░ ░ ░░ ░░░░░░░░░░░░░░░░ ░░░░░░░░   ░░░░░░░░░░ ░░░░░ ░ ░ ░░░░░░░░░░░░░░░░░░ ░░░░░░░░   ░░░░░░░ ░ ░░░░ press ⏎ to skip ░░░░░░░░ ░░ ░░░░░  [?7h[?25l[?7l ✧ ████████████████████████   ✦ ████████████████████████✦ ✧  ████ ████ ✦   ████ ████   · ████ ████ · ·   ✦ ████· ████   ▒▒▒▒ ████ ✧ ✧ ✦   · ▒▒▒▒ ████ · ✧   ░░░░ ░ ░ ░░▒▒▒▒▒▒▒░░░ ░░▒▓▓███████▓▒▒▒▓▓▓▓▓▓▓▓▒▒░░ ░░░▒▒▓▓▓▓▓▓▒▒░░ ░ ░░░░░   ░░ ░░░▒▒▒▒▒▒▒░░░ ░░░▒▓███████▓▓▒▓▓▓▓██▓▓▓▒▒░ ░ ░░▒▒▒▓▓▓▒▒▒░░░ ░░░░░ ░   ░░░░░ ░░░░▒▒▒▒▒▒▒▒░░░░░░░░▒▒▓▓▓▓█▓▓▓▓▓▓▓███▓▓▒▒░░░ ░░░░▒▒▒▒▒▒▒▒░░░ ░ ░   ░░░░░ ░░░▒▒▒▒▓▒▓▒▒▒░░ ░░▒▒▒▓▓▓▓▓▓▓▓▓██▓▓▒▒░░░░ ░░░▒▒▒▒▒▒▒░░░░ ░ ░ ░   ░░░░ ░░░▒▒▒▓▓▓▓▓▒▒░░░ ░░░▒▒▒▒▓▓▓▓▓▓▒▒▒░░ ░ ░░░░░░▒▒░▒░░░░░░░░░ ░ ░   ░░░░░ ░░░░░▒▒▒▓▓▓▓▓▒▒▒░░ ░░▒▒▒▒▓▓▒▒▒▒░░ ░░░░▒░▒▒░░░░░░░░░░░░░░   ░░░ ░░░▒▒▒▒▒▒▓▓▓▒▒▒▒░░ ░░▒▒▒▒▒▒░░░ ░░░░░░░░░░░░░░░░░░░░░░   ░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░▒░▒░░░ ░░░░░░░░░░▒░░▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░▒░░░░░ ░░░░░░░▒▒░▒░░▒▒▒▒▒▒▒░░░░   ░░░░▒▒▒▒▒▒▒░░░▒▒░░▒░░░░░ ░░░░▒▒░░░░ ░░░░░░░▒░▒▒░▒░▒▒▒▒▒▒▒░░░   ░ ░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░   ░░ ░ ░ ░░░ ░░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒░░░░ ░ ░   ░░░░░ ░ ░░░░░▒▒▓▓████▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒░░░ ░   ░░░░░░░░ ░░░▒▒▒▓▓███▓█▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒░▒░░░░░░░ ░ ░░   ░░░░░░░ ░░░░▒▒▒▓▓▓▓▓▓▒▒▒▒░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░░   ░░░░░░░░ ░ ░░░░▒▒▒▒▒▒▒▒▒▒░░░░░▒▒▒░▒░░░░░░░░ ░░░ ░░░░░░   ░░░░░░ ░ ░░░░░░░░ ░░░░░░░░░░░ ░ ░░░░░░░░   ░ ░░ ░░░░ ░ ░░░░░░   ░ ░░ ░ ░░ ░░░░░░ ░   ░░░░░░ ░░░ ░ ░░░ ░ ░░░░░░░░   ░░░░░░░░░░ ░░░ ░ ░ ░░░░░░░   ░░░░░░░░░░░░░░ ░ ░░░░ ░ ░░ ░░ ░ ░░░░░░░   ░░░░░░░░░░ ░░░░ ░░░░░░░░░░░░░░░░░ ░░░░░░░░░   ░░░░░░░ ░ ░░░░ press ⏎ to skip ░░░░░░░░ ░░ ░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████   ✦ ████████████████████████✦ ✧  ████ ████ ✦   ████ ████   ✦ ████· ████   ▒▒▒▒ ████ ✧ ✧ ✦   · ▒▒▒▒ ████ · ✧   ✧ ✧ ✧ ████   ████   ░░░ ░ ░░░░░▒▒▒▒▒▒▒░░ ░░▒▓████████▓▒▒▒▓▒▓▓▓▓▓▓▒▒░░░ ░░░░▒▒▓▓▓▓▓▓▒░░ ░ ░░░░   ░░ ░ ░░▒▒▒▒▒▒▒░░░ ░░▒▒▓███████▓▒▒▒▓▓▓██▓▓▓▒▒░░░ ░░░▒▒▒▓▓▒▒▒▒░░ ░░ ░   ░░░ ░░░░▒▒▒▒▒▒▒░░░░░░░░▒▒▓▓████▓▓▓▓▓▓▓███▓▓▒▒▒░░ ░ ░░▒▒▒▒▒▒▒▒▒░░ ░   ░ ░░ ░░░▒▒▒▒▓▒▒▒▒░░ ░░▒▓▓▓▓▓▓▓▓▓▓▓██▓▓▒▒▒░░░ ░░░░▒▒▒▒▒▒░░░░ ░ ░ ░   ░░░░ ░░░▒▒▒▓▓▓▓▒▒▒░░░ ░░░▒▒▒▒▒▓▓▓▓▓▓▓▒▒▒░░ ░ ░░░░░░▒▒░▒░░░░░░░░░ ░ ░   ░░░░ ░░░░░▒▒▒▓▓▓▓▓▒▒▒░ ░░▒▒▒▒▓▓▓▒▒▒▒░░ ░░░░░░▒▒░░░░░░░░░░░░░░   ░░░ ░░░▒▒▒▒▒▒▓▓▓▒▒▒▒░░ ░▒▒▒▒▒▒▒▒░░ ░░░░░░░░░░░░░░░░░░░░   ░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▒░░░ ░░░░░░░░░░▒░░░▒▒▒▒░░░░   ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░▒▒░░░░ ░░░░░░▒░░▒░░▒▒▒▒▒▒▒░░░░   ░░░▒░▒▒▒▒▒▒▒░░░▒▒░░░░░ ░░▒▒░░░░ ░░░░░░▒░░▒░▒░▒▒▒▒▒▒▒▒░░   ░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒░▒▒░░░▒▒▒░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒░▒▒▒░▒░░   ░░ ░ ░░░░░ ░░░░░▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒░░░ ░   ░░ ░ ░░░░░▒▒▓▓▓██▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒░░░ ░   ░░░░░░░░ ░░▒▒▒▒▓▓███▓█▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒░▒▒░░░░ ░ ░ ░░   ░░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░ ░ ░░░░   ░░░░░░░░░ ░ ░░░░▒▒▒▒▒▒▒▒▒▒░░░░▒▒▒▒▒▒░▒░░░░░░░░░░░ ░ ░░░░   ░░░░░░ ░ ░░░░░░░░░░░░░░░░░░░░░ ░ ░ ░░░░░   ░░░ ░░ ░░░░ ░ ░░░░░░   ░ ░ ░ ░░░░░░ ░   ░░░ ░ ░ ░ ░ ░ ░ ░░░░░░░░   ░░░░░░░░░░ ░░░ ░░░░░░░   ░░░░░░░░░░░░░░ ░ ░░░░ ░ ░ ░░░░░░░   ░░░░░░░░░░ ░░ ░░░░░░ ░ ░░ ░░░ ░ ░░░░░░░   ░░░░░░░ ░ ░░░░ press ⏎ to skip ░░░░░░░░ ░ ░░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████   ✦ ████████████████████████✦ ✧  ████ ████ ✦   ████ ████   · ████ ████ · ·   ✦ ████· ████   ▒▒▒▒ ████ ✧ ✧ ✦   · ▒▒▒▒ ████ · ✧   ████   ░░ ░ ░░░░░▒▒▒▒▒▒▒▒░░ ░░▒▓████████▓▒▒▒▒▒▓▓▓▓▓▓▒▒░░░ ░░░░▒▓▓▓▓▓▓▒▒░░ ░░░░   ░░░░▒▒▒▒▒▒▒░░░ ░░▒▓████████▓▒▒▒▒▓▓▓▓▓▓▓▓▒░░░ ░░░▒▒▒▒▓▒▒▒▒░░ ░░ ░   ░░░░░▒▒▒▒▒▒▒░░ ░░░▒▒▒▓█████▓▓▓▓▓▓▓▓███▓▓▒▒░░ ░ ░░░▒▒▒▒▒▒▒▒░░░   ░ ░░░░▒▒▒▒▒▒▒▒▒░░ ░░░░▒▒▓▓▓▓▓▓▓▓▓▓▓███▓▓▒▒▒░░ ░░░░▒▒▒▒▒▒░░░░ ░ ░   ░░░░ ░░░▒▒▒▒▓▓▒▒▒░░░ ░░▒▒▒▒▒▒▒▓▓▓▓█▓▓▒▒▒░░░░ ░░░░░░▒▒░▒░░░░░░░░░ ░   ░░░░ ░░░░░▒▒▒▓▒▓▓▒▒▒▒░ ░▒▒▒▒▒▓▓▓▓▒▒▒░░░ ░░░░░░▒░░░░░░░░░░░░░░░   ░░░░ ░░░▒▒▒▒▒▒▓▓▓▒▒▒▒░ ░▒▒▒▒▒▒▒▒▒░░ ░░░░░░░░░░░░░░░░░░░   ░░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▒▒░░░ ░ ░░░░░░░░░░░▒░▒▒▒░░░   ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░▒▒▒░░░ ░░░░░░▒░░▒▒▒▒▒▒▒░░░░░   ░░░▒▒▒▒▒▒▒▒▒░▒░▒░░░░░ ░░▒▒░░░░ ░░░░░▒░░▒░▒░▒▒▒▒▒▒▒▒▒░░   ░░░░░░▒▒░░░░░▒░▒▒▒▒▒▒░░░░░░░░▒▒▒░░░░░░░░░▒░▒▒▒▒▒▒▒▒▒░▒▒▒▒▒░░   ░░░░░░░░░░░░░▒▒▒▒▓▒▓▒▒▒▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒░░░░ ░   ░ ░ ░░░░░▒▒▓▓▓█▓▓▓▓▓▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒░░░ ░   ░░░░░░░ ░░▒▒▒▓▓▓███▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒░▒▒░░░░░░ ░░   ░░░░░░░░ ░░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░ ░░░░   ░░░░░░░░ ░ ░░░░▒▒▒▓▓▓▒▒▒▒░░░░▒▒▒▒▒▒░▒░░░░░░░░░░░ ░░░░   ░░░░░░ ░ ░░░░░▒▒░▒░░░░░░░░░░░░░░░░░ ░ ░░░░░░   ░░░░ ░ ░░ ░░░░░░░ ░░░░░░░░   ░ ░ ░ ░░░░░░░   ░░ ░ ░ ░ ░░░░░░░░   ░ ░░░░░░░░░ ░░░ ░░░░░░░░   ░░░░░░░░░░ ░░░ ░ ░ ░░ ░ ░ ░░░░░░░░░░   ░░░░░░░░░░ ░░ ░░░░░░ ░ ░ ░ ░ ░░░░░░░░   ░░░░░░░░ ░ ░░░ press ⏎ to skip ░░░░░░░░ ░░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████   ✦ ████████████████████████✦ ✧  ✦ ████· ████   ▒▒▒▒ ████ ✧ ✧ ✦   · ▒▒▒▒ ████ · ✧   ░ ░░░░░▒▒▒▒▒▒▒░ ░▒▒█████████▓▒▒▒▒▒▒▓▓▓▓▓▒▒▒░░ ░░ ░░▒▒▓▓▓▓▓▓▒▒░░ ░░░░   ░ ░░░░▒▒▒▒▒▒░░░ ░░▒▓████████▓▒▒▒▒▓▓▓▓██▓▓▒▒░░ ░░░░▒▒▒▓▒▒▒▒░░ ░   ░ ░░ ░░░░░▒▒▒▒▒░░░ ░░░▒▒▓▓█████▓▓▒▒▒▓▓▓▓██▓▓▓▒░░░░ ░░░░▒▒▒▒▒▒▒░░░   ░ ░░░░▒▒▒▒▒▒▒▒░░ ░░░▒▒▓▓▓█▓▓▓▓▓▓▓▓████▓▒▒▒░░ ░░░▒▒▒▒▒░░░░░ ░ ░   ░░ ░ ░░░▒▒▒▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▒▒▓▓▓▓█▓▓▓▓▒░░░░ ░░░░░░░▒░▒░░░░░░░░░ ░   ░░░░ ░░░░░▒▒▒▓▒▓▒▒▒▒░░ ░░▒▒▒▒▒▓▓▓▓▓▒▒▒▒░ ░░░░░░▒░░░░░░░░░░░░░░░   ░░░░ ░░░▒▒▒▒▒▒▓▓▓▒▒▒░░ ░░▒▒▒▒▓▒▒▒▒░░░ ░░░░░░░░░░░░░░░░░░░░   ░░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░ ░░░▒▒▒▒▒░░ ░░░░░░░░░░░░░▒▒▒░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░▒▒▒░░░ ░░░░░░░░░▒░▒▒▒▒▒░▒░░░   ░░▒▒▒▒▒▒▒▒▒▒▒▒░▒░░░░ ░░░▒░▒░░ ░ ░░░░░▒░▒░▒▒▒▒▒▒▒▒▒░░   ░░░░░░▒▒░░░░░▒░▒▒▒░▒░░░░░░░░░▒▒▒░░░ ░░░░░░░▒░▒▒░▒▒▒▒░▒▒▒▒▒▒░░░   ░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒░░░ ░   ░░ ░░░░░▒▒▓▓▓▓▓▓▓▒▓▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒░░ ░   ░░░░░░ ░░░▒▒▒▓▓▓███▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒░░░░░░ ░░   ░░░░░░░░ ░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░ ░░░   ░░░░░░░ ░░░░▒▒▒▒▓▓▓▓▓▒▒▒░░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░░   ░░░░░░░░ ░ ░░░░▒▒▒▒▒▒░░░░░░░░░░▒░░░░░ ░ ░ ░░░░░   ░░░░░ ░ ░░░ ░░░░░░░░ ░░░░░░░░   ░░ ░ ░ ░░░░░░░   ░░ ░ ░ ░ ░░░░░░░   ░ ░░░░ ░ ░░ ░░░ ░░░░░░░░   ░░░░░░░░░░ ░░░ ░ ░░ ░░░░░░░░░   ░░░░░░░░ ░ ░ ░░░░░░ ░ ░░░░░░░░   ░░░░░░░░░ ░ ░░░ ░░ press ⏎ to skip ░░░░░░░░ ░░░░░░░  [?7h[?25l[?7l · ✦   ·   · · ✧     ✦ ✦   ✦ ✧ ·   ✧ ████████████████████████ ✦   ✧ ████████████████████████   ✦ ✦ ████ ████   ✦ ████ ✦████   ████ ████ ·   · ✦ ████ ████ ✦   ✧ ✦ ▒▒▒▒ ████   ✧ ▒▒▒▒ ████ ✧   ✦ ████· ✦   ░░ ░░░░▒▒▒▒▒▒▒▒░ ░▒▓████████▓▓▒▒▒▒▒▒▒▓▓▓▓▓▒▒▒░░░░ ░░▒▒▒▓▓▓▓▓▒▒░░ ░░░░   ░ ░░░░░░▒▒▒▒▒░░░ ░▒▒▓████████▓▒▒▒▒▒▓▓▓▓█▓▓▓▒░░ ░░░░▒▒▒▓▒▒▒▒░░░ ░   ░░ ░░ ░░░░░▒▒▒░░░░ ░░░▒▓▓██████▓▓▒▒▒▓▓▓▓██▓▓▓▒▒░░░ ░░▒▒▒▒▒▒▒░░░   ░░░░▒▒▒▒▒▒▒░░░ ░░░▒▒▒▓███▓▓▓▓▒▓▓▓████▓▓▒▒░░ ░░░▒▒▒▒▒░░░░░░ ░ ░   ░░ ░░░░▒▒▒▒▒▒▒▒░░░░ ░░░▒▒▒▒▓▓▒▒▓▒▓▓██▓▓▓▒▒░░░ ░░░░░▒░▒░░░░░░░░░ ░░   ░░ ░░░░░▒▒▒▓▒▒▒▒▒▒░░ ░░░▒▒▒▒▒▓▓▓▓▓▓▒▒▒░░ ░░░░░░░░░░░░░░░░░░░   ░░░ ░░░▒▒▒▒▒▒▒▓▒▒▒▒░░ ░▒▒▒▒▒▓▒▒▒▒▒░░ ░░░░░░░░░░░░░░░░░░░░░   ░░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░▒▒▒▒▒▒░░░ ░░░░░░░░░░░░▒▒▒░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░▒▒▒░░░░ ░░░░░░░░░░░▒▒▒▒░▒▒░░   ░░░▒▒▒▒▒▒▒▒▒▒▒▒░▒░░░░ ░░░▒░▒░░ ░░░░░▒░░░▒▒▒▒▒▒▒▒▒▒░   ░░░░▒▒▒▒░░░▒▒▒░▒▒▒░░░░░ ░░░░░▒▒▒░░░ ░ ░░░░░▒▒░▒▒▒▒░▒▒▒▒▒▒░░░   ░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒░░   ░ ░░ ░░ ░░░░░▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒░░░░ ░   ░░ ░░ ░░░▒▒▒▓▓▓██▓▓▓▓▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒░▒▒░░▒▒░░░ ░   ░░░░░ ░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░ ░ ░   ░░░░░░░ ░░░░▒▒▒▒▓▓▓▓▓▒▒▒░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░ ░░░░   ░░░░░░░░ ░ ░░░░▒▒▒▒▒▒▒▒░░░░░░░░▒▒░░░░░░░░ ░ ░░░░░░   ░░░░░ ░░░░░░ ░ ░░░░░░░░░ ░░░░░░░   ░░ ░ ░ ░░░ ░░░░░░   ░░ ░ ░ ░ ░ ░░░░░░░░   ░░░░ ░ ░░░ ░░░░░░░   ░░░░░░░░░░ ░░ ░ ░░░░░░░░░░   ░░░░░░░░ ░ ░░░░░ ░░░░░░░   ░░░░░░░░░ ░ ░░░ ░░ press ⏎ to skip ░░░ ░░░░ ░░░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████ ✦   ✧ ████████████████████████   ✧ ▒▒▒▒ ████ ✧   ░░ ░░▒▒▒▒▒▒▒▒▒░░ ▒▒▓████████▓▓▒▒▒▒▒▒▒▓▓▓▓▓▒▒▒░░░░ ░▒▒▒▓▓▓▓▓▒▒░░ ░░░░   ░░░░░░░░▒▒▒▒▒▒░░ ░▒▓████████▓▓▒▒▒▒▒▓▓▓▓█▓▓▓▒▒░░ ░ ░░▒▒▒▓▒▒▒▒▒░░░ ░   ░░ ░ ░░░░░░░░▒▒▒░░░ ░░▒▓▓███████▓▓▒▒▒▒▓▓▓███▓▓▒▒▒░░ ░░▒▒▒▒▒▒▒░░░░   ░ ░░░░▒▒▒▒▒░░░░ ░░▒▒▓▓▓███▓▓▓▒▒▓▓▓████▓▓▓▒▒░░ ░░░░▒▒▒▒░░░░░░░░░░   ░ ░░░░▒▒▒▒▒▒▒░░░ ░░ ░░▒▒▒▓▒▓▓▓▒▒▒▒▓▓███▓▓▓▒░░░ ░░░░░▒░░░░░░░░░ ░░   ░ ░░░░░▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒░░░ ░░░░░░░░░░░░░░░░░░░   ░░░ ░░░▒▒▒▒▒▒▒▓▒▒▒░░░ ░░▒▒▒▒▒▓▓▓▒▒▒░░░ ░ ░░░░░░░░░░░░░░░░░░░   ░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒░░ ░▒▒▒▒▒▒▒▒░░░ ░░░░░░░░░░░░▒▒▒░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▒▒░░░ ░░░░░░░░░░▒▒▒▒▒▒▒░░   ░░░▒▒▒▒▒▒▒▒▒▒▒▒░▒░░ ░░░▒░▒░░ ░░░░░░░░░▒▒▒▒▒▒▒▒▒░░   ░░░░▒▒▒▒▒▒░▒▒▒▒░▒░░░░░ ░░░░▒▒▒░░░ ░░░░░▒░▒▒▒▒░▒▒▒▒▒▒░░░   ░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░▒░░▒▒▒▒░░░░ ░░░░▒░░▒▒▒▒░▒▒▒▒▒▒▒▒░░   ░░░░░░░ ░░░░░▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒░░░ ░   ░ ░░░▒▒▒▓▓▓▓█▓▓▓▓▒▒▒▒▒▒░▒░▒▒▒▒▒▒▒▒▒▒▒░▒▒░░▒▒░░░ ░   ░░░░░ ░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░ ░   ░░░░░░░ ░░░░▒▒▒▓▓▓▓▓▓▓▒▒▒░▒░▒▒▒▒▒▒▒▒▒░▒░░░░░░░░ ░░   ░░░░░░░░ ░ ░░░░▒▒▒▒▒▒▒▒░░░░░░░▒▒▒░▒░░░░░░░░ ░░░░░░   ░░░░░░ ░░░░░░░░░░ ░░░░░░░░░ ░ ░ ░░░░░   ░░ ░░░░░░ ░░░░░░   ░ ░ ░ ░ ░░░░░░░   ░ ░░░ ░░░░░░░   ░░░░░░░░░░ ░░ ░ ░░░░░░░░   ░░░░░░░░ ░ ░░░░ ░░░░░░░   ░░░░░░░░░ ░ ░░░ ░ press ⏎ to skip ░░░ ░ ░░ ░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████ ✦   ✧ ████████████████████████   ✦ ✦ ████ ████   ✦ ████ ✦████   ████ ████ ·   ✧ ✦ ▒▒▒▒ ████   ✧ ▒▒▒▒ ████ ✧   ░░░░░▒▒▒▓▓▓▒▒▒░░ ░▒▒▓████████▓▓▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒░░░ ░░▒▒▓▓▓▓▓▒▒▒░░ ░░░░   ░░░░░░▒▒▒▒▒▒▒▒░░ ░▒▒▓███████▓▓▓▒▒▒▒▒▒▓▓▓██▓▓▒▒▒░ ░ ░░░▒▒▒▒▒▒▒▒▒░░   ░░ ░░ ░░░░▒░░░▒▒░░░ ░░▒▓▓███████▓▓▒▒▒▒▒▓▓▓██▓▓▓▒▒░░ ░░░░▒▒▒▒▒▒▒░░   ░ ░ ░░░░░░▒▒▒░░░░░░ ░░▒▒▓▓████▓▓▓▒▒▒▒▓▓███▓▓▓▒▒░░ ░░░▒▒▒▒░░░░░░░░░░   ░ ░░░░░▒▒▒▒▒▒░░░░ ░░░░▒▒▒▓▓▓▓▓▓▒▒▒▒▓▓███▓▓▓▒▒░░ ░░░░░▒░░░░░░░░░ ░░   ░ ░░░░░░▒▒▒▒▒▒▒▒▒░░ ░░░░▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒░░░ ░░░░░░░░░░░░░░░░░░░ ░   ░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░▒▒▒▒▒▓▓▓▓▓▒▒░░░ ░░░░░░░░░░░░░░░░░░░   ░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒░░░ ░░▒▒▒▒▒▒▒▒░░░░ ░░░░░░░░░░░▒▒▒░░░░   ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▒▒▒░░ ░░░░░░░░░░▒▒░▒▒▒▒░░░   ░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒░▒░ ░░░░░▒░░ ░░░░░░░░░▒░▒▒▒▒▒▒░░░   ░░░░▒▒▒▒▒▒░▒▒▒▒░▒░░░░ ░░░▒▒▒░░░ ░░░░░▒░░░▒▒░░▒▒▒▒▒░░░░   ░░░░░▒░░░░░░░░▒▒▒░▒▒▒░░░░░░░░▒▒▒▒░░░ ░░░░▒░░▒▒▒▒░▒▒▒▒▒▒▒▒░░░   ░░░░░░░░░░░░░░▒▒▒▓▓▓▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒░▒▒▒▒▒░░░   ░░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒░░░▒▒▒▒▒▒▒▒▒▒▒░▒▒░░▒▒░░░░   ░░ ░░▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░ ░   ░░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░ ░░   ░░░░░░░░ ░ ░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒░░░░░░░ ░░░░░░   ░░░░░░ ░░░░░░░░░░░ ░░░░░░░░░░░░ ░ ░░░░░   ░ ░░ ░░░░░░░ ░░░░░   ░ ░ ░░░░░░░   ░░░ ░░░░░░░░   ░ ░░░░░░ ░ ░░ ░░░░░░░░░   ░░░░░░░░ ░ ░░░░ ░░░░░░░░   ░░░░░░░░░ ░░░░░ press ⏎ to skip ░ ░ ░ ░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████ ✦   ✧ ████████████████████████   ✦ ✦ ████ ████   ✦ ████ ✦████   ████ ████ ·   · ✦ ████ ████ ✦   ✧ ✦ ▒▒▒▒ ████   ✧ ▒▒▒▒ ████ ✧   ✦ ████· ✦   ░░░░▒▒▒▒▓▓▓▒▒░░ ░▒▒▓████████▓▓▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒░░░ ░░▒▒▓▓▓▓▒▒▒░░ ░ ░   ░░░░░▒▒▒▒▒▒▒▒▒░ ░▒▒▓███████▓▓▒▒▒▒▒▒▒▓▓▓██▓▓▒▒▒░░░ ░░░▒▒▒▒▒▒▒▒░░   ░░░░░░░░░░▒▒░░▒░░░ ░▒▒▓████████▓▓▒▒▒▒▒▓▓▓███▓▓▒▒░░ ░░░░▒▒▒▒▒▒▒░░   ░ ░ ░░░░░░░░▒▒▒░░░░░ ░▒▒▓▓█████▓▓▒▒▒▒▒▒▓████▓▓▓▒▒░░ ░░░░▒▒▒▒▒░░░░░░░░   ░░ ░░░░░▒▒▒▒▒▒░░░ ░░░░▒▒▓▓▓▓▓▓▓▒▒▒▒▓▓█████▓▒▒▒░ ░ ░░░░▒░░░░░░░░░ ░░░   ░░░░░░▒▒▒▒▒▒▒▒░░░ ░░░░▒▒▒▓▒▒▒▒▓▓▓▓▓▓▓▓▒▒░░░ ░░░░░░░░░░░░░░░░░░░░░   ░░ ░░░░▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▒▒▒▒▓▓▓▓▓▒▒░░░ ░░░░░░░░░░░░░░░░░░░   ░░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒░░ ░░▒▒▒▒▒▒▒▒▒░░░ ░░░░░░░░░░░▒▒▒░░░░░   ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▒▒▒░░ ░░░░░░░░░▒▒░▒▒▒▒▒░░   ░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░▒░░░ ░░░░░░░░░▒░▒▒▒▒▒▒░░░   ░░░▒▒▒▒▒▒▒░▒▒▒▒░░░░ ░░▒▒▒░░░ ░ ░░░▒░░░▒▒░░▒▒▒▒▒░░░░   ░░░▒░▒░░▒░░░░░▒▒▒░░▒░░░░░░░░░▒▒▒▒░░░ ░░░░░░▒▒▒░▒▒▒▒▒▒▒▒▒░░   ░ ░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒░▒▒▒▒▒▒▒░▒▒▒▒▒▒░░   ░ ░░ ░░░░▒▒▒▓▓▓▓▓▓▒▓▒▒▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒▒▒░▒▒░▒▒▒░░░░   ░░ ░░▒▒▒▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░   ░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░ ░   ░░░░░░░ ░ ░░░▒▒▒▒▓▓▒▒▒▒▒░░░░░░▒▒▒▒▒▒░░░░░░░░ ░░░░░   ░░░░░░ ░░░░░▒▒░▒░░░░ ░░░░░▒░░░░░░ ░ ░░░░░   ░ ░░ ░░ ░ ░░░░░░░ ░ ░░░░░   ░ ░ ░ ░░░░░░   ░░░ ░░░░░░░   ░ ░ ░ ░ ░░ ░░░░░░░   ░░░░░░░░ ░ ░░ ░░░░░░░░   ░░░░░░░░░ ░░░░░ press ⏎ to skip ░ ░░░░░░░ ░  [?7h[?25l[?7l ✧ ████████████████████████ ✦   ✧ ████████████████████████   ✦ ✦ ████ ████   ✦ ████ ✦████   ████ ████ ·   · ✦ ████ ████ ✦   ✧ ✦ ▒▒▒▒ ████   ✧ ▒▒▒▒ ████ ✧   ✦ ████· ✦   ████   ░░░░▒▒▒▒▓▓▓▒▒░░ ░▒▓▓████████▓▓▒▒▒▒▒▒▒▒▒▓▓▓▓▒▒▒░░░ ░░▒▒▒▓▓▓▒▒▒▒░   ░░░░░▒▒▒▒▒▒▒▒░ ░▒▓████████▓▓▒▒▒▒░▒▒▒▓▓▓██▓▓▒▒░░░ ░░▒▒▒▒▒▒▒▒░░   ░░░░░░░░░▒▒▒░░▒░░ ░▒▒█████████▓▓▒▒▒▒▒▒▓▓▓███▓▒▒▒░░ ░░░░░▒▒▒▒▒▒░░░░   ░░░░░░░░░░░░░▒▒░░░ ░░▒▓▓██████▓▓▒▒▒▒▒▒▓▓███▓▓▓▒▒░░░ ░░░▒░▒░▒░░░░░░░░   ░ ░░ ░░░░░▒░░▒░░░░ ░░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▓▓█████▓▓▒▒░░░ ░░░░░░░░░░░░░ ░░░ ░   ░░░░░░░▒▒▒▒▒▒░░░░ ░░░░▒▒▒▒▓▒▒▒▒▓▓▓▓▓█▓▓▓▒▒░░ ░░░░░░░░░░░░░░░░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒░░░ ░░░▒▒▒▒▒▒▓▓▓▓▓▓▒▒░░ ░░░░░░░░░░░░░░░░░   ░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒░░ ░░▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░░░░░░▒░▒░░░░░░   ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▒▒▒▒░░ ░░░░░░░░░░▒░▒░▒▒▒░░   ░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░▒▒░░░ ░░░░░░░░░▒▒▒▒▒▒░░░   ░▒░▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░▒▒▒▒░░ ░░░░░░░░▒░░░▒▒▒▒░░░░   ░░░▒░▒░░▒░░░▒░▒▒▒░░░░░░░ ░░░▒▒▒▒░░░ ░░░░░░▒░░░▒▒▒▒▒▒▒▒▒░░   ░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒░░▒▒▒▒▒▒░░░ ░░░░░░░▒▒▒▒▒▒▒░▒▒▒▒▒▒░░░░   ░░░░ ░░░░▒▒▒▓▒▓▓▓▓▒▒▒▒▒▒▒▒▒░░░░░▒▒▒▒▒▒▒▒▒░▒▒░▒▒▒░▒░░░   ░░ ░░▒▒▒▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒░░░░▒░░░░   ░░░░ ░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░ ░   ░░░░░░░ ░ ░░▒▒▒▒▓▓▓▒▒▒▒▒░░░░░░▒▒▒▒▒▒░▒░░░░░░░ ░░░░░   ░░░░░░ ░░░░░░▒▒▒▒▒░░░ ░░░░░▒░░░░░░░░░ ░ ░░░░░   ░░ ░░░ ░░░░░░ ░░░░░░░░░ ░░░░   ░ ░ ░░ ░░░░░   ░░ ░░░░░░░   ░ ░ ░ ░░░░░░   ░░░░ ░ ░ ░ ░░ ░░░░░░░░   ░ ░░░░░░░░░ ░░░░░ press ⏎ to skip ░░░░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████ ✦   ✧ ████████████████████████   ✦ ✦ ████ ████   ✦ ████ ✦████   ████ ████ ·   · ✦ ████ ████ ✦   ████   ░░░▒▒▓▒▓▓▓▓▒░░ ▒▓▓████████▓▓▒▒▒▒▒░▒▒▒▒▓▓▓▓▓▓▒▒▒░ ░░░▒▒▒▓▓▓▓▒▒░░   ░░░░▒▒▒▒▒▒▒▒░░ ░░▒▓████████▓▓▒▒▒▒░░▒▒▒▓▓▓█▓▓▒▒▒░░░ ░░░▒▒▒▒▒▒▒▒░░░   ░░░░░░▒▒░▒▒▒░░░░░ ░▒▒▓████████▓▒▒▒▒▒▒▒▒▒▓▓▓██▓▓▓▒▒░░ ░░░▒▒▒▒▒▒░░░░   ░░░░░░░░░░▒░░░░░ ░▒▓▓███████▓▒▒▒▒▒▒▒▓▓▓████▓▓▒▒░░ ░░░░▒░▒░░░░░░░░   ░░░░░░░░░░░░░░░░░ ░░▒▒▓▓████▓▓▓▒▒▒▒▓▓▓▓████▓▓▒▒░░░ ░░░░░░░░░░░░░ ░░░ ░   ░ ░░░░░░░▒░▒░░░░░░░ ░░░▒▒▒▒▓▓▓▓▒▒▒▒▒▓▓▓▓██▓▓▒▒▒░░░ ░░░░░░░░░░░░░░░░░░░   ░ ░░░░░░▒▒▒▒▒▒░░░░ ░░░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒░░ ░░░░░░░░░░░░░░░░ ░   ░░ ░░░░░▒▒▒▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▓▓▓▓▒▒▒▒░ ░░░░░░░░░░░░░░░░░   ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒░ ░░░░▒▒▒▒▒▒▒░░░ ░░░░░░░░░░░▒▒▒▒░░   ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░░▒▒▒▒░░ ░░░░░░░░░░▒▒▒▒░░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▒░░░ ░░░░░░░░░░▒▒▒▒▒▒▒░░   ░░░▒▒▒▒▒▒▒░░▒▒▒░░░░░ ░░░░▒▒▒░░░ ░░░░░░░░▒▒░▒▒▒▒▒▒▒░░░   ░░░░░▒░░░░░░░░░░░░▒▒▒▒░░░░░░░░▒▒▒▒░░░ ░░░░▒▒▒▒▒▒░▒▒▒▒▒▒░░░░   ░░░░░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒▒░▒▒▒░▒░░░░   ░░ ░░░▒▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░░▒▒▒▒▒▒▒▒▒▒▒▒░▒░░░░░░   ░░ ░ ░░▒▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░   ░░░░░ ░░░░▒▒▒▓▓▓▓▒▒▒▒▒░░░░░░░▒▒▒▒▒▒▒░░░░░░░░ ░░░   ░░░░░░░ ░░░░░▒▒▒▒▒▒▒░░░░░░░░░░▒▒▒▒░░░░░░░░░ ░░░░   ░░░░░░ ░░░░░░░░░░░░ ░░░░░░░░░ ░░   ░░░░░ ░░░░░   ░ ░ ░░░░░   ░ ░░░░░░   ░ ░░ ░ ░ ░ ░░░░░░░   ░ ░░░░░░░░░ ░░░ press ⏎ to skip ░░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████ ✦   ✧ ████████████████████████   ✦ ✦ ████ ████   ✦ ████ ✦████   ✦ ████· ✦   ████   ░░░▒▒▓▓▓▓▓▒▒░░ ░▒▓▓████████▓▓▒▒▒▒▒░░▒▒▒▓▓▓▓▓▓▒▒▒░░ ░░░░▒▒▒▓▒▓▒▒▒░░   ░░░▒▒▒▒▒▒▒▒▒░░ ░▒▓▓███████▓▓▓▒▒▒░░░░▒▒▒▓▓▓▓▓▓▓▒░░░ ░░▒▒▒▒▒▒▒▒▒░░   ░░░░░░▒▒▒▒▒▒░░░░ ░▒▒▓███████▓▓▒▒▒▒▒▒░▒▒▒▓▓▓█▓▓▓▒▒░░ ░░░░▒▒▒▒▒▒░░░   ░░░░░░░░░░░▒░░░░░ ░░▒▓████████▓▒▒▒▒▒▒▒▒▓▓█▓███▓▓▒░░ ░░░░▒░▒░░░░░░░░   ░░░░░░░░░░░░░░░░ ░▒▒▒▓█████▓▓▓▒▒▒▒▒▒▓▓████▓▓▒▒░░░ ░░░░░░░░░░░░ ░░░ ░   ░ ░░░░░░░░░░▒░░░░░░░░░░▒▒▒▒▓▓▓▓▓▒▒▒▒▒▓▓▓▓██▓█▓▒▒░░░ ░░░░░░░░░░░░░░░░░░   ░ ░░░░░░▒▒▒▒▒░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▓▓▓▓█▓▓▒▒▒░░ ░░░░░░░░░░░░░░░░░░   ░ ░ ░░░░░▒▒▒▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▓▓▓▓▒▒▒▒░░ ░░░░░░░░░░░░░░░░░   ░ ░░░░▒░▒▒▒▒▒▒▒▒░░ ░░░░░▒▒▒▒▒▒▒░░░ ░░░░░░░░░░░▒▒▒▒░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░▒▒▒▒░░░ ░ ░░░░░░░░▒▒▒▒▒░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░▒▒▒░░░░ ░░░░░░░░░░░▒▒▒▒▒░░   ░░░░▒▒▒▒▒▒▒░░▒▒▒░░░ ░░░▒▒▒▒░░ ░ ░░░░░░▒▒░▒▒▒▒▒▒▒░░░░   ░░░░░▒░░░░░░░░░░░░▒░░░░░░░░░░░▒▒▒▒░░░ ░░░░░▒▒▒▒░▒▒▒▒▒▒░▒░░░   ░░░░░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░░░░▒▒▒▒▒▒▒▒░▒▒▒░▒▒░░░   ░░░░ ░░░▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▒▒▒▒▒▒▒░▒░░░░░░░   ░░ ░ ░░▒▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒▒▒▒░▒░░░░░░░   ░░░░ ░░░▒▒▒▒▓▓▓▓▒▓▒▒▒░▒░░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░ ░ ░░   ░░░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒░░░░░░░░░ ░░░░   ░░░░░░ ░░░░░░▒▒░░░░ ░░░░░░░░░░░░░ ░░ ░░   ░ ░ ░░░░░░ ░░░░░   ░ ░ ░░░░░░   ░░░░░   ░ ░░ ░ ░░░░░░░   ░ ░░░░ ░ ░░ ░░░ press ⏎ to skip ░░░░░░░  [?7h[?25l[?7l ✦ ✦ ✧   · ✦ ✦ ✦ ✧ · ✦   ✧ ✦ ✦ ✧ ✧ · · ·   ·   ✦ ✦   ✧ ✦   ████████████████████████ ✧ · ✦   ████████████████████████ ✧ ·✧   · ████ ████   ✦ ████ ████ ✧   ████ ████ ✦   ████ ████ ✦   ▒▒▒▒ ████ ·   ▒▒▒▒ ████ ·   · ████ ·   ████   ░░░▒▒▓▓▓▓▓▒▒░ ░▒▓▓███████▓▓▓▒▒▒▒▒░░░▒▒▓▓▓▓▓▓▓▒▒░░ ░░░▒▒▒▓▒▓▒▒▒░░   ░░░▒▒▒▒▒▒▒▒▒░░ ░▒▓▓███████▓▓▓▒▒▒░░░░▒▒▒▓▓▓▓▓▓▓▒▒░░░ ░░░▒▒▒▒▒▒▒░░   ░░░░░░▒▒▒▒▒▒░░░ ░▒▓████████▓▓▒▒▒▒░░░░▒▒▓▓▓██▓▓▒▒░░ ░ ░░▒▒▒▒▒▒░░░   ░░░░░░░░░░░▒░░░░ ░░▒▓████████▓▒▒▒▒▒▒▒▒▒▓▓▓███▓▓▒▒░░ ░░░▒░░░░░░░░░░   ░ ░░░░░░░░░░░░░░░ ░▒▒▓██████▓▓▒▒▒▒▒▒▒▓▓▓████▓▓▒▒░░ ░░░░░░░░░░░░ ░░░ ░   ░░░░░░░░░░░░░░░░░░ ░░░░░▒▒▓▓▓▓▓▓▓▒▒▒▒▒▓▓▓▓████▓▓▒▒░░ ░░░░░░░░░░░░░░░░░░   ░ ░░░░░░░▒░▒▒░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▓▓▓███▓▓▒▒░░ ░░░░░░░░░░░░░░░░░   ░ ░░░░░▒▒▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▒▓▓▓▓▓▒▒▒▒░░ ░░ ░░░░░░░░░░░░░░░   ░ ░░░░░░▒▒▒▒▒▒▒▒░░ ░░░░░░▒▒▒▒▒▒▒▒░░░ ░░░░░░░░░░░▒▒▒▒░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒░░░ ░ ░░░░░░░░▒▒▒▒▒░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░▒▒▒░░░░ ░░░░░░░░░░░▒▒▒▒▒░░   ░░░▒▒▒▒▒▒▒▒░░▒▒▒░░ ░░░▒░▒▒░░ ░░░░░░▒░░▒▒▒▒▒▒▒░░░   ░░░░░▒░░░░░░░░░░░░░░░░░░░░ ░░░▒▒▒▒░░░ ░░░░▒▒▒▒░▒▒▒▒▒▒▒▒░░░░   ░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒░░ ░░░░░░▒░▒▒▒▒▒▒░▒▒▒░▒▒░░░   ░░░░ ░░░░▒▒▒▒▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▒▒▒▒▒▒▒░▒░▒░░░░░   ░ ░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒░▒░░░░░░░   ░░░░ ░░░▒▒▒▒▓▓▓▓▒▒▒▒▒░▒░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░░ ░ ░   ░░░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒░░░░░░ ░░░░   ░░░░░░ ░░░░░▒▒▒░░░░ ░░░░░░░░░░░░░ ░░ ░░   ░ ░░ ░ ░░░░░░░ ░ ░░░░░░   ░ ░░ ░ ░░░░░   ░░░░░░   ░ ░ ░░░░░   ░ ░░░░ ░ ░░ ░░░ press ⏎ to skip ░░░░░░░ ░  [?7h[?25l[?7l ████████████████████████ ✧ · ✦   ████████████████████████ ✧ ·✧   ████ ████ ✦   ▒▒▒▒ ████ ·   ▒▒▒▒ ████ ·   · ████ ·   ████   ░░░▒▓▓▓▓▓▓▒▒░ ░▒▓▓██████▓▓▓▓▒▒▒▒▒░░░▒▒▒▓▓▓▓▓▓▒▒▒░░ ░░▒▒▓▒▓▒▒▒▒░   ░░░░▒▒▓▓▒▒▒▒░░ ░▒▓███████▓▓▒▒▒▒▒░░░░░▒▒▓▓▓▓▓▓▓▒▒▒░░ ░░░▒▒▒▒▒▒▒░░░   ░░░░░░▒▒▒▒▒▒░░░ ░▒▓████████▓▓▒▒▒▒░░░░░▒▒▓▓██▓▓▓▒▒░░░ ░░░▒▒▒▒░░░░   ░░░░░░░░▒░▒▒░░░░ ░▒▓▓████████▓▒▒▒▒▒░░▒▒▓▓▓███▓▓▒▒░░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░ ░░▒▓▓██████▓▓▒▒▒▒▒▒▒▓▓▓▓███▓▓▓▒▒░░ ░ ░░░░░░░░░░ ░░░ ░   ░░░░░░░░░░░░░░░░░ ░░░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▓▓▓▓████▓▓▒▒░░ ░░░░░░░░░░░░░░░░░   ░ ░ ░░░░░░░░░░░░░░ ░░░░░▒▒▒▒▓▒▒▒▒▒▒▒▓▓▓███▓▓▓▒░░ ░░ ░░░░░░░░░░░░░░░   ░ ░░░░░░▒▒▒▒▒▒░░░░ ░░░░▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒░░ ░ ░░░░░░░░░░░░░░░   ░░░░░░▒▒▒▒▒▒▒▒░░ ░░░▒░░▒▒▒▓▓▓▒▒░░░ ░░░░░░░░▒▒▒▒░░░   ░░░░▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░░▒▒▒▒▒░░░ ░░░░░░░░░▒▒▒▒░░░░░   ░░░▒░▒▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▒▒░░░ ░ ░░░░░░░░░▒▒▒▒▒▒░░░   ░░░░▒▒▒▒▒▒▒▒░▒▒▒░░░ ░░▒░▒▒░░ ░░░░░░▒░░▒▒▒▒▒▒▒░░░   ░░░░░▒░░░░░▒░▒░░░░░░░░ ░░▒▒▒▒░░░ ░░░▒▒▒░░▒▒▒▒▒▒▒▒░░░░   ░░░░░░░░░░░░░░░░▒▒▒▒▒░▒▒░░░▒░▒▒▒▒▒▒░░ ░░░░▒▒▒░▒▒░▒▒▒░▒▒░░░   ░░░░░ ░░░░▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒░▒▒░░░░   ░░▒▒▓▒▓▓▓▓▓▓▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒░▒░▒░░░░░   ░░░ ░░░▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░░▒▒▒▒▒▒▒▒▒░▒░░░░░░░ ░   ░░░░░░ ░░░▒▒▒▒▒▓▓▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒░░░░░░ ░ ░░░░   ░░░░ ░ ░░░▒▒▒▒▒▒░░░░░ ░░░░░▒▒░▒░░░░ ░░ ░░   ░ ░░░░░ ░ ░░░░░░░░░ ░░░░░   ░ ░░░ ░░░░░   ░░░░░░   ░░░░   ░ ░ ░░ ░ ░ press ⏎ to skip ░░░░░ ░  [?7h[?25l[?7l ████████████████████████ ✧ · ✦   ████████████████████████ ✧ ·✧   · ████ ████   ✦ ████ ████ ✧   ████ ████ ✦   ████ ████ ✦   ▒▒▒▒ ████ ·   ▒▒▒▒ ████ ·   ░░▒▒▓▓▓▓▓▓▒░░ ░░▒▓▓█████▓▓▓▓▓▒▒▒▒▒░░░▒▒▒▒▒▓▓▓▓▒▒▒░░ ░░░▒▒▒▓▒▒▒▒░   ░░░▒▒▒▓▓▒▒▒▒░░ ░▒▒▓███████▓▓▒▒▒▒▒▒░░░░░▒▒▓▓▓▓▓▓▒▒▒░░ ░░░▒▒▒▒▒▒░░░░   ░░░░░▒▒▒▒▒▒▒░░░ ░▒▓███████▓▓▓▒▒▒▒░░░░░▒▒▓▓▓▓▓▓▓▓▒▒░░ ░░░▒▒▒▒░░░░░   ░░░░░░▒▒▒░▒▒░░░ ░▒▓█████████▓▒▒▒▒▒░░▒▒▒▓▓▓██▓▓▓▒▒░░ ░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░ ░▒▒▓▓██████▓▓▒▒▒▒▒▒▒▒▓▓▓████▓▓▒▒░░ ░░░░░░░░░░ ░░░ ░░   ░░░░░░░░░░░░░░░░░ ░░░▒▒▓▓█▓█▓▓▓▓▒▒▒▒▒▒▓▓▓████▓▓▓▒▒░░ ░░░░░░░░░░░░░░░░   ░ ░░ ░░░░░░░░░░░░░░ ░░░▒▒▒▒▒▓▓▒▒▒▒▒▒▒▓▓▓███▓▓▓▒▒░░ ░░ ░░░░░░░░░░░░░░   ░ ░░░░░░░▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▒░░░ ░ ░░░░░░░░░░░░░░░░   ░ ░░░░░░▒▒▒▒▒▒▒▒░░ ░░░▒░░▒▒▒▓▓▓▓▒▒▒░ ░░░░░░░░░▒▒▒░░░░   ░░░░▒▒░▒▒▒▒▒▒▒░░░░ ░░░░░░▒▒▒▒▒▒░░░ ░░░░░░░░▒▒▒░▒░░░   ░░░▒░▒▒▒▒▒▒▒▒▒▒▒░░ ░░░░░▒▒▒▒░░░ ░░░░░░░░░▒▒▒▒▒▒░░░   ░░░░▒▒▒▒▒▒▒▒░▒▒▒░░░ ░░▒░▒▒░░ ░░░░░░░░░▒▒▒▒▒▒░░░   ░░░░▒▒▒░▒░░▒░▒░░░░░░ ░░▒▒▒▒░░░ ░░░▒░░░▒▒▒▒▒▒▒▒░░░░   ░░░░░▒░░░░░░░░░░▒▒▒░░░░░░░░░░▒▒▒▒▒▒░░ ░░░▒░▒░▒▒░▒▒▒░▒▒▒░░░   ░░░░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░▒▒░░░░   ░░░░▒▒▓▒▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒░▒▒░░░░░░   ░ ░ ░░░▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░░▒▒▒▒▒▒▒▒▒░▒░░░░░░░   ░░░░░░ ░░░▒▒▒▒▒▓▓▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒░░░░░░░░ ░░░   ░░░░ ░ ░░▒▒▒▒▒▒▒░░░░░░ ░░░░░▒▒░▒░░░░░ ░░   ░ ░ ░ ░░░░░░░░░ ░░░░░░░░░░ ░░░░   ░ ░ ░░░░ ░░░░░   ░░░ ░░░░░░   ░░░   ░ ░ ░░ ░ press ⏎ to skip ░░░░░ ░  [?7h[?25l[?7l ████████████████████████ ✧ · ✦   ████████████████████████ ✧ ·✧   · ████ ████   ✦ ████ ████ ✧   ████ ████ ✦   ████ ████ ✦   ▒▒▒▒ ████ ·   ░░▒▒▓▓▓▓▓▒▒░ ░▒▒▓▓▓███▓▓▓▓▓▓▒▒▒▒▒░░░▒░▒▒▒▓▓▓▓▒▒▒░░ ░ ░░░▒▒▒▒▒▒▒▒░░   ░░░▒▒▒▓▓▒▒▒▒░░ ░▒▒▓███████▓▓▒▒▒▒▒▒░░░░░▒▒▒▓▓▓▓▓▓▒▒░░░ ░░░▒▒▒▒▒▒░░░░   ░░░░▒▒▒▒▒▒▒▒░░░ ░▒▓▓███████▓▓▓▒▒▒▒░░░░░░▒▒▓▓▓▓█▓▓▒▒░░ ░░░▒░▒▒▒░░░░   ░░░░░░▒▒▒▒▒▒░░░ ░░▒▓████████▓▒▒▒▒▒▒░░▒▒▒▓▓▓███▓▓▒▒░░ ░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░ ░▒▓▓███████▓▓▒▒▒▒▒▒▒▒▒▓▓████▓▓▒▒░░ ░░░░░░░░░░ ░░░ ░░   ░░░░░░░░░░░░░░░ ░ ░░░▒▒▓▓████▓▓▓▒▒▒▒▒▒▓▓▓▓████▓▓▒▒░░ ░░░░░░░░░░░░░░░░   ░ ░ ░░░░░░░░░░░░░░░ ░░░▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒▓▓▓▓███▓▓▓▒▒░ ░░░░░░░░░░░░░░   ░ ░░░░░░░░▒░░░░░░ ░░▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▒▒░░ ░ ░░░░░░░░░░░░░░░   ░░ ░░░░░░▒▒▒▒▒▒▒░░░ ░ ░░░▒░░▒▒▒▓▓▓▓▒▒▒░░ ░░░░░░░░░▒▒▒░░░░   ░░░░░▒░▒▒▒▒▒▒▒░░ ░░░░░░▒▒▒▒▒▒▒░░ ░░░░░░░░▒▒▒▒▒░░░   ░░░▒░░▒▒▒▒▒▒▒▒▒░░ ░░░░░▒▒▒▒░░░ ░░░░░░░░▒▒▒▒▒▒░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒░░░ ░░▒░▒▒░░░ ░░░░░░░░░▒▒▒▒▒▒░░░   ░░▒░▒▒▒▒▒░▒▒░▒░░░░░ ░░▒▒▒▒░░░ ░░░░░░░▒▒▒▒▒▒▒▒░▒░░   ░░░░░▒░░░░░░░░░░░▒░░░░░░░░░░░▒▒▒▒▒▒░░ ░░░░▒░▒▒░▒▒▒░▒▒▒▒░░░   ░░░░░░░ ░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░▒▒░░░░░   ░░░░▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░   ░ ░░░░▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░░▒░▒▒▒▒▒▒▒▒▒░░░░░░░ ░   ░░░░░ ░░▒▒▒▒▓▓▓▓▒▒▒▒░▒░░░░░░░▒▒▒▒▒▒▒▒░░░░░░░ ░░   ░░░░ ░ ░░░▒▒▒▒▒▒▒▒░░░░░ ░░░░░▒▒░▒░░░░░ ░░   ░ ░ ░░░░░░░░░░░ ░░░░░░░░░░░░ ░░░   ░ ░ ░░░░░ ░░░░   ░ press ⏎ to skip ░░░░  [?7h[?25l[?7l ████████████████████████ ✧ · ✦   ████████████████████████ ✧ ·✧   ░░▒▒▓▓▓▓▓▒▒░ ░░▒▒▓▓▓█▓▓▓▓▓▓▓▒▒▒▒▒▒░░░▒░▒▒▒▒▓▓▓▓▓▒▒░░░ ░░░░▒▒▒▒▒▒▒▒░░   ░░░▒▒▒▓▓▒▒▒▒░ ░▒▓▓██████▓▓▓▒▒▒▒▒▒░░░░░░▒▒▓▓▓▓▓▓▒▒▒░░░ ░░░░▒▒▒▒▒░░░░   ░░░░▒▒▒▒▒▒▒▒░░ ░▒▓████████▓▓▒▒▒▒▒░░░░░░▒▒▒▓▓▓█▓▓▒▒░░░ ░░░░░▒▒▒░░░░░   ░░░▒░░▒▒▒▒▒▒░░ ░▒▒▓████████▓▒▒▒▒▒▒░░░▒▒▒▓▓████▓▒▒░░░ ░ ░░░░░░░░░░░   ░░░░░░░░░░░░░░░░ ░▒▓▓██████▓▓▓▒▒▒▒▒▒▒▒▒▓▓█████▓▓▒▒░ ░░░░░░░░░░ ░░░ ░░   ░░░░░░░░░░░░░ ░░ ░░▒▒▓▓█████▓▓▓▒▒▒▒▒▒▒▒▓▓█████▓▓▒▒░ ░ ░░░░░░░░░░░░░░ ░   ░░░░░░░░░░░░░░░░░░░░ ░░░▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒▓▓▓████▓▓▒▒░░ ░░░░░░░░░░░░░░   ░ ░░░░░░░░░░░░░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▒▒░ ░░░░░░░░░░░░░░░   ░░ ░░░░░░░▒▒▒▒▒▒░░ ░░▒▒▒▒░░▒▒▒▓▓▓▓▓▒▒▒░ ░░░░░░░▒▒▒░░░░░   ░░░░░░░░▒▒▒▒▒▒░░ ░░░░░░▒▒▒▒▒▒▒░░░ ░░░░░░░░▒▒░░▒░░░   ░░░▒░░▒▒▒▒▒▒▒▒▒░░ ░░░░░▒▒▒▒▒░░ ░░░░░░░▒▒░▒▒▒░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒░░░ ░░▒░░▒▒░░ ░░░░░░░▒▒▒▒▒▒░▒░░░   ░░▒░▒▒▒▒▒░▒▒░▒░░░ ░ ░░░▒▒▒░░░ ░░░░░░▒▒▒▒▒▒▒▒░▒░░░   ░░░░░▒░░░░░░░░░░░░░░░ ░░░░ ░░░░▒▒▒▒░░ ░░░░▒░▒▒░▒▒▒░▒▒▒▒░░░   ░░░░░░░░░░ ░░░░░▒▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒░░░ ░░░▒▒▒▒▒▒▒▒▒░▒▒░░░░░   ░ ░░░░▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░   ░░░▒▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▒▒▒▒▒▒░░░░░░░ ░   ░░░░ ░░▒▒▒▒▓▓▓▓▒▒▒▒░▒░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░ ░   ░░░░ ░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░▒▒▒▒░░░░░░ ░   ░ ░ ░ ░░░░░░▒░░░░ ░░░░░░░░░░░░ ░ ░ ░   ░ ░ ░ ░ ░░░░░░ ░░░   ░░░░░ ░░░░░░   ░░  [?7h[?25l[?7l ████████████████████████ ✧ · ✦   ████████████████████████ ✧ ·✧   · ████ ████   ✦ ████ ████ ✧   ░▒▒▒▓▓▓▓▓▒▒░ ░▒▒▓▓▓▓█▓▓▓▓▓▒▓▒▒▒▒▒▒░░░░░▒▒▒▒▒▓▓▓▓▒▒░░░ ░░░▒▒▒▒▒▒▒▒░░   ░░▒▒▒▒▓▓▒▒▒░░ ░░▒▓▓██████▓▓▒▒▒▒▒▒▒░░░░░░▒▒▒▓▓▓▓▓▓▒▒░░░ ░ ░░▒▒▒▒▒▒▒░░░   ░░░▒▒▒▒▒▒▒▒▒░ ░▒▓████████▓▒▒▒▒▒▒░░░░░░░▒▒▓▓▓█▓▓▓▒░░░░ ░░░░▒▒▒░░░░░   ░░░▒░▒▒▒▒▒▒▒░ ░▒▒▓███████▓▓▒▒▒▒▒▒░░░░▒▒▒▓▓███▓▓▒▒░░ ░░░░░░░░░░░   ░░░░░░░▒▒░░░░░░ ░░▒▓███████▓▓▓▒▒▒▒▒▒░▒▒▒▓▓█████▓▓▒░░ ░ ░░░░░░░░ ░░░ ░░   ░░░░░░░░░░░░░ ░░▒▒▓██████▓▓▓▒▒▒▒▒▒▒▒▓▓█████▓▓▒▒░░░ ░░░░░░░░░░░░ ░   ░░░░░░░░░░░░░░░░░ ░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▓▓█████▓▓▒░░░ ░░░░░░░░░░░░░░   ░ ░░░░░░░░░░░░░░ ░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▒▒░ ░░░░░░░░░░░░░░░   ░░ ░░░░░░░░▒▒▒▒░░░ ░░░░▒▒▒▒░░▒▒▒▓▓▓▓▓▒▒▒░░ ░░░░░░▒▒▒░░░░░   ░░░░░░░░▒▒▒▒▒▒░░ ░░░░░░▒▒▒▓▒▒▒▒░░ ░░░░░░░░▒░░▒░▒░░   ░░░░▒░░▒▒▒▒▒▒▒▒▒░░ ░░░░░░▒▒▒▒▒▒░░ ░░░░░░░░▒░▒▒▒░░░░   ░░░░░▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░▒▒░░ ░░░░░░░▒▒▒▒▒▒▒▒░░   ░░░▒░▒▒▒▒▒▒▒▒░▒░░░ ░░░▒░░▒░░ ░░░░░▒▒▒▒▒▒▒▒░▒░░░   ░░░░▒░▒▒░▒░░░░░░░░░░░░ ░░░░▒▒▒▒░░ ░░░░▒▒░░▒▒░▒▒▒▒▒░░░   ░░░░░░░░░░░░░░░░░▒▒░░▒░░░░░░▒▒▒▒▒▒▒░░░ ░░░▒▒▒▒▒▒▒▒▒░▒▒░░░░░░   ░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░   ░░░▒▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░   ░░ ░░░▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░   ░░░░ ░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒░░░░░ ░ ░ ░   ░ ░ ░░░░░░░▒▒▒░░░ ░░░░░░░░░░░░░░░ ░   ░ ░ ░░░░░ ░░░░░░░░ ░ ░░   ░░░░░ ░░░░░░   ░░   press ⏎ to skip ░░░░  [?7h[?25l[?7l ████████████████████████ ✧ · ✦   ████████████████████████ ✧ ·✧   · ████ ████   ✦ ████ ████ ✧   ████ ████ ✦   ████ ████ ✦   ▒▒▒▒ ████ ·   ▒▒▒▒ ████ ·   ░▒▒▒▓▓▓▒▒▒░░ ░░▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒░░░░░▒▒▒▒▒▓▓▓▓▓▒▒░░░ ░░▒▒▒▒▒▒▒▒░░   ░░▒▒▒▒▓▓▒▒▒░░ ░▒▒▓▓▓████▓▓▒▒▒▒▒▒▒▒░░░░░░░▒▒▓▓▓▓▓▓▓▒▒░░ ░░░░▒▒▒▒▒░░░   ░░░▒▒▒▒▒▒▒▒▒░ ░▒▓███████▓▓▒▒▒▒▒▒░░░░ ░░▒▒▓▓▓█▓▓▓▒▒░░░ ░░░░▒▒▒░░░░░   ░░░▒▒▒▒▒▒▒▒▒░ ░░▒▓████████▓▓▒▒▒▒▒▒░░░░░▒▒▓▓███▓▓▓▒▒░░ ░░░░░░░░░░░   ░░░░▒░░▒▒░░░░░░ ░▒▓▓███████▓▓▒▒▒▒▒░▒░▒▒▒▒▓█████▓▓▒▒░ ░░░░░░░░ ░░░ ░░   ░░░░░░░░░░░░░░ ░▒▒▓▓██████▓▓▓▒▒▒▒▒▒▒▒▒▓▓█████▓▓▒▒░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░ ░▒▒▒▓▓▓█▓▓▓▓▒▒▒▒▒▒▒▒▓▓██████▓▒▒░░ ░ ░░░░░░░░░░░░░░   ░ ░ ░ ░░ ░░░░░░░░ ░░░░░▒▒▒▒▓▒▒▒▒▒▒▒▒▒▓▓▓▓▓██▓▓▒▒░ ░ ░░░░░░░░░░░░░   ░░ ░░░░░░░░▒▒░░░░░ ░░▒░▒▒▒▒░░▒▒▒▓▓▓▓▓▓▓▒▒░ ░░░░░░░▒▒░░░░░   ░░░░░░░░▒▒▒▒▒▒░░ ░░░░░░░▒▒▒▓▒▒▒▒▒░ ░░░░░░░▒░░▒▒▒░░░   ░░░░░░░░▒▒▒▒▒▒▒░░░ ░░░░░░▒▒▒▒▒▒░░ ░ ░░░░░▒░▒▒▒░░░░░   ░░░░░░▒▒▒▒▒▒▒▒▒▒░░ ░░░░░░▒▒░░░ ░░░░░░░▒▒▒▒▒▒▒░░   ░░░▒░▒▒▒▒▒▒▒▒░▒░░ ░░░▒░░▒░░░ ░░░░░░░▒▒▒▒░▒░▒▒░░   ░░░░▒▒▒▒▒▒░░░░░░░░░░ ░░░░▒░▒▒░░ ░░░░▒▒░░▒▒░▒▒▒▒▒░░░   ░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▒░▒▒░░░░░░   ░░░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░ ░░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░   ░░░▒▒▒▒▒▓▓▓▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒░░░░░░░░░░   ░░░▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░   ░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒▒░░░░░░░ ░   ░ ░ ░░░░▒▒▒▒▒▒░░░ ░░░░░░░░▒░░░▒░░░░ ░   ░ ░ ░ ░░░░░░ ░░░░░░░░░░ ░░   ░░░░░ ░░░░░   press ⏎ to skip ░ ░  [?7h[?25l[?7l ████████████████████████ ✧ · ✦   ████████████████████████ ✧ ·✧   · ████ ████   ✦ ████ ████ ✧   ████ ████ ✦   ████ ████ ✦   ▒▒▒▒ ████ ·   ▒▒▒▒ ████ ·   ░▒▒▒▓▓▒▒▒▒░░ ░░▒▒▓▓▓▓▓▒▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░▒▒▒▒▒▓▓▓▓▓▒▒░░░ ░░░▒▒▒▒▒▒░░░   ░░▒▒▒▒▓▓▒▒▒░ ░▒▒▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▒▓▓▓▓▓▒▒░░ ░░░▒▒▒▒▒░░░   ░░░▒▒▒▒▒▒▒▒░░ ░░▒▓██████▓▓▒▒▒▒▒▒▒░░░░ ░░░▒▒▓▓█▓█▓▓▒▒░░ ░ ░░▒░░░▒░░░   ░░░▒▒▒▒▒▒▒▒░░ ░░▒▓████████▓▓▒▒▒▒▒░░░░░░▒▒▒▓███▓▓▓▒▒░░░ ░ ░░░░░░░░░   ░░░░▒░▒▒▒░░░░ ░▒▓████████▓▓▒▒▒▒▒░▒░░░▒▒▓▓████▓▓▒▒░░ ░░░░░░░░ ░░░ ░░   ░░░░░░░░░░░░░░ ░▒▒▓▓██████▓▓▓▒▒▒▒▒▒▒▒▒▓▓▓████▓▓▒▒░░ ░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░ ░░▒▒▓▓▓████▓▓▒▒▒▒▒▒▒▒▓▓██████▓▓▒░░ ░░░░░░░░░░░░░   ░░░░░░░ ░ ░░░░░░░ ░░░▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒▒▓▓▓▓██▓▓▒▒░░ ░░░░░░░░░░░░░   ░░░ ░ ░░░░░░░░░░░░ ░░░▒▒▒▒▒▒░░▒▒▒▓▓▓▓▓▓▓▒▒░░ ░░ ░░░░░▒░░░░░░░   ░ ░░░░░░░░▒▒▒▒▒▒░░ ░░░░░░░░▒▒▒▓▓▓▒▒▒░░ ░ ░░░░░░░░▒▒▒░░░   ░░░░░░░░▒▒▒▒▒░▒░░░ ░░░░░░▒▒▒▒▒▒▒░░ ░░░░░░░▒▒░░░░░   ░░░░░░▒▒▒▒▒▒▒▒░▒░░ ░░░░░░▒▒▒░░ ░░░░░░░▒▒▒▒▒▒▒░░   ░░░▒░▒▒▒▒▒▒▒▒░▒░░ ░░░░░░▒░░░ ░░░░░░░░░▒▒░▒░▒▒▒░░░   ░░░░▒▒▒▒▒▒░░░░░░░░░░ ░░░░▒░▒▒░░ ░░░░░▒░░▒▒░▒▒▒▒▒░░░   ░░░░░▒░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒░░░░ ░░░░░░▒▒▒▒░▒▒░░░▒░░░   ░░░░░░░ ░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░▒▒▒▒▒▒▒▒▒░░░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░░   ░░▒▒▒▒▒▓▒▓▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░   ░░ ░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░▒▒▒▒▒▒▒░░░░░░░   ░ ░ ░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░▒░▒░▒░░░░ ░   ░ ░ ░░░░░░░░░ ░░░░░░░░░░ ░   ░░░░░░ ░░░░   ░ ░   press ⏎ to skip ░ ░  [?7h[?25l[?7l ✧   ✧ ✧ · · ✦✧   ✦   · ✦ ✧ ✦   · · · ·   · · ·✧ · ·   ████████████████████████ ✧   ████████████████████████ ✦   ✧ ████ ████   ✦ ████ ████ ✧   · ████ ████ ✧   · · ████ ████ ·   ✧ ✦ ▒▒▒▒ ████ ✧   · ▒▒▒▒ ████ ✧   · · ████ ·   ░░▒▒▒▓▓▒▒▒░░ ░░░▒▒▒▓▓▒▒▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░▒▒▒▒▒▓▓▓▓▓▒▒░░░ ░░░▒▒▒▒▒▒░░░░   ░░▒▒▒▒▓▓▒▒▒░ ░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▓▓▓▓▓▒▒░░░ ░░░▒▒▒▒▒░░░   ░░░▒▒▒▒▒▒▒▒░░ ░▒▓▓█████▓▓▓▒▒▒▒▒▒▒░░░ ░░▒▒▒▓▓▓█▓▓▒▒░░ ░░░░░░▒░░░   ░░░▒▒▒▒▒▒▒▒░ ░▒▒▓███████▓▓▓▒▒▒▒▒░░ ░░░░▒▒▓▓▓█▓▓▓▒▒░░░ ░░░░░░░░░   ░░░░▒▒▒▒▒░░░░ ░▒▓████████▓▒▒▒▒▒▒░░░░░▒▒▓▓▓███▓▓▓▒░░ ░ ░░░░░░ ░░░ ░░   ░░░░░░░░░░░░░░ ░▒▒▓███████▓▓▓▒▒▒▒▒▒▒▒▒▒▓▓█████▓▓▒░░░ ░░░░░░░░░░░░░   ░░░░░░░░░░░░░░ ░░▒▒▓██████▓▓▒▒▒▒▒▒▒▒▒▓▓█████▓▓▒▒░░ ░ ░░░░░░░░░░░   ░░░░░░░ ░ ░░░░ ░░░▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▓▓▓▓███▓▓▒░░ ░░░░░░░░░░░░░   ░░░ ░ ░░░░░░░░░░░░ ░░░▒▒▒▒▒▒▒▒░░▒▒▒▓▓▓▓▓▓▓▓▒░░ ░ ░░░░░▒░░░░░░░   ░ ░░░░░░░░░▒▒░░░░ ░░░░░░░░░▒▒▒▓▓▓▓▒▒▒░ ░░░░░░░░▒▒▒░░░   ░░░░░░░░░░▒▒▒░░░░ ░░░░░░░▒▒▒▒▒▒▒░░ ░░░░░░░▒▒░░▒░░░   ░░░░░░▒▒▒▒▒▒▒░░░░░ ░░░░░░░▒▒▒░░░ ░░░░░░░░▒▒▒▒▒▒░░░   ░░░▒░▒▒▒▒▒▒▒▒░▒░░ ░░░░░░▒░░░░ ░░░░░░░░░▒░▒░▒▒▒░░░   ░░░░▒▒▒▒▒▒░░░░░░░░░ ░░░░▒░▒▒░░ ░░░░░░▒▒░▒▒▒▒▒░░░░   ░░░░░▒░░░░░░░░░░░░░░░░ ░░░░▒▒▒▒░░░░ ░░░░░▒▒▒▒░▒▒░░▒▒░░░░   ░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒░▒░▒▒▒▒▒▒░░░ ░░░▒▒▒▒▒▒▒▒▒░▒░▒░░░░   ░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░▒▒▒▒▒▒▒░░░░░░░░░   ░░▒▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░   ░ ░░▒▒▒▓▒▒▒▒▒▒▒▒░░░░░░░░░░▒▒▒▒▒▒▒░░░░░░░ ░   ░ ░ ░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░▒▒▒░▒░░░░░░░   ░ ░ ░░░░░▒░░░ ░░░░░▒▒░░░░ ░   ░░░░░░░░ ░░   press ⏎ to skip ░ ░ ░  [?7h[?25l[?7l ████████████████████████ ✧   ████████████████████████ ✦   ✦ ████ ████ ✧   · ████ ████ ✧   · · ████ ████ ·   ✧ ✦ ▒▒▒▒ ████ ✧   · ▒▒▒▒ ████ ✧   ░▒▒▒▒▓▓▒▒▒░░ ░░▒▒▒▒▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓▒▒▒▒░░░░▒░▒▒▒▓▓▓▓▒▒▒░░ ░░▒▒▒▒▒▒▒░░░   ░░▒▒▒▒▓▒▒▒░░ ░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▒▓▓▓▓▓▒▒░░ ░░▒░▒▒▒▒░░   ░░░▒▒▒▒▒▒▒▒░ ░░▒▓▓████▓▓▓▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▓▓▓█▓▓▒▒░░░ ░░░░░░▒░░░   ░░░▒▒▒▒▒▒▒░░ ░░▒▒▓██████▓▓▓▒▒▒▒▒▒░░ ░░░░░▒▒▓▓██▓▓▓▒▒░░░ ░░░░░░░░   ░░░░▒▒▒▒▒░░░░ ░░▒▓███████▓▓▒▒▒▒▒▒░░░░░░▒▒▓▓▓██▓▓▓▒▒░░ ░░░░░░ ░░░ ░░   ░░░░░░▒░░░░░░░ ░░▒▓▓██████▓▓▓▒▒▒▒▒▒▒▒░▒▒▓▓█████▓▓▒▒░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░ ░ ░▒▒▓▓██████▓▒▒▒▒▒▒▒▒▒▒▓▓▓████▓▓▒▒▒░░ ░ ░░░░░░░░░░░   ░░░░░░░ ░ ░░ ░ ░░▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▓▓▓▓███▓▓▒▒░░ ░░░ ░░░░░░░░░   ░ ░░░ ░ ░ ░░░░░░░░░░ ░░░░▒▒▒▒▒▒▒▒░▒▒▒▒▓▓▓█▓▓▓▓▒▒░░ ░░░░░░░░░░░   ░ ░░░░░░░░░▒▒░░░ ░░░░▒░░░░░▒▒▒▓▓▓▓▓▒▒░░ ░░░░░░░░▒░░░░   ░░░░░░░░░░▒▒▒░░░░ ░░░░░░░▒▒▒▒▒▒▒▒░░ ░░░░░░▒▒░░▒░░░   ░░░░░░▒░▒▒▒▒▒░░░░░ ░░░░░░░▒▒▒▒░░░ ░░░░░░░░▒▒▒▒▒░░░   ░░░▒░▒▒▒▒▒▒▒▒░░░░ ░░░░░░▒▒░░░ ░░░░░░░░░▒░▒▒▒░░░   ░░░░▒▒▒▒▒▒░░░░░░░░░ ░░░░▒░▒░░░ ░░░░░░░▒░▒▒▒▒▒░░░░   ░░░░░░▒▒░░░░░░░░░░░░ ░░░░▒▒▒░░░░░ ░░░░░░▒░▒░▒▒░░▒▒░░░░   ░░░░░░░░░░░░░░░░▒▒▒░░░░░░░░▒▒▒▒▒▒░░░ ░░░░░▒▒▒▒▒▒▒░▒░▒░░░░   ░░░ ░ ░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░ ░░░▒▒▒▒▒▒▒▒░░░░░░░░   ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░   ░░░▒▒▓▓▒▒▒▒▒▒▒▒▒░▒░░░░░░░░░▒▒▒▒▒▒░░░░░░ ░   ░ ░ ░░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░▒▒▒▒░▒░░░░░   ░ ░ ░░░░░░░▒░░░░ ░░░░░▒▒░░░░░░   ░░ ░░░░░░░░░ ░░   ░  [?7h[?25l[?7l ████████████████████████ ✧   ████████████████████████ ✦   ✧ ████ ████   ✦ ████ ████ ✧   · ████ ████ ✧   · · ████ ████ ·   ████   ░▒▒▒▒▓▓▒▒▒░░ ░░▒▒▒▒▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓▒▒▒▒░░░░░░▒▒▒▒▓▓▓▒▒▒▒░░░ ░░░▒▒▒▒▒▒▒░░   ░░▒▒▒▒▓▒▒░░░ ░░▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▓▓▓▓▓▒▒▒░░░ ░░░░▒▒▒▒▒░   ░░░▒▒▒▒▒▒▒▒░ ░▒▒▓▓█▓█▓▓▓▓▒▒▒▒▒▒▒▒▒░░░ ░░▒▒▒▓▓█▓▓▓▒▒░░ ░░░░▒░░░░   ░░░▒▒▒▒▒▒▒░░ ░░▒▓▓█████▓▓▓▒▒▒▒▒▒▒░░ ░ ░░░▒▓▓██▓▓▓▒▒░░░ ░░░░░░░░░   ░░░░▒▒▒▒▒░░░░ ░▒▒▓███████▓▓▒▒▒▒▒▒░░░░░░░▒▓▓▓████▓▓▒░░░ ░░░░░░ ░░░ ░░   ░░░░░░▒░░░░░░░ ░▒▒▓███████▓▓▒▒▒▒▒▒▒▒▒░░▒▒▓██████▓▓▒▒░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░ ░░▒▒▓███████▓▒▒▒▒▒▒▒▒▒▒▒▓▓▓████▓▓▒▒░░ ░ ░░░░░░░░░░   ░░░░░░░ ░ ░░▒▒▒▓▓█▓▓▓▓▒▒▒▒▒▒▒▒▒▓▓▓▓████▓▓▒░░░ ░ ░ ░░░░░░░░░░   ░░░░░ ░ ░ ░░░░░░░ ░░░░▒▒▒▓▒▒▒▒▒▒▒▒▒▒▓▓▓█▓▓█▓▓▒▒░░ ░░░░░░░░░░░   ░ ░░░░░░░░░░░░░░ ░░░░░░░▒░░▒░░▒▒▒▓▓▓▓▓▓▒░░ ░░░░░░░░▒░░░░   ░░░░░░░░░░▒▒▒░░░░ ░░░░░░░▒▒▒▒▒▓▒▒░░░ ░░░░░░▒▒░░▒░░░   ░░░░░░▒░▒▒▒▒▒░░░░ ░░░░░░░▒▒▒▒░░░ ░ ░░░░░░▒▒░▒▒░░░   ░░░░▒░░▒▒▒▒▒▒▒░░░ ░░░░░░▒▒▒░░░ ░░░░░░░▒░▒▒▒░░░░   ░░░░▒▒▒▒▒▒▒░▒░░░░░ ░░░░▒░▒░░░ ░ ░░░░░▒░▒▒▒▒▒░░░░░   ░░░░░░▒▒▒░░░░░░░░░░░ ░░░░▒▒▒░░░░░ ░░░░▒░▒░▒▒░░▒▒░░░░   ░░░░░░░░░░░░░░░░▒░░░░░░░░░░▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▒░▒░▒░░░░░   ░░░ ░ ░ ░░░░░░▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒░░░ ░ ░░░░░▒▒▒▒▒▒░░░░░░░░░░   ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░   ░░░▒▒▓▓▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒░░░░░░ ░   ░ ░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░▒▒▒▒▒░░░░░░   ░ ░░░░░▒▒▒░░░░░ ░░░░░▒▒░░░░░░░   ░░░░░ ░░░░░░░░░ ░   ░░ ░   press ⏎ to skip ░ ░  [?7h[?25l[?7l ████████████████████████ ✧   ████████████████████████ ✦   ✧ ████ ████   ✦ ████ ████ ✧   · ████ ████ ✧   · · ████ ████ ·   · ▒▒▒▒ ████ ✧   · · ████ ·   ████   ░░▒▒▒▒▒▒░░░░ ░░░▒▒▒▒▓▒▒▒▒░▒░░░░▒▒▒▒▒▒▓▓▓▒▒▒▒░░░░░░▒▒▒▓▓▓▒▒▒▒░░ ░ ░░▒▒▒▒▒▒░░   ░░▒▒▒▒▒▒░░░ ░░▒▒▒▓▓▓▒▒▒▒▒▒▒▒▒▒▒▓▒▒▒▒▒░░░ ░░░░░▒▓▓▓▓▓▓▒▒░░░ ░░▒░▒▒▒░░░   ░░░▒▒▒▒▒▒▒░░ ░░▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░▒▓▓▓▓▓▓▓▒▒░░ ░░░░░░░░░ ░   ░░░▒▒▒▒▒▒░░░ ░░▒▒▓▓███▓▓▓▒▒▒▒▒▒▒▒▒░░ ░▒▒▓▓▓▓█▓▓▒▒░░░ ░░░░░░░░   ░░░░▒▒▒▒▒░░░ ░░▒▓███████▓▓▒▒▒▒▒▒▒░░░░ ░░▒▒▒▓▓████▓▒▒░░░ ░░░░░ ░░░ ░░   ░░░░░░▒░░░░░░ ░▒▓▓███████▓▓▒▒▒▒▒▒▒▒░░░░▒▒▓▓█████▓▒▒░░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░ ░░▒▓███████▓▓▒▒▒▒▒▒▒▒▒▒▒▒▓▓█████▓▓▒▒░░ ░░░░░░░░░░   ░░░░░░░░░ ░ ░░▒▒▓▓▓███▓▓▓▒▒▒▒▒▒▒▒▒▒▒▓▓█████▓▒▒▒░ ░░░░░░░░░░   ░ ░░░░░░ ░ ░░ ░░░▒▒▒▒▓▓▓▒▓▒▒▒▒▒▒▒▒▒▓▓▓▓▓██▓▓▒▒░░ ░░░░░░░░░░░░░   ░ ░ ░░░ ░░ ░░░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒░░ ░░░░░░░▒░░░░░   ░░ ░░░░░░░▒░░░░ ░░░░░░░░░▒▒▒▒▓▓▓▒▒░░ ░░░░░░░░▒░░░░   ░░░░░░░░░░▒▒▒░░░░ ░░░░░░░▒▒▒▒▒▒░░ ░░░░░░░░▒▒░▒░░░   ░░░░░░░▒░▒▒▒▒▒░░░ ░░░░░░▒▒▒░░░ ░░░░░░░░░▒▒▒░░░░░   ░░░░░░▒▒▒▒▒▒▒░▒░░░░ ░░░░░░░░░░░░ ░░░░░░░░░░▒▒░░░░░░   ░░░░▒▒▒▒▒▒░░░░░░░ ░░░▒▒░░░░░░ ░░░░░▒░▒▒░░▒▒░░░░░░   ░░░░░░░░░░░░░░░░░░░░ ░ ░░░░░▒▒▒▒▒░░░ ░░░░░▒▒▒▒░▒░▒░░░░░░░   ░░░░░░░ ░░░░░░░░░░▒░░░░░░░▒▒▒▒▒▒▒░░ ░ ░░░▒▒▒▒▒░░░░▒░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░░▒▒▒▒▒▒▒▒░░░░░░░   ░░░▒▒▒▓▓▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒░░░░░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒░▒░░▒░░░░░░░░▒▒▒▒▒░▒░░░░ ░   ░ ░░▒▒▒▒▒▒▒▒░░░░ ░ ░░░░░▒▒▒░▒░░░░░   ░ ░░░░░░░░░░░ ░░░░░░░░░░░   ░░░░░   press ⏎ to skip ░ ░  [?7h[?25l[?7l ████████████████████████ ✧   ████████████████████████ ✦   ✦ ████ ████ ✧   · ████ ████ ✧   · · ████ ████ ·   ✧ ✦ ▒▒▒▒ ████ ✧   · ▒▒▒▒ ████ ✧   · · ████ ·   ████   ░░▒▒▒▒▒▒░░░ ░░░▒▒▓▓▓▒▒▒▒░░░░░░░▒▒▒▒▒▓▓▓▒▒▒▒░░░░░░▒▒▒▓▓▓▒▒▒▒░░░ ░░▒▒▒▒▒▒░░ ░   ░░▒▒▒▒▒▒░░░ ░░▒▒▓▓▓▒▒▒▒░▒░░░░▒▒▒▓▒▒▒▒▒▒░░░░░░░░▒▒▒▓▓▓▓▒▒▒░░ ░░░░▒▒▒▒░░   ░░░▒▒▒▒▒▒▒░ ░░▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░▒▒▓▓▓▓▓▓▒▒░░░ ░░░░░░░░░░░   ░░░▒▒▒▒▒▒░░░ ░▒▒▒▓▓▓█▓▓▓▒▒▒▒▒▒▒▒▒▒▒░ ░ ░░▒▒▓▓▓█▓▓▓▒▒░░ ░░░░░░   ░ ░░░░▒▒▒▒▒░░░ ░▒▒▓██████▓▓▓▒▒▒▒▒▒▒░░░░ ░▒▒▓▓████▓▓▒▒░░ ░░░░░ ░░ ░░   ░░░░░░░▒░░░░░░ ░░▒▓▓██████▓▓▒▒▒▒▒▒▒▒░░░░░▒▒▓▓▓████▓▒▒▒░░ ░ ░░░░░░░ ░░░░░   ░░░░░░░░░░░░░ ░▒▒▓███████▓▓▒▒▒▒▒▒▒▒░▒▒▒▓▓▓████▓▓▓▒▒░ ░░░░░░░░░░   ░░░░░░░░░░ ░░▒▒▓▓████▓▓▓▒▒▒▒▒▒▒▒▒▒▒▓▓██████▓▒▒░░ ░░░░░░░░   ░░░░░░░░░ ░░░▒▒▒▓▓▓▓▒▓▒▒▒▒▒▒▒▒▒▓▓▓▓▓██▓▓▒▒░░ ░░░░░░░░░░░░   ░ ░ ░░ ░░ ░░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒░░ ░░░░░░▒░░░░░   ░ ░ ░ ░░░░░░░░░░ ░░░░░▒░░░░░▒▒▒▓▓▓▓▓▒▒▒░ ░░░░░░░░▒░░░░   ░░░░░░░░░░░▒░░░░ ░░░░░░░▒▒▒▒▒▒▒░░ ░░░░░░░▒▒░▒░░░   ░░░░░░░░▒░▒▒▒▒▒░░░ ░░░░░░▒▒▒▒░░░ ░░░░░░░░░▒▒▒▒░░░░   ░░░░░░▒░▒▒▒▒▒░░░░░ ░░░░░░░░░░░░ ░ ░░░░░░░▒▒░▒░░░░   ░░░░▒▒▒▒▒▒░░░░░░░ ░░░░░░░░░░░ ░░░░░░░░░▒▒░░░░░░   ░░░░░░░░░░░░░░░░░░░░ ░░░▒▒▒▒▒░░░ ░░░▒░▒▒░▒░▒░░▒░░░░   ░░░░░░░ ░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒░░ ░░░▒▒▒▒▒░░░░▒░░░░░░   ░ ░░░░▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒░░░ ░ ░░░░▒▒▒▒▒▒▒░░░░░░░░░   ░░░▒▒▒▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒░▒▒▒▒▒▒░░░░░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒░▒░▒▒░░░░░░░░░▒▒▒▒▒▒░░░░ ░░   ░░░▒▒▒▒▒▒▒▒░░░░░░░ ░░░░░▒▒▒░▒░░░░░   ░░░░░▒░░░░░ ░░░░░░░░░░░   ░░░░░   press ⏎ to skip ░░ ░  [?7h[?25l[?7l ████████████████████████ ✧   ████████████████████████ ✦   · ████ ████ ✧   · · ████ ████ ·   ✧ ✦ ▒▒▒▒ ████ ✧   · ▒▒▒▒ ████ ✧   · · ████ ·   ░░░▒▒▒▒▒▒░░░ ░░▒▒▒▒▓▓▓▒▒▒░░░░░░░░▒▒▒▒▒▓▓▓▒▒▒▒░░░░░░▒▒▒▓▓▓▓▒▒▒░░░ ░░░▒▒▒▒▒▒░░░   ░░░▒▒▒▒▒▒░░░ ░░▒▒▒▓▓▒▒▒▒░░░░░░░▒▒▒▓▒▒▒▒▒▒░░░░░░░░░▒▒▓▓▓▓▓▒▒░░ ░░░░▒▒▒▒░░   ░░░▒▒▒▒▒░░░ ░▒▒▒▒▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▓▓▓▓▓▒▒▒░░░ ░░░░░░░░░░░   ░░░▒▒▒▒▒▒░░░ ░▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░▒▒▓▓▓█▓▓▓▒▒░░ ░░░░░░   ░ ░░░░▒▒▒▒▒░░░ ░▒▒▓█████▓▓▓▒▒▒▒▒▒▒▒░░░░ ░░▒▒▓▓▓██▓▓▒▒░░ ░░░░ ░░ ░░   ░░░░░░░▒░░░░░░ ░▒▒▓██████▓▓▓▒▒▒▒▒▒▒▒░░░░░░▒▒▓▓████▓▓▓▒▒░░ ░░░░ ░░░░░   ░░░░░░░░░░░░░ ░▒▓▓██████▓▓▓▒▒▒▒▒▒▒▒░░▒▒▒▒▓█████▓▓▒▒░░ ░░░░░░░░░░   ░░░░░░░░░░ ░░▒▓▓█████▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▓▓█████▓▒▒▒░ ░░░░░░░░   ░░░░░░░░░ ░░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▓▓▓▓███▓▓▒▒░░ ░░░░░░░░░░░░   ░░░░ ░ ░ ░░░ ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓█▓▓▒▒▒░ ░░░░░░░░░░░░   ░ ░ ░ ░░░░░░░░░ ░░░░░▒▒░░░░▒▒▒▒▓▓▓▓▓▒▒▒░░ ░░░░░░░░▒░░░░   ░░░░░░░░░░░▒░░░░ ░░░░░░░░▒▒▒▒▒▒▒░░ ░░░░░░░▒▒░▒░░░   ░░░░░░░░▒░░▒░▒▒░░░ ░░░░░░▒▒▒▒▒░░ ░░░░░░░░▒▒▒▒░░░░   ░░░░░░▒░▒▒▒▒▒░░░░░ ░░░░░░░░░░░░░ ░ ░░░░░░░▒▒░░▒░░░░   ░░░░░░▒▒▒▒▒▒░░░░░░ ░░░░░░░░░░░ ░░░░░░░░░░▒░░░░░░   ░ ░░░░░░░░░░░░░░░░░░ ░░░░▒░░▒░░░ ░░░░░▒▒░▒░▒░░▒▒░░░░   ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒░░ ░░░░░▒▒▒░░░░▒░▒░░░░   ░░ ░░░░▒▒▒▒▒▒▒░▒▒░▒▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▒▒░▒░░░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒░▒▒▒▒▒▒▒░░░░░░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒░▒░▒▒▒░░░░░░░░░▒▒▒▒▒░▒░░░░░   ░░▒▒▒▒▒▒▒▒▒░░░░░░░ ░░░░░▒▒▒▒▒▒░░░░░░   ░░░░░▒░░░░░ ░░░░░░░░░░░░   ░░ ░ ░░░░░░  [?7h[?25l[?7l ████████████████████████ ✧   ████████████████████████ ✦   ✧ ████ ████   ✦ ████ ████ ✧   · ████ ████ ✧   · · ████ ████ ·   ✧ ✦ ▒▒▒▒ ████ ✧   ░░░▒▒░▒▒░░ ░▒▒▒▓▓▓▓▓▒▒▒░░░░░░░░░▒▒▒▓▓▓▓▒▒▒▒░░░░░░░▒▒▒▓▒▓▒▒▒▒░░ ░░░▒▒▒▒▒▒░░   ░░░░▒▒▒▒▒░░ ░ ░░▒▒▒▓▓▒▒▒░░░░░░░░▒▒▓▓▒▒▓▒▒▒░░░░ ░░░░▒▒▒▓▓▓▓▒▒░░░ ░ ░░▒▒▒▒░░   ░░░▒▒▒▒▒░░░ ░░▒▒▒▒▓▓▒▒▒▒░░░▒▒▒▒▒▒▒▒▒▒░░ ░░▒▒▓▓▓▓▓▓▒▒░░░ ░░░░░░░░░   ░░░▒▒▒▒▒▒░░░ ░▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░▒▒▓▓█▓▓▓▓▒▒░ ░ ░░░░░   ░ ░░░░▒▒▒▒▒░░░ ░░▒▒▓███▓█▓▓▒▒▒▒▒▒▒▒▒░░░░ ░░░▒▓▓▓███▓▒▒▒░ ░░ ░░ ░░   ░░░░░░░▒░░░░░░ ░▒▒▓██████▓▓▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▓▓████▓▓▒▒░░ ░░ ░ ░░░░   ░░░░░░░░░░░░░ ░▒▓███████▓▓▓▒▒▒▒▒▒▒▒░░░▒▒▒▓▓████▓▓▒▒░░ ░░ ░░░░░░   ░░░░░░░░░░ ░▒▒▓▓█████▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓█████▓▓▒▒░░ ░░░░░░░   ░░░░░░░░░ ░░░▒▒▓▓▓█▓▓▓▓▒▒▒▒▒▒▒▒▒▒▓▓▓▓███▓▓▒▒▒░ ░░░░░░░░░░░░░   ░░░░░ ░ ░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓██▓▓▒▒▒░ ░ ░░░░░░░░░ ░   ░ ░ ░ ░░░░░░ ░ ░░░░░▒░▒▒░░░░▒▒▒▒▓▓▓▓▓▓▒▒░░ ░ ░░░░░▒░░░░░   ░░ ░░░░░░░░░░░░░ ░ ░░░░░░░░▒▒▒▒▒▒▒▒░░ ░░░░░░░▒░▒░░░   ░░░░░░░░░░░▒░▒░░░ ░░░░░░▒▒▒▒▒▒░░ ░░░░░░░░░▒▒░▒░░░   ░░░░░░░░▒▒▒▒▒░░░░ ░░░░░░░░░░░░░ ░░░░░░░░▒░░▒░░░░░   ░░░░░░░▒▒▒▒▒░░░░░░ ░░░░░░░░░░░░ ░░░░░░░░░░▒░░░░░░░   ░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░ ░░░░▒▒░░░▒░░▒▒░░░░   ░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░▒▒▒▒▒░░ ░░░▒░▒░░░░▒░▒░░░░░   ░ ░░░░ ░░░░░░▒▒░░░░░░░▒▒▒▒▒▒▒░░░ ░░░░░▒▒▒▒▒▒▒░░░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒░░░ ░░░░░▒▒▒▒▒▒▒▒░░░░░░░░   ░ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒░░░ ░░░░░▒▒▒▒▒▒▒░░░░░░   ░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░▒▒▒▒▒▒▒░░░░░   ░░░░▒▒▒░░░░ ░ ░ ░░░░░░░▒▒░░░░   ░░░░░░░░ ░░░░░░░   press ⏎ to skip ░░░  [?7h[?25l[?7l   ·   ✦ ✧ ✧· ✧   · ✦ ✧ ✦   ✧   ✦ · ✧   ✧ ✦ ✧ ████████████████████████   ████████████████████████ ✧ ✦   ✦ ████ ████ ·   ████ ████ · ·   ████ ████   ✦ ████ ████   ▒▒▒▒ ████ · ·   ▒▒▒▒ ████ ✦   ✦ ████ ·   ░░░▒▒░░░░ ░░▒▒▒▓▓▓▓▓▒▒▒░░░ ░ ░░░▒▒▒▓▓▓▓▒▒▒▒░▒░░░░░▒▒▒▓▒▓▒▓▒▒▒░ ░░▒▒▒▒▒▒░░   ░░░░▒▒▒░░░ ░░░▒▒▒▓▓▓▒▒▒░░░░░░░░▒▒▓▓▒▒▓▒▒▒▒░░░ ░░░▒▒▒▓▓▓▓▓▒▒░░ ░░░░▒▒░░░░   ░░░▒▒▒▒▒░░░ ░░▒▒▒▒▒▓▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒░░ ░▒▒▓▓▓▓▓▓▓▒░░░ ░░░░░░░░   ░░░▒▒▒▒▒░░░ ░▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░▒▒▒▓▓▓▓▓▓▒▒░░░ ░░░░░ ░   ░░░░▒▒▒▒▒░░ ░░▒▒▓▓█▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░ ░░▒▒▓▓███▓▓▒▒░░░ ░ ░ ░░   ░░░░░░░▒░░░░░░ ░▒▓▓██████▓▓▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▓▓████▓▓▒▒▒░░ ░░ ░ ░░░░   ░░░░░░░░░░░░░ ░▒▒▓███████▓▓▒▒▒▒▒▒▒▒▒░░░▒▒▒▒▓████▓▓▓▒▒░░ ░ ░░░░░░ ░   ░░░░░░░░░░ ░░▒▓▓██████▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓█████▓▓▓▒░░ ░░░░░░░░   ░░░░░░░░░ ░░▒▒▓▓▓▓█▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▓▓▓███▓▓▓▒▒░░ ░░░░░░░░░░░░   ░░░░░ ░ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓███▓▓▒▒░░ ░ ░░░░░░░░░░   ░ ░ ░░░░░ ░░░░▒▒▒▒▒▒▒░░░░▒▒▒▒▓▓▓▓▓▓▒▒▒░ ░ ░░░░░▒░░▒░░░   ░░ ░ ░░░░░░░░░░░ ░░░░░░░░░░░▒▒▒▒▒▒▒▒▒░░ ░░░░░░▒░▒░░░░░   ░░░░░░░░░░░░░░░░░ ░░░░░░▒▒▒▒▒▒░░ ░░░░░░░░░▒░░▒░░░   ░░░░░░░░▒▒▒▒▒░░░░ ░░░░░ ░░░▒░░░░ ░░░░░░░░░░▒░░░░░   ░░░░░░░░▒▒▒▒░░░░░░ ░░░░░░░░░░░ ░░░░░░░░░▒░░░▒░░░   ░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░ ░░░░░▒░░░▒░░▒▒░░░░   ░░░░░░░░░░░░░░░░░░░░ ░░░░░▒▒░▒▒░░ ░░░▒░░░░░░▒░▒░░░░░   ░░░░░░ ░ ░░░░░░░░░░░░░░░▒▒▒▒▒▒░░░░ ░░░▒▒▒▒▒▒▒░░░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒░░░ ░░░▒▒▒▒▒▒▒░▒░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒░░░ ░░░░░▒▒▒▒▒▒▒▒░░░░░   ░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒░░░░   ░░░▒░▒▒▒▒░░░░ ░░ ░░ ░░░░░░░▒▒░░░░░   ░░░░░░░░░ ░░░░░░░░░   press ⏎ to skip ░░░  [?7h[?25l[?7l ✧ ✦ ✧ ████████████████████████   ████████████████████████ ✧ ✦   ✦ ████ ████ ·   ████ ████ · ·   ████ ████   ✦ ████ ████   ▒▒▒▒ ████ · ·   ▒▒▒▒ ████ ✦   ░░░░▒░░░░░ ░░░▒▓▓▓▓▓▓▓▒▒▒░ ░ ░ ░░░▒▒▒▓▓▓▓▒▒▒▒░▒░░░░░░▒▒▒▒▒▒▓▒▒▒░░ ░░░▒▒▒▒▒░░   ░░░░▒░░░░░ ░░░▒▒▒▓▓▓▒▒▒░░ ░░░░░▒▒▓▓▓▒▓▒▒▒▒░░░ ░▒▒▒▓▓▓▓▓▒▒▒░ ░░░░▒▒░░░░   ░░░▒▒░▒▒░░░ ░░░▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒░░ ░▒▒▓▓▓▓▓▓▒▒░░ ░░░░░░░   ░░░▒▒▒▒░░░░ ░░▒▒▓▓▓▒▒▒▒▒▒░░▒▒▒▒▒▒▒▒▒░░░ ░░▒▒▓▓▓▓▓▓▓▒▒░░ ░░░░ ░   ░░░░▒▒▒▒▒░░ ░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░ ░░░▒▓▓███▓▓▒▒▒░░ ░ ░ ░░   ░░░░░░░▒░░░░░ ░░▒▓▓█████▓▓▒▒▒▒▒▒▒▒▒▒░░ ░░▒▒▒▓▓███▓▓▓▒▒░░ ░░ ░ ░░░░   ░░░░░░░░░░░░░ ░▒▒▓███████▓▓▒▒▒▒▒▒▒▒▒░░░░▒▒▒▓▓█████▓▓▒░░░ ░ ░░░░░░ ░   ░░░░░░░░░░ ░░▒▓▓██████▓▓▓▒▒▒▒▒▒▒▒░▒▒▒▒▓▓████▓▓▓▒▒░ ░░░░░░░░░   ░░░░░░░░░ ░░░▒▒▓▓▓▓█▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▓▓▓████▓▓▒▒░░░ ░ ░░░░░░░░░   ░ ░░░░░ ░ ░░▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓███▓▓▒▒░░░ ░░░░░░░░░░   ░ ░ ░░ ░░░░▒▒▒▒▒▒▒░░░▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒░ ░░░░░░░▒░░░   ░ ░ ░░░░░░░ ░ ░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░ ░░░░░░▒░▒░░░░░   ░░ ░░░░░░░░░░░░░░░ ░░░░░░▒▒▒▒▒▒▒░░ ░░░░░░░▒░░▒░░░░   ░░░░░░░░▒░▒▒░░░░░ ░░░░░ ░░░▒░▒░░ ░░░░░░░░░░░░░░░░   ░░░░░░░░▒▒▒▒░░░░░ ░░░░░░░░░░░ ░░░░░░░░▒░░░▒░░░   ░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░ ░░░░░░░▒░░▒▒░░░░   ░░░░░░░░░░░░░░░░░░ ░ ░░▒▒░░░░░ ░░░░░░░░░▒░▒░░░░░ ░   ░░ ░░░░░░ ░ ░░░░░░░░░░░░░░░░░▒▒▒▒▒░░░░ ░░░░▒▒▒▒▒▒░░░░░░░░   ░░░░░░▒▒▒▒▒▒░▒▒▒░▒▒▒▒▒░▒░░░ ░░░▒▒▒▒▒▒▒▒▒░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░▒▒▒▒▒▒▒▒░░░░░   ░░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░ ░░░▒▒▒▒▒▒▒▒░░░░   ░░░▒▒▒▒▒▒░░░░░░░░░░ ░░░░░░░▒▒░▒░░░   ░░░░░░░░░ ░░░░░░░░░   press ⏎ to skip ░░░ ░  [?7h[?25l[?7l ✧ ✦ ✧ ████████████████████████   ████████████████████████ ✧ ✦   ✦ ████ ████ ·   ✦ ████ ████   ▒▒▒▒ ████ · ·   ▒▒▒▒ ████ ✦   ✦ ████ ·   ████   ░░░░░░░░░ ░░▒▒▓▓█▓▓▓▒▒▒░░ ░░░▒▒▒▓▓▓▓▒▒▒▒░▒░░░░░░░▒▒▒▒▒▓▒▒▒░░░ ░░░▒░▒▒▒░░░   ░░░░░░░░░░ ░░░▒▒▓▓▓▓▒▒▒░░ ░░░░▒▒▓▓▓▒▓▓▓▒▒░░░░ ░░░▒▓▓▓▓▓▒▒▒░░ ░░░░▒▒░░░░░   ░░░▒░░▒░░░░ ░░░▒▒▓▓▒▒▒▒▒░░░ ░░░░▒▒▒▓▒▒▒▒▒▒░░ ░░▒▒▓▓▓▓▓▒▒▒░ ░░░░░░░ ░   ░░░▒▒▒▒░░░ ░░▒▒▓▓▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒▒░░░ ░░▒▒▒▓▓▓▓▓▓▒▒░░ ░░░░ ░   ░░░░▒▒▒▒▒░░ ░░▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░▒▒▓▓██▓▓▓▒▒░░ ░ ░   ░░░░░░░▒░░░░░ ░▒▒▓▓████▓▓▒▒▒▒▒▒▒▒▒▒░░░ ░░▒▓▓███▓█▓▒▒░░ ░ ░ ░░░░   ░░░░░░░░░░░░ ░▒▒▓██████▓▓▓▒▒▒▒▒▒▒▒░░░░░░░▒▓▓▓████▓▓▒▒░░ ░ ░░░░░░ ░   ░░░░░░░░░░ ░▒▒▓▓██████▓▓▒▒▒▒▒▒▒▒▒░░░▒▒▒▓█████▓▓▓▒░░ ░░░░░░░░░   ░ ░░░░░░░░░ ░░▒▒▓▓████▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▓▓█████▓▓▒▒░░ ░ ░░░░░░░░   ░░░░░░░ ░ ░░▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓███▓▓▓▒▒░░ ░░░░░░░░░░   ░ ░░ ░ ░ ░░░░▒▒▒▒▒▒▒▒▒▒░░▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒░░ ░░░░░░░▒░░░░   ░ ░░░░░░░ ░░░░░░░░░░░░░▒▒▒▒▒▒▒▓▒▒▒░ ░░░░░░░▒░░░░░░   ░░ ░░░░░░░░░░░░ ░ ░ ░░░░░░░▒▒▒▒▒▒▒░░ ░ ░░░░▒░░▒░░░░   ░░░░░░░░▒░░▒░░░░░ ░░░░░ ░░▒▒░▒░░ ░ ░░░░░░░░░░░░░   ░░░░░░░░▒▒▒▒░░░░░ ░░░░░░░░░░░░ ░░░░░░░░░░░▒░░░░   ░░░░░░░░░░░░░░░░ ░░░░░░░░░░░ ░░░░░░░░░░░▒░░░░░   ░░░░░░░░░░░░░░░░░ ░ ░░░▒░░░░░ ░░░░░░░░▒░▒░░░░░░░   ░░░░░░░░░ ░ ░░░░░░░░░░ ░ ░░░░▒▒▒▒▒░░░░ ░░░▒▒▒▒▒▒░░░░░░░░░░   ░ ░░░░░░░▒▒▒▒░░▒▒▒░▒▒▒▒▒░▒░░░ ░░░░▒▒▒▒▒▒▒▒░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░▒░▒▒▒▒▒▒░░░░░░ ░   ░░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░ ░░░▒▒░▒▒▒▒▒▒░░░░   ░░▒▒▒▒▒▒▒░░░░░░░░░░ ░░░░░░░▒▒▒▒░░░   ░░░░░░░░░░░ ░░░░░░░░░░   press ⏎ to skip ░░░░░  [?7h[?25l[?7l ✧ ✦ ✧ ████████████████████████   ████████████████████████ ✧ ✦   ✦ ████ ████ ·   ████ ████ · ·   ████ ████   ✦ ████ ████   ▒▒▒▒ ████ · ·   ✦ ████ ·   ████   ░░░░░░░░░ ░░▒▓▓▓█▓▓▓▒▒▒░ ░░░▒▒▒▓▓▓▓▒▒▒▒▒▒░░░░░░░▒▒▒▒▒▓▓▒▒▒░░░ ░░░░▒▒▒░░░   ░░░░░░░░░░ ░░▒▒▒▓▓▓▓▓▒▒░░ ░░░▒▒▓▓▓▓▓▓▓▒▒▒░░░ ░░▒▒▓▓▓▓▓▒▒░░░ ░░░▒░░░░░   ░░░░░░░░ ░░░▒▒▒▓▓▒▒▒▒░░░ ░░░▒▒▒▓▒▒▒▒▒▒░░ ░░▒▒▓▓▓▓▓▓▒▒░░ ░░░░░░░ ░   ░░░▒░░░░░ ░░░▒▒▓▓▒▒▒▒▒░░░░░▒▒▒▒▒▒▒▒▒░░░░ ░░▒▒▓▓▓▓▓▓▒▒░░ ░░░░ ░   ░░░░▒▒▒▒░░░ ░░▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░▒▒▓▓█▓▓▓▓▒░░ ░ ░   ░░░░░░░▒░░░░░ ░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░ ░░▒▒▓▓██▓█▓▓▒▒░░ ░ ░░░░   ░░░░░░░░░░░░ ░░▒▓▓█████▓▓▓▒▒▒▒▒▒▒▒▒░░░░░░░▒▒▓▓████▓▓▒▒░░ ░░░░░░░░   ░░░░░░░░░░ ░▒▒▓███████▓▓▒▒▒▒▒▒▒▒▒░░░░▒▒▓▓█████▓▓▒▒░░ ░░░░░░░░   ░░░░░░░░░░░ ░░▒▒▓▓████▓▓▒▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓████▓▓▓▒░░ ░░░░░░░░   ░░░░░░░ ░ ░░▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓██▓▓▓▓▒░░ ░░░░░░░░░   ░░░░ ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒░░▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒░░░ ░░░░░░░▒░░░░   ░ ░ ░░░░░ ░░░░░░░░░░░▒░▒▒▒▒▒▓▓▓▒▒▒▒░ ░░░░░░░░░░░░░   ░░ ░░░ ░░░░░░░░ ░░░░░░░░░░░▒▒▒▒▒▒▒▒░░ ░ ░░░░░░░▒░░░░   ░░░░░░░░░░░░▒░░░░ ░░░ ░ ░░▒▒▒▒▒░░ ░ ░░░░░░░░░░░░░   ░░░░░░░░▒▒▒▒░░░░░ ░░░░░░░░░░░░ ░ ░░░░░░░░░▒░░░░   ░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░ ░░░░░░░░░░░▒░░░░░░   ░░░░░░░░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░▒░▒░░░░░░░   ░░░░░░░░░ ░░░░░░░░░░░ ░░░░▒▒▒▒░░░░░ ░░░▒▒▒▒▒▒░░░░░░░░░░   ░░ ░░░░░░░░▒▒░░░░▒▒░▒▒▒▒▒░▒░░░ ░ ░░▒░▒▒▒▒▒▒░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░ ░░░░▒▒▒▒▒▒▒░░░░░ ░   ░░░▒▒▒▒▒▒▒▒▒▒▒░░░░▒░░░░ ░░░░░▒▒▒▒▒▒░░░░░░   ░░▒▒▒▒▒▒▒░░░░░░░░░░ ░░░░░░░▒▒▒▒░░░   ░░░░▒▒░░░░░ ░░░░▒░░░░░░   ░░ press ⏎ to skip ░░░░░  [?7h[?25l[?7l ✧ ✦ ✧ ████████████████████████   ████████████████████████ ✧ ✦   ████ ████   ✦ ████ ████   ▒▒▒▒ ████ · ·   ▒▒▒▒ ████ ✦   ░░░░░░░ ░░▒▒▓▓▓█▓▓▓▒▒░░ ░░▒▒▒▓█▓▓▓▒▒▒▒▒▒░░░░░░▒▒▒▒▒▓▓▒▒▒░░░ ░░░░▒▒▒░░░░   ░░░░░░░░ ░░▒▒▓▓▓▓▓▓▒▒░░ ░░░▒▒▓▓▓▓▓▓▓▒▒▒░░░ ░░░▒▒▓▓▓▓▒▒▒░░ ░░░░░░░░░   ░░░░░░░░ ░░▒▒▒▓▓▓▒▒▒▒░░░ ░░░▒▒▒▓▒▒▒▒▒▒░░░ ░░▒▒▒▓▓▓▓▓▒▒▒░░ ░░░░░░░ ░   ░░░▒░░░░░ ░░░▒▒▒▓▓▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒░░░░ ░░▒▓▓▓▓▓▓▓▒▒░░ ░░░ ░   ░░░░▒░░▒░░░ ░░▒▒▒▒▒▒▓▓▒▒▒▒░▒▒▒▒▒▒▒▒▒░░░░ ░▒▒▓▓█▓▓▓▓▒▒░░ ░ ░   ░░░░░░▒░░░░░ ░░▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░▒▓▓████▓▓▒▒░░ ░ ░░░░   ░░░░░░░░░░░░ ░░▒▓▓████▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒▓▓▓▓██▓▓▓▒▒░░ ░░ ░░░░░░   ░░░░░░░░░░ ░▒▒▓██████▓▓▓▒▒▒▒▒▒▒▒▒░░░░░▒▒▓▓████▓▓▒▒░░░ ░░░░░░░   ░░░░░░░░░░░ ░░▒▒▓█████▓▓▒▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓████▓▓▓▒▒░░ ░░░░░░░░   ░░░░░░░ ░ ░░▒▒▓▓█▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓████▓▓▒▒░ ░░░░░░░░   ░░░░░ ░ ░░░▒▒▒▒▓▒▒▒▒▒▒▒░░▒▒▒▒▒▒▓▓▓▓▓▓▓▓▒▒░░ ░░ ░░░░▒░░░░   ░ ░ ░ ░ ░░░ ░░░░▒▒░░░░░░▒░▒▒▒▒▒▓▓▓▓▓▒▒░ ░░░░░░░░░░░░░   ░ ░ ░ ░░░░░░░░ ░░░░░░░░░░░▒▒▒▒▒▒▒▒░░ ░ ░░░░░▒░░░░   ░░ ░░░░░░░░░░░░░░ ░░░ ░░░░▒▒▒▒▒░░ ░░░░░░░░░░░░░░░   ░░░░░░░░▒▒▒▒░░░░ ░░░░░░░░░░░░░ ░ ░░░░░░░░░▒░░░░   ░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░ ░░░░░░░░░▒░░░░░░   ░ ░░░░░░░░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░▒░▒░░░░░░░   ░░░░░░░░░░░░░░░░░░░ ░░░░▒░▒░░░░░ ░░░▒▒░▒░░░░░░░░░░   ░░░░ ░ ░░░░░░░░░░░░░░░░▒░▒▒▒▒▒░░░░░ ░░░░░▒▒▒▒▒░░░░░░   ░░░░▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒░░░ ░ ░░░▒▒▒▒▒▒▒░░░░░ ░   ░░░░▒▒▒▒▒▒▒▒▒▒▒░▒▒░▒░░░░ ░░░░░▒▒▒▒▒▒░░░░░░   ░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░▒▒▒▒▒░░░░   ░░▒▒▒▒░░░░░ ░ ░ ░ ░░░░▒░░░░░░░   ░░░░ press ⏎ to skip ░░░░░░░  [?7h[?25l[?7l ✧ ✦ ✧ ████████████████████████   ████████████████████████ ✧ ✦   ░░░░░░░ ░░▒▒▓█▓█▓▓▓▒▒░░ ░░▒▒▒▓█▓▓▓▒▒▒▒▒▒░░░░░░░▒▒▒▒▓▓▒▒▒░░░ ░░░░▒▒░░░░   ░░░░░░░ ░░▒▒▒▓▓▓▓▓▓▒▒░░ ░░░▒▒▓▓▓▓▓▓▓▓▒▒░░░░ ░░░▒▒▓▓▓▓▒▒▒░░░ ░░░░░░░░░   ░░░░░░░░ ░▒▒▒▓▓▓▓▒▒▒░░ ░░░▒▒▒▓▒▒▒▒▒▒░░░ ░▒▒▒▓▓▓▓▒▒▒░░ ░░░░░ ░   ░░░░░░░░░ ░░░░▒▒▓▓▓▒▒▒░░░ ░ ░░▒▒▒▒▒▒▒▒▒░░░ ░░▒▒▓▓▓▓▓▓▓▒▒░ ░ ░   ░░░░▒░░░░░ ░░▒▒▒▒▒▒▒▒▒░▒░░░▒▒▒▒▒▒▒▒░░░░ ░▒▒▓▓▓▓▓▓▓▓▒▒░░ ░ ░   ░░░░░░▒░░░░░ ░▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░▒▒▓▓█▓█▓▓▓▒▒░ ░ ░ ░   ░░░░░░░░░░░ ░░▒▒▓▓████▓▓▓▒▒▒▒▒▒▒▒▒▒░░░ ░░░░▒▒▓▓▓███▓▓▒▒▒░ ░ ░░░░░░   ░░░░░░░░░░ ░░▒▓▓██████▓▓▓▒▒▒▒▒▒▒░▒░░░░░▒▒▓▓█████▓▓▒▒░░ ░░░░░░░ ░   ░░░░░░░░░░░ ░░▒▒▓▓█████▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓█████▓▓▒▒▒░ ░░░░░░░   ░░░░░░░ ░░▒▓▓▓█▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓████▓▓▒▒▒░░ ░░░░░░░░░░   ░░░░░ ░ ░░░▒▒▒▒▓▓▒▒▒▒▒▒░░▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▒▒░ ░ ░░░░░░░░░   ░░ ░ ░ ░░░░░▒▒▒▒▒░░▒░░░▒▒▒▒▒▒▒▓▓▓▓▓▒▒░░ ░░░░░░░░░░   ░ ░ ░░░░░░░ ░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒░░ ░ ░░░░░▒░░░░░   ░░ ░░░░░░░░░░░░░ ░░░ ░░░░▒▒▒▒▒▒░░ ░░░░░░░░░░░░░░░   ░░░░░░░░░░▒▒░░░░ ░░░░░░░░░▒░░░ ░░░░░░░░░▒░▒░░   ░░░░░░░░░░░░░░░░ ░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░▒░▒░░░░░░░░   ░░░░░░░░░░░░░░░░░░░ ░░░░░░▒░░░░░ ░░░▒▒░▒░░░░░░░░░░   ░░░░░ ░░ ░░░░░░░░░░░░░░░░░░░▒▒▒▒▒░░░░░ ░░░░░▒▒▒▒░░░░░░░░   ░ ░░░░▒▒▒▒▒░░░▒▒▒▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒░░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒░░░░ ░░░░░▒▒▒▒▒▒▒░░░░░   ░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░▒▒▒▒▒▒░░░ ░   ░░░▒▒▒▒░░░░░ ░░░░ ░░ ░░░░▒░░░░░░░   ░ ░░░░ press ⏎ to skip ░░░░░░░  [?7h[?25l[?7l ✧ ✦ ✧ ████████████████████████   ████████████████████████ ✧ ✦   ░ ░░░░░░ ░▒▒▓▓███▓▓▓▒░░░ ░░▒▒▒▓███▓▓▒▒▒▒▒░░░░░░░▒▒▒▒▓▓▒▒▒▒░░ ░░░░▒░▒░░   ░░░░░░ ░░▒▓▓▓▓▓▓▓▓▒░░ ░░░▒▒▓▓▓▓▓▓▓▓▒▒▒░░░ ░ ░▒▒▒▓▓▓▓▓▒░░░ ░░░░░░░░░░   ░░░░░ ░ ░▒▒▒▒▓▓▓▓▒▒▒░ ░░░▒▒▒▓▒▒▒▓▒▒▒░░ ░░▒▒▓▓▓▓▓▒▒░░ ░░░░░░░   ░░░░░░░░ ░░▒▒▒▒▓▓▓▒▒░░░ ░░▒▒▒▒▒▒▒▒▒░▒░░ ░░▒▒▓▓▓▓▓▓▒▒░░ ░ ░   ░░░░░░░░░ ░▒▒▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒░░░░ ░▒▒▓▓▓▓▓▓▓▒▒░░ ░   ░░░░░░░░░░░░ ░░▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░▒▓▓▓▓██▓▓▒▒░ ░ ░   ░░░░░░░░░░░ ░░▒▒▓▓███▓▓▓▒▒▒▒▒▒▒▒▒▒░░░ ░░▒▒▓▓▓███▓▒▒▒░ ░░░░░░   ░░░░░░░░░░░ ░▒▒▓▓█████▓▓▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▓▓████▓▓▒▒░░ ░░░░░░░ ░   ░░░░░░░░░░░ ░░▒▒▓▓█████▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓████▓▓▒▒░ ░░░░░░░   ░░░░░░░ ░░░▒▓▓▓▓██▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓███▓▓▓▒▒░░ ░░░░░░░░░░   ░░░░░░ ░ ░░░▒▒▒▓▓▓▓▓▓▒▒▒▒░▒▒▒▒▒▒▒▒▓▓▓▓▓█▓▓▒▒▒░ ░░░░░░░░   ░ ░░ ░ ░░░░░▒▒▒▒▒░░▒░░░▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒░░ ░░░░░░░░░░   ░ ░░░░░░░ ░░░░░░░░░░░░░░▒▒▒▒▒▓▓▒▒▒░░ ░░░░░░░░░░░   ░░ ░░░░░░░░░░░░░ ░░ ░░░ ░░░▒▒▒▒▒▒▒░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░▒▒░░░ ░ ░░░░░░░▒▒░░ ░░░░░░░░░░░▒░░░   ░░░░░░░░░░░░░░░░░ ░░░ ░░░░░░ ░░░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░░ ░░░░░░░░░░ ░░░░░▒░░░░░░░░░░   ░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░ ░░░░░▒░░░░░░░░░░░░   ░░░░░ ░░░░░░░░░░░░░░░░░ ░░░░▒▒▒▒░░░░░ ░░░░▒▒▒▒░▒░░░░░░   ░ ░ ░░░░▒░▒░░░░░▒▒░▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒░░░░░░░   ░░░░░▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒░░░░ ░░░░░▒▒▒▒▒▒▒▒░░░░ ░   ░░░░▒▒▒▒▒▒▒▒▒░░░░▒░░░░░░░░░░░▒▒▒▒▒▒▒░░ ░   ░░▒▒▒▒▒░░░░░░░░░░░░░ ░░░░▒░░░░░░░   ░░░░░░ press ⏎ to skip ░░░░░░░░  [?7h[?25l[?7l ░░░░░░░░░ ░▒▒▓████▓▓▒▒░░░ ░░▒▒▓▓███▓▓▒▒▒▒▒░░░░░░░░░▒▒▓▓▒▒▒▒░░ ░░░░▒░▒░░   ░░░░░ ░░▒▒▓▓▓▓▓▓▓▒▒░░ ░░░▒▒▓▓▓▓▓▓▓▓▓▒▒▒░░ ░▒▒▒▒▓▓▓▓▒▒░░ ░░░░░░░░   ░░░░░ ░▒▒▓▓▓▓▓▓▒▒░░ ░░░▒▒▒▓▒▓▓▓▒▒▒▒░░ ░░▒▒▓▓▓▓▓▓▒▒░ ░░░░░░   ░░░░░░░ ░▒▒▒▓▒▓▓▒▒░░░ ░░▒▒▒▒▒▒▒▒▒▒▒░░░ ░▒▒▓▓▓▓▓▓▒▒░░ ░ ░   ░░░░░░░░░ ░░▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒░░░░ ░░▒▓▓▓▓▓▓▓▒▒░░ ░   ░░░░░░░░░░░░ ░░░▒▒▒▓▓▓▒▒▒▒▒░░▒▒▒▒▒▒▒░░░░ ░▒▒▓▓▓██▓▓▓▒▒░ ░   ░░░░░░░░░░░ ░▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░▒▒▓▓▓███▓▓▒▒░░ ░░░ ░░   ░░░░░░░░░░ ░▒▒▓▓███▓█▓▓▒▒▒▒▒▒▒▒▒░░░░░ ░░▒▒▒▓█▓███▓▓▒▒░ ░░░░░░░ ░   ░░░░░░░░░░░ ░░▒▒▓▓█████▓▓▒▒▒▒▒▒▒▒▒▒░░░▒▒▒▓▓▓▓████▓▒▒░░ ░░░░░░   ░░░░░░ ░░░▒▓▓▓▓██▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓█▓███▓▒▒░░ ░░░░░░░░░░   ░░░░░░░ ░░▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓█▓▓▒▒▒░░ ░░░░░░░░░   ░░░░░ ░ ░░░░░▒▒▒▒▒▒▒░▒░░▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒░░░ ░░░░░░░░░░░   ░ ░░ ░░ ░░░░░░░░░░░░░░░▒▒▒▒▒▓▓▓▒▒▒░░░ ░░░░░░░░░░░░   ░░ ░░ ░░░░░░░░░ ░░ ░░░ ░░░▒▒▒▒▒▒▒▒░░░ ░░░░░░░░░░░░░   ░ ░░░░░░░░░▒░░░ ░ ░░░░░░░▒▒░░░ ░ ░░░░░░░▒░░░   ░░░░░░░░░░░░░░░░ ░░░ ░░░░░░ ░░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░ ░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░░░ ░░ ░░░░░▒▒░░░░░░ ░░░░░▒▒▒░▒░░░░░░   ░░ ░ ░░░░▒░▒░░░░░░░░▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒░▒░░░░░░   ░░░░░▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒░░░░ ░░░░░░░▒▒▒▒▒▒░░░░░░   ░░░░▒▒▒▒▒▒▒░▒░▒░░▒░░░░░░░░░░░▒▒▒▒▒▒▒░░░░   ░░▒▒▒▒▒░░░░░░░░░░░░░ ░░░░▒░▒░▒░░░░░   ░░░░░░░ press ⏎ to skip ░░░░░░░░░  [?7h[?25l[?7l ✧   ✦ ✦ ✧     · · · ·   ✦ ✧ · ✧   · ████████████████████████ ✧   ████████████████████████ ✧   ✦ ✧ ✦ ████ ████ ·   ████ ████   · ✧████ ████ · ✧ ✦   ✧ · ████ ████   ✧ ▒▒▒▒ ████   ▒▒▒▒ ████ ✧   · ████   ░░░░░░░ ░░▒▒▓████▓▓▒▒░░░ ░░▒▒▓▓███▓▓▒▓▒▒▒░░░░░░░░░▒▒▒▓▒▓▒▒▒░░ ░░▒░▒░░   ░░░░ ░░▒▒▓▓██▓▓▓▒▒░ ░░░▒▒▓▓▓▓█▓▓▓▓▒▒▒░░ ░▒▒▒▓▓▓▓▒▒░░ ░░░░░░░░░   ░░░░ ░░▒▒▓▓▓▓▓▓▒░░░ ░░░▒▒▒▓▓▓▓▓▓▒▒▒░░ ░░░▒▓▓▓▓▓▓▒▒░░░ ░░░░░░   ░░░░░ ░ ░░▒▒▒▓▓▓▓▒▒░░ ░░▒▒▒▒▒▒▒▒▒▒▒░░░ ░▒▒▓▓▓▓▓▓▒▒░░ ░ ░   ░░░░░░░░░ ░░░▒▒▒▒▒▒▒▒▒░░ ░░░░░░▒▒▒▒▒▒░▒░░░ ░░▒▒▓▓▓▓▓▓▒▒▒░ ░   ░░░░░░░░░░░ ░░▒▒▒▒▓▓▒▒▒▒░░░░░▒▒▒▒▒▒░░░░ ░░▒▓▓▓███▓▓▒▒░ ░   ░░░░░░░░░░░ ░░▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░▒▒▓▓███▓▓▓▒▒░ ░ ░░   ░░░░░░░░░░ ░░▒▒▓▓███▓▓▓▓▒▒▒▒▒▒▒▒▒░░░░ ░░▒▒▓▓▓███▓▓▒▒▒░ ░░░░░░░░░   ░░░░░░░░░░░ ░▒▒▓▓▓███▓█▓▓▒▒▒▒▒▒▒▒▒▒░░░░░▒▒▓▓▓████▓▓▒▒░░ ░░░░░░   ░░░░░░░ ░░▒▒▓▓▓███▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓███▓▓▓▒░░ ░░░░░░░   ░░░░░░░ ░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓█▓▓▓▒▒░░ ░░░░░░░░░░   ░░░░░ ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒░░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒░░ ░ ░░░░░░░░   ░ ░░░░▒▒░░░░░░░░▒▒▒▒▒▒▓▓▓▓▒▒▒░░ ░░░░░░░░░░░░   ░ ░ ░░░░░░░ ░░░░░░░░░░░▒▒▒▒▒▒▒▒░░░ ░░░░░░░░░░░░   ░ ░░░░░░░░░░░ ░ ░░░░░░░▒▒▒░░░ ░░░░░░░░░░░░   ░░░░░░░░░░░░░░ ░░░ ░░░░░░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░░ ░░░░ ░░░░░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░░░ ░░░░░▒░░░░░░░ ░░░▒▒▒░▒░░░░░░░░   ░░░ ░ ░ ░░░░░░░░░░░░░░░░░▒▒▒▒▒▒░░░ ░░▒░▒▒▒░▒░░░░░░   ░░░░░▒▒▒▒░░░▒░▒▒▒▒▒▒▒░░░░ ░░░░░░▒▒▒▒▒▒▒░░░░░   ░░░░▒▒▒▒▒▒▒░▒░▒░▒▒░░░░░░░░░░░░▒▒▒▒▒▒░░░░   ░░░▒▒▒▒▒░▒░░░░░░░░░░░ ░░░░▒░▒▒▒░░░░░  [?7h[?25l[?7l ████████████   ██ ██   ░░░░░░░ ░░▒▓▓████▓▓▒▒░░░ ░░▒▒▓▓███▓▓▓▓▒▒▒░░░░░░░░░▒▒▒▒▒▓▒▒▒░░░ ░░░░▒░░░   ░░░ ░░▒▓▓▓██▓▓▓▒░░ ░░░▒▒▓▓▓▓██▓▓▓▒▒▒░░░ ░░▒▒▓▓▓▓▒▒▒░░ ░░░░░░░░░   ░░ ░░▒▓▓▓▓▓▓▒▒░░ ░░░▒▒▒▓▓▓▓▓▓▓▒▒▒░ ░░▒▒▓▓▓▓▓▓▒▒░░ ░░░ ░   ░░░░ ░ ░▒▒▒▒▓▓▓▓▒▒░ ░░▒▒▒▒▒▒▒▒▒▒▒▒░░ ░▒▒▓▓▓▓▓▓▒▒░░ ░ ░   ░░░░░░░░░ ░░░░▒▒▒▒▒▒▒░░░ ░░░░░▒▒▒▒▒▒░▒░░░ ░▒▒▓▓▓▓▓▓▒▒░░░   ░░░░░░░░░░░ ░░░▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▒▒░░░░░ ░▒▒▓▓███▓▓▒▒░░ ░   ░░░░░░░░░░░ ░░▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░▒▒▓█▓█▓▓▓▒▒░ ░ ░░   ░░░░░░░░░░ ░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░ ░░▒▒▒▓▓███▓▓▒▒▒░ ░░ ░░░░░░   ░░░░░░░░░░░ ░░▒▒▓▓▓██▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░░▒▒▓▓▓███▓▓▓▒░░ ░░░░░░░   ░░░░░░░░ ░░▒▒▓▓▓███▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓███▓▓▓▒▒░░ ░░░░░░░   ░░░░░░░ ░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓█▓▓▓▓▒▒░░ ░░░░░░░░░░   ░░░░░ ░ ░░░▒▒▒▒▓▓▒▒▒▒▒▒░░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒░ ░ ░░░░░░░░   ░░░▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▓▓▓▓▒▒▒▒░ ░░░░░░░░░░   ░ ░░░░░ ░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░ ░░ ░░░░░░░░░░░   ░ ░░░░░░░░░░░ ░ ░░░░░▒░▒▒▒▒░░ ░░░░░░░░░░░░░   ░ ░ ░░░░░░░░░░░░ ░░░ ░░░░░░░░ ░░░░░░░░░░░░░░ ░   ░░░░░░░░░░░░░░░░░ ░ ░░ ░░░░░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░░ ░░░░▒░░░░░░░ ░░░░▒▒░▒░░░░░░░░   ░░░░░ ░░░ ░░░░░░░░░░░░░░░░░░▒░▒▒▒░░░ ░░░░▒▒▒░▒▒░░░░░░░   ░░░░░▒▒▒▒░░░▒░▒▒▒▒▒▒▒░░░░ ░ ░░░▒▒▒▒▒▒░░░░░   ░░░░░▒▒▒▒▒▒▒░▒░▒░▒▒░░░░░░░░░░░░▒▒▒▒▒▒░░░░   ░░░▒▒▒▒▒░▒░░░░░░░░░░░ ░░░░░░▒▒▒▒░░░░░   ░░░░▒▒░░ press ⏎ to skip ░░░░▒░░░░░  [?7h[?25l[?7l ██ ██   ▒▒ ██   Setup step 1 of 5   Sign in to one or more providers. Press ⎋ when you're done.   ░░░░░░░ ░▒▒▓▓████▓▓▒▒░░░ ░░▒▒▓▓███▓▓▓▓▒▒▒▒░░░░░░░░░▒▒▒▒▓▒▒▒▒░░ ░░░░▒░░░   ░░░ ░▒▒▓█▓██▓▓▒▒░░ ░░░▒▒▓▓▓████▓▓▓▒▒▒░░ ░░▒▒▒▓▓▓▒▒▒░░░ ░░░░░░░░░   ░░ ░░▒▓▓▓▓▓▓▓▒▒░░ ░░░▒▒▒▓▓▓▓▓▓▓▒▒▒░░ ░░▒▒▓▓▓▓▓▒▒░░ ░ ░ ░   ░░░░ ░░░▒▒▓▓▓▓▓▓▒░░ ░░▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░▒▓▓▓▓▓▓▓▒░░░ ░   ░ ░░░░░ ░░░▒▒▒▓▒▒▒▒░░░ ░░░▒▒▒▒▒▒▒░▒░░░ ░▒▒▓▓▓▓▓▓▓▒▒░░   ░ ░░░░░░░ ░░▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒░░░░░ ░░▒▒▓████▓▓▒░░   ░░░░░░░░░░ ░▒▒▒▒▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░ ░░▒▒▒▓▓██▓▓▓▒░░░ ░░   ░░░░░░░░░░ ░░░▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░▒▒▓▓▓██▓▓▓▒▒░░ ░░░░░   ░░░░░░░░░░░ ░░▒▒▓▓▓██▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░░▒▒▒▓▓▓███▓▓▒░░░ ░░░░░░░   ░░░░░░░░ ░░▒▒▓▓▓██▓▓▓▒▒▒▒▒▒▒▒▒▒▒░▒░▒▒▒▓▓▓▓██▓▓▓▒▒░░ ░░░░░░░   ░░░░░░░ ░░▒▒▒▓▓█▓█▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓█▓█▓▓▓▒░░ ░░░░░░░░░   ░░░░░░ ░░▒▒▒▒▒▓▓▒▓▒▒▒▒░░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒▒░ ░ ░░░░░░░░ ░   ░ ░░░░▒▒▒▒▒▒▒░░░░░▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒░░ ░░░░░░░░░░░░   ░░░░ ░ ░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒░░ ░ ░░░░░░░░░░░   ░ ░ ░░░░░░░░░ ░ ░ ░░░░░▒░▒▒▒▒░░ ░░░░░░░░░░░░░   ░ ░░░░░░░░░░░░ ░░░░░░░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░░ ░ ░░ ░░░░░░░ ░░░░░░░░░░░░░░ ░   ░░░░░░░░░░░░░░░░ ░░░░░ ░░░ ░░░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░ ░░░░▒░▒░░░░░░░░   ░░░░░░ ░░░ ░░░░░░░░░░░ ░░░░░▒░▒▒░░░░ ░░▒▒▒░▒▒░░░░░░░   ░░░░░░░░░░░░░░▒▒▒▒▒▒░░░░░ ░░░▒▒▒▒▒▒░░░░░   ░░░░░▒▒▒▒▒▒▒░▒░▒░▒▒░░░░░░░░░░░░▒░▒▒▒▒░░░░   ░░▒▒▒▒▒▒░▒░░░░░░░░░░░ ░░░░░░░▒▒▒▒░░░░   ░░░░▒▒░░ press ⏎ to skip ░░░░▒░▒░░░  [?7h[?25l[?7l ██   omp   Sign in to your providers     Pick a provider to sign in — you can connect more than one.   │ ❯ ChatGPT Plus/Pro (Codex Subscription) █ │   │ Anthropic (Claude Pro/Max) │ │   ░░░░░ ░▒▓▓███▓▓▓▒▒░░ ░░░▒▒▓▓▓█████▓▓▓▒▒▒░ ░░▒▒▒▒▓▓▒▒▒░░ ░░░░░░░   ░ ░░▒▒▓▓███▓▓▒░░ ░░░▒▒▒▓▓█▓▓▓▓▓▒▒▒░░ ░▒▒▓▓▓▓▓▒▒░░░ ░ ░ ░   ░░▒▒▓▓▓▓▓▓▒▒░ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░ ░░▒▒▓▓▓▓▓▒▒░░ ░   ░ ░ ░░▒▒▒▓▓▓▒▒▒░ ░░▒▒▒▒▒▒▒░▒▒▒░░ ░▒▒▒▓▓▓▓▓▒▒░░░   ░░░░ ░░░▒▒▒▒▒▒▒▒░░ ░░░░▒▒▒▒▒░░░░░░ ░░▒▒▓▓██▓▓▓▒░░   ░░░░░░░░░ ░░▒▒▒▒▒▒▒▒▒▒░░░░░▒▒▒▒▒▒░░░░░ ░░▒▓▓██▓▓▓▒▒░░ ░░ ░   ░░ ░░░░░░░ ░░▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░░ ░░▒▒▒▓▓██▓▓▓▒▒░░ ░ ░░   ░░░░░░░░░░░ ░▒▒▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▓▓███▓▓▓▒▒░ ░ ░░░░░░   ░░░░░░░░ ░░▒▒▓▓█▓▓█▓▓▒▒▒▒▒▒▒▒▒▒▒▒░▒░░░▒▒▓▓▓▓█▓▓▓▓▒▒░░ ░░░░░░░   ░░░░░░░░ ░░▒▓▓▓██▓█▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓█▓█▓▓▓▒▒░░ ░░░░░░░   ░░░░░░ ░░▒▒▓▓▓▓▓▓▓▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▓▓▓▓█▓▓▒▒░░ ░░░░░░░░   ░░░░ ░░░░░▒▒▒▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒░░ ░░░░░░░░░   ░ ░░░░░░░░░░░░░░▒░▒▒▒▒▒▒▓▓▒▒▒▒░░ ░░░░░░░░░░░   ░░░░░ ░░░ ░░░░░▒▒▒▒▒▒▒▒░░ ░ ░░░░░░░░░░   ░░░░░░░░ ░░░░░░▒░░░░ ░ ░░░░░░░░░░░░   ░░ ░░░░░░░░░░░░░ ░░░░░░░ ░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░ ░░░ ░ ░░░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░ ░░░░░░░░ ░░░░ ░░░░░▒░░░░░░░░░   ░░░░░░░ ░░░░░░░░░ ░ ░░░░▒░░░░░░░ ░░░░░░▒▒░░░░░░░   ░ ░░░░░░░░░░░░░░▒▒░▒▒░░░░░░ ░░░░▒▒▒▒▒░░░░░░   ░░░░░▒▒░░░▒░░▒░▒▒▒▒░░░░░░ ░░░▒▒▒▒▒▒░░░░░   ░░░▒▒▒▒▒▒░░░░░░░▒░░░░░ ░ ░░░░░▒▒▒▒░░░░░   ░░░▒░▒▒▒░ press ⏎ to skip ░░░░▒░▒▒░░░  [?7h[?25l[?7l     ╭─ Select provider to login ───────────────────────────────────────────────────────────────────────────────────╮   │ Z.AI (GLM Coding Plan) │ │   │ Z.AI (GLM Coding Plan · Sign in) │ │   ░░░▒▓▓▓▓▓▓▓▒░░ ░▒▒▒▒▒▒▓▓▓▒▒▒▒▒▒░ ░▒▒▓▓▓▓▓▒▒▒░ ░   ░░▒▒▒▒▓▓▓▒▒░░ ░░▒▒▒▒▒▒▒▒▒▒▒░░ ░░▒▒▓▓▓▓▓▓▒▒░░   ░ ░░▒▒▒▒▒▒▒▒▒░░ ░ ░░▒▒▒▒▒░░░░░░░ ░▒▒▓▓███▓▓▒▒░░   ░░░░░ ░░░▒▒▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒░░░░░ ░░▒▒▓▓▓▓▓▓▓▒░░░ ░░ ░   │ Devin │ │   │ Antigravity (Gemini 3, Claude, GPT-OSS) │ │   ░░░░░░░░ ░▒▒▒▓▓▓▓▓█▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░░▒▒▒▒▓▓█▓██▓▓▒▒░░ ░ ░░░░░   ░░░░░░░░ ░▒▒▓▓███▓█▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓█▓▓▓▒▒▒░░ ░░░░░░░   ░░░░░░ ░▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▓▓▓▓█▓▓▓▒▒░ ░░░░░░░   ░░░░ ░░░░▒▒▒▒▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒░░ ░░░░░░░░░   ░░░░░░▒▒▒░░░░░░▒▒▒▒▒▒▒▓▓▓▓▒▒▒░░ ░░░░░░░░░   ░░░░ ░░ ░ ░░░░░░░░▒▒▒▒▒▒▒▒▒░░░ ░ ░░░░░░░░░   ░░░░░░░░ ░░░░░░▒░░░░ ░░░░░░░░░░░░░   ░░ ░░░░░░░░░░░░ ░░░░░░░ ░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░ ░░░ ░░░░ ░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░ ░░░░░░░ ░░░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░ ░ ░░░░▒░░░░░░░ ░ ░░░░▒▒░░░░░░░   ░ ░░ ░ ░░░░░░░░░ ░░░░░░░▒░░░░░░ ░░░▒▒▒▒░░░░░░ ░░   ░░░░░░▒░░░░░░░░▒▒▒▒░░░░░░ ░░░▒▒▒▒▒▒░░░░░░   ░░░▒▒▒▒▒▒░░░░░░░▒░░░░░ ░░░░░▒▒▒▒░░░░░   ░░░▒░▒▒▒░ press ⏎ to skip ░░░░▒▒▒▒░░░ ░  [?7h[?25l[?7l │ Kimi Code │ │   │ OpenRouter │ │   │ GitHub Copilot │ │   │ Cursor (Claude, GPT, etc.) │ │   │ Type to search │   ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────╯   ░░░░░░ ░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓█▓▓▓▓▒░░ ░░░░░░░   ░░░░ ░░░▒▒▒▒▓▓▒▒▒▒░░░░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒▒░ ░░░░░░░░░   ░ ░░░░░▒░▒▒▒░░░░░░▒▒▒▒▒▒▒▓▓▓▓▒▒▒▒▒░ ░░░░░░░░░   ░░░░ ░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒░░░ ░░░░░░░░░░   ░░░░░░ ░ ░░░░░▒▒░▒░░░ ░░░ ░░░░░░░░░   ░░ ░░░░░░░░░░░ ░░░░░░░ ░ ░░░░░░░░░░░░░   ░░░░░░░░░░░░░ ░ ░ ░░░░ ░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░ ░ ░░░ ░ ░ ░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░ ░░░░░░░░░░░░ ░░░░░▒░░░░░░░░░   ░ ░░ ░ ░░ ░░░░░░░░░ ░░░░░░░░░░░░░ ░░░▒░▒▒░░▒░░░░░░   ░░░░░░░░░░░░░░░▒▒▒▒░░░░░░ ░░░░▒▒▒▒░▒░░░░░   ░░░▒▒▒▒▒▒░░░░░░░▒░░░░░░ ░░░░░▒▒▒▒░▒░░░   ░░░░▒░▒▒▒░ press ⏎ to skip ░░░░▒░▒▒▒░░░░  [?7h[?25l[?7l     ░ ░░ ░░░▒▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▓▓▓▓▓▒▓▒▒░░ ░░░░░░░░   ░ ░░░░ ░░░░░░░░░░░░▒░▒▒▒▒▒▒▒▒▒▒▒░░ ░░░░░░░░░░       ░░░░░░░░░░░░░ ░░░░ ░ ░░░░░░░░░░░░   ░ ░░░░░░░░░░░░░░ ░ ░░░ ░ ░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░ ░ ░░░░░░░░░░░░ ░░░░░░░░░░░░░░░   ░░░░░░ ░ ░░ ░░░ ░ ░░░░░░░░░░░░ ░░░░▒▒░▒▒░░░░░░     ░░░░░▒▒▒▒░░░░░░░░▒░░░░░░ ░░░░▒▒▒▒▒▒░░░   ░░░░▒░▒▒░░ press ⏎ to skip ░░░░▒░▒▒▒░░░░░  [?7h[?25l[?7l         ░░░░░░░░░░░░░░ ░ ░░░░░░░░░░ ░ ░░░░░░░░░░░░░░░   ░░░░░░ ░ ░░ ░░░ ░░░░░░░░░░ ░ ░░░▒▒░▒▒░░░░░░░░   ░░░░░▒▒░░░░░░░░░░▒░░░░░░ ░ ░░░░▒▒▒▒░░░░░   ↑/↓ select · ⏎ confirm · ⎋ skip · ⌃C exit setup  [?7h[?25l[?7l     ░░░░░░░░░░░░░░░░░▒░░░░░ ░░░░▒▒▒▒▒░░░░  [?7h[?25l[?7l  [?7h \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--omp-18-composer-narrow@agent.json b/src/main/runtime/__fixtures__/readiness-census/transcript--omp-18-composer-narrow@agent.json new file mode 100644 index 00000000000..105aa058311 --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--omp-18-composer-narrow@agent.json @@ -0,0 +1,15 @@ +{ + "description": "omp recording at 60x24 replayed on the agent pane, one entry per chunk", + "observations": { + "clocked": [ + "0-303: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "304-305: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start", + "306-364: now=pending:closed edge=ready-strong quiet=ready-strong wait=ready@start", + "365-707: now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start" + ], + "clockless": [ + "0-303: verdict=pending:closed wait=pending", + "304-707: verdict=ready-strong wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--omp-18-composer-narrow@unknown.json b/src/main/runtime/__fixtures__/readiness-census/transcript--omp-18-composer-narrow@unknown.json new file mode 100644 index 00000000000..39c61da1329 --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--omp-18-composer-narrow@unknown.json @@ -0,0 +1,15 @@ +{ + "description": "omp recording at 60x24 replayed on the unknown pane, one entry per chunk", + "observations": { + "clocked": [ + "0-303: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll", + "304-305: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start", + "306-364: now=pending:closed edge=ready-strong quiet=ready-strong wait=ready@start", + "365-707: now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start" + ], + "clockless": [ + "0-303: verdict=pending:open wait=ready@poll", + "304-707: verdict=ready-strong wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--omp-18-composer@agent.json b/src/main/runtime/__fixtures__/readiness-census/transcript--omp-18-composer@agent.json new file mode 100644 index 00000000000..b217a84a268 --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--omp-18-composer@agent.json @@ -0,0 +1,19 @@ +{ + "description": "omp recording at 120x40 replayed on the agent pane, one entry per chunk", + "observations": { + "clocked": [ + "0-510: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "511: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start", + "512-570: now=pending:closed edge=ready-strong quiet=ready-strong wait=ready@start", + "571-587: now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start", + "588-25926: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "25927-26866: now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start" + ], + "clockless": [ + "0-510: verdict=pending:closed wait=pending", + "511-587: verdict=ready-strong wait=ready@start", + "588-25926: verdict=pending:closed wait=pending", + "25927-26866: verdict=ready-strong wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--omp-18-composer@unknown.json b/src/main/runtime/__fixtures__/readiness-census/transcript--omp-18-composer@unknown.json new file mode 100644 index 00000000000..4564c04d132 --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--omp-18-composer@unknown.json @@ -0,0 +1,19 @@ +{ + "description": "omp recording at 120x40 replayed on the unknown pane, one entry per chunk", + "observations": { + "clocked": [ + "0-510: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll", + "511: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start", + "512-570: now=pending:closed edge=ready-strong quiet=ready-strong wait=ready@start", + "571-587: now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start", + "588-25926: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "25927-26866: now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start" + ], + "clockless": [ + "0-510: verdict=pending:open wait=ready@poll", + "511-587: verdict=ready-strong wait=ready@start", + "588-25926: verdict=pending:closed wait=pending", + "25927-26866: verdict=ready-strong wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--omp-18-setup@agent.json b/src/main/runtime/__fixtures__/readiness-census/transcript--omp-18-setup@agent.json new file mode 100644 index 00000000000..ea6693bfa5e --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--omp-18-setup@agent.json @@ -0,0 +1,17 @@ +{ + "description": "omp recording at 120x40 replayed on the agent pane, one entry per chunk", + "observations": { + "clocked": [ + "0-909: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "910: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start", + "911-969: now=pending:closed edge=ready-strong quiet=ready-strong wait=ready@start", + "970-986: now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start", + "987-25161: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending" + ], + "clockless": [ + "0-909: verdict=pending:closed wait=pending", + "910-986: verdict=ready-strong wait=ready@start", + "987-25161: verdict=pending:closed wait=pending" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--omp-18-setup@unknown.json b/src/main/runtime/__fixtures__/readiness-census/transcript--omp-18-setup@unknown.json new file mode 100644 index 00000000000..484e8bd78f9 --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--omp-18-setup@unknown.json @@ -0,0 +1,17 @@ +{ + "description": "omp recording at 120x40 replayed on the unknown pane, one entry per chunk", + "observations": { + "clocked": [ + "0-909: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll", + "910: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start", + "911-969: now=pending:closed edge=ready-strong quiet=ready-strong wait=ready@start", + "970-986: now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start", + "987-25161: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending" + ], + "clockless": [ + "0-909: verdict=pending:open wait=ready@poll", + "910-986: verdict=ready-strong wait=ready@start", + "987-25161: verdict=pending:closed wait=pending" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--omp-native-title-win32@agent.json b/src/main/runtime/__fixtures__/readiness-census/transcript--omp-native-title-win32@agent.json index e2d95131a67..f0d51f39845 100644 --- a/src/main/runtime/__fixtures__/readiness-census/transcript--omp-native-title-win32@agent.json +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--omp-native-title-win32@agent.json @@ -3,9 +3,13 @@ "observations": { "clocked": [ "0: now=working edge=working quiet=working wait=pending", - "1: now=pending:closed edge=pending:closed quiet=ready-weak wait=ready@start", + "1: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start", "2-3: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending" ], - "clockless": ["0: verdict=working wait=pending", "1-3: verdict=pending:closed wait=pending"] + "clockless": [ + "0: verdict=working wait=pending", + "1: verdict=ready-strong wait=ready@start", + "2-3: verdict=pending:closed wait=pending" + ] } } diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--omp-native-title-win32@unknown.json b/src/main/runtime/__fixtures__/readiness-census/transcript--omp-native-title-win32@unknown.json index 5168edca93e..6d55bf91a4d 100644 --- a/src/main/runtime/__fixtures__/readiness-census/transcript--omp-native-title-win32@unknown.json +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--omp-native-title-win32@unknown.json @@ -3,9 +3,13 @@ "observations": { "clocked": [ "0: now=working edge=working quiet=working wait=pending", - "1: now=pending:closed edge=pending:closed quiet=ready-weak wait=ready@start", + "1: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start", "2-3: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending" ], - "clockless": ["0: verdict=working wait=pending", "1-3: verdict=pending:closed wait=pending"] + "clockless": [ + "0: verdict=working wait=pending", + "1: verdict=ready-strong wait=ready@start", + "2-3: verdict=pending:closed wait=pending" + ] } } diff --git a/src/main/runtime/agent-state-rules/agent-state-rules-engine.test.ts b/src/main/runtime/agent-state-rules/agent-state-rules-engine.test.ts index c15c60cb9d2..157351f18c7 100644 --- a/src/main/runtime/agent-state-rules/agent-state-rules-engine.test.ts +++ b/src/main/runtime/agent-state-rules/agent-state-rules-engine.test.ts @@ -211,6 +211,8 @@ describe('strength and quiet through the tui-idle ranking', () => { readPositiveBodyEvidence: () => false, readQuietReadyBodyEvidence: () => false, readAgentRuleVerdict: () => ruled, + readScreenInputVeto: () => null, + titleObservedAtEpochMs: null, agent: 'cline', firstPartyStatus: null, quiescenceMs: QUIESCENCE_MS diff --git a/src/main/runtime/agent-transcript-replay-test-harness.ts b/src/main/runtime/agent-transcript-replay-test-harness.ts index 35e7726bea9..35364709505 100644 --- a/src/main/runtime/agent-transcript-replay-test-harness.ts +++ b/src/main/runtime/agent-transcript-replay-test-harness.ts @@ -11,7 +11,7 @@ import { visibleNonBlankTerminalLines } from './terminal-tail-read' const DEFAULT_CHUNK_CHARS = 64 -/** `screenLines` are readLiveTerminalScreenLines' rows; `ruledScreenLines` readScreenRuledLines'. */ +/** `screenLines` are readLiveTerminalScreenLines' rows; `ruledScreenLines` readRuledScreen's. */ export type TranscriptReplayFrame = { screenLines: string[] ruledScreenLines: string[] diff --git a/src/main/runtime/codex-quiet-ready-screen.test.ts b/src/main/runtime/codex-quiet-ready-screen.test.ts index 8a6c6cf211e..db6077a723e 100644 --- a/src/main/runtime/codex-quiet-ready-screen.test.ts +++ b/src/main/runtime/codex-quiet-ready-screen.test.ts @@ -198,6 +198,8 @@ describe('Codex composer ready screen, frame by frame', () => { isQuietReadyScreenBody(waitText, 'codex', () => screenLines), agent: 'codex', readAgentRuleVerdict: () => null, + readScreenInputVeto: () => null, + titleObservedAtEpochMs: null, firstPartyStatus: null, quiescenceMs: QUIESCENCE_MS }) @@ -295,6 +297,8 @@ describe('a busy 0.150-0.157 pane whose header stays in the tail', () => { isQuietReadyScreenBody(waitText, 'codex', () => screenLines), agent: 'codex', readAgentRuleVerdict: () => null, + readScreenInputVeto: () => null, + titleObservedAtEpochMs: null, firstPartyStatus: null, quiescenceMs: QUIESCENCE_MS }) @@ -352,6 +356,8 @@ describe('a busy 0.150-0.157 pane whose header stays in the tail', () => { readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText, 'codex', () => header), agent: 'codex', readAgentRuleVerdict: () => null, + readScreenInputVeto: () => null, + titleObservedAtEpochMs: null, firstPartyStatus: null, quiescenceMs: QUIESCENCE_MS }) @@ -381,6 +387,8 @@ describe('reading the live screen never removes quiet-lane readiness', () => { isKnownReadyPromptBody(frame.waitText, agent, () => frame.screenLines, true), agent, readAgentRuleVerdict: () => null, + readScreenInputVeto: () => null, + titleObservedAtEpochMs: null, firstPartyStatus: null, quiescenceMs: QUIESCENCE_MS } satisfies Omit diff --git a/src/main/runtime/omp-readiness-transcripts.test.ts b/src/main/runtime/omp-readiness-transcripts.test.ts new file mode 100644 index 00000000000..ecfd65e7cc3 --- /dev/null +++ b/src/main/runtime/omp-readiness-transcripts.test.ts @@ -0,0 +1,231 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' +import { extractLastOscTitle } from '../../shared/osc-title-extraction' +import type { TuiAgent } from '../../shared/tui-agent' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +function transcript(name: string): string { + return readFileSync(join(__dirname, '__fixtures__', `${name}.txt`), 'utf8') +} + +// Synthetic repaint of the composer's bottom row (row 21 of the 120x40 capture): no capture +// exists of a post-turn, resumed or non-default-glyph composer, whose effort hint is gone or +// differs. +const HINTLESS_COMPOSER_ROW = '\x1b[21;1H\x1b[2K╰─' +const ASCII_HINT_COMPOSER_ROW = `\x1b[21;1H\x1b[2K╰─${' '.repeat(82)}Shift+Tab to change thinking effort` + +async function waitsReady( + options: { + name: string + title: string + launchAgent?: TuiAgent + repaint?: string + size?: { cols: number; rows: number } | null + busyFirst?: boolean + keepalive?: boolean + }, + timeoutMs = 5_000 +): Promise { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: options.launchAgent ? 'omp' : 'bun', + data: transcript(options.name), + ...(options.launchAgent ? { launchAgent: options.launchAgent } : {}), + ...(options.size === null ? {} : { size: options.size ?? { cols: 120, rows: 40 } }) + }) + if (options.busyFirst) { + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;π : capture-cwd\x07', Date.now()) + } + if (options.repaint) { + // Why the wait: the grid ingests writes asynchronously, and the repaint precedes the title. + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, options.repaint, Date.now()) + await vi.advanceTimersByTimeAsync(50) + } + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, `\x1b]0;${options.title}\x07`, Date.now()) + // OMP 18.4.5 re-asserts bracketed paste every second once a terminal answers its DECRQM probe, + // as xterm and main's query authority do; the capture tool answered nothing. + const keepalive = options.keepalive + ? setInterval(() => runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b[?2004h', Date.now()), 1_000) + : null + const settled = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs }).then( + (result) => result.satisfied === true, + () => false + ) + await vi.advanceTimersByTimeAsync(timeoutMs) + if (keepalive) { + clearInterval(keepalive) + } + return settled +} + +describe('OMP 18.4.5 captured readiness', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it.each([ + ['omp-18-setup', 120, 40, false], + ['omp-18-composer', 120, 40, true], + ['omp-18-composer-narrow', 60, 24, true] + ] as const)('%s at %sx%s has readiness %s', async (name, cols, rows, ready) => { + const data = transcript(name) + expect(data).toContain('\x1b[') + expect(data).toContain('\r') + const { runtime, handle } = await createTranscriptPane({ + paneTitle: extractLastOscTitle(data) ?? 'OMP', + foregroundProcess: 'omp', + data, + launchAgent: 'omp', + size: { cols, rows } + }) + const result = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 5_000 }) + const assertion = ready + ? expect(result).resolves.toMatchObject({ satisfied: true }) + : expect(result).rejects.toThrow('timeout') + await Promise.all([assertion, vi.advanceTimersByTimeAsync(5_000)]) + }) + + it('does not accept the composer while its native title still says working', async () => { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'OMP', + foregroundProcess: 'omp', + data: transcript('omp-18-composer'), + launchAgent: 'omp', + size: { cols: 120, rows: 40 } + }) + // Synthetic busy transition isolates the timer while preserving the captured composer grid. + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;π : capture-cwd\x07', Date.now()) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b[0m', Date.now()) + const result = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 5_000 }) + const assertion = expect(result).rejects.toThrow('timeout') + await Promise.all([assertion, vi.advanceTimersByTimeAsync(5_000)]) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;π > capture-cwd\x07', Date.now()) + const idle = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 5_000 }) + const idleAssertion = expect(idle).resolves.toMatchObject({ satisfied: true }) + await Promise.all([idleAssertion, vi.advanceTimersByTimeAsync(5_000)]) + }) + + it.each([ + ['hintless', HINTLESS_COMPOSER_ROW], + ['ascii-glyph', ASCII_HINT_COMPOSER_ROW] + ])('accepts a %s composer: the effort hint retires and its glyphs vary', async (_, repaint) => { + expect( + await waitsReady({ + name: 'omp-18-composer', + title: 'π > capture-cwd', + launchAgent: 'omp', + repaint + }) + ).toBe(true) + }) + + it("accepts the titlebar extension's post-turn title on a hintless composer", async () => { + expect( + await waitsReady({ + name: 'omp-18-composer', + title: 'π - capture-cwd', + launchAgent: 'omp', + repaint: HINTLESS_COMPOSER_ROW, + busyFirst: true + }) + ).toBe(true) + }) + + it('accepts an OMP 17-style `π:` title through the quiet name-only lane', async () => { + expect( + await waitsReady({ name: 'omp-18-composer', title: 'π: capture-cwd', launchAgent: 'omp' }) + ).toBe(true) + }) + + it("refuses the wizard's splash, which has no step heading yet", async () => { + const setup = transcript('omp-18-setup') + const splash = setup.slice(0, setup.indexOf('Setup step')) + expect(splash).toContain('\x1b[?1049h') + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'omp', + data: splash, + launchAgent: 'omp', + size: { cols: 120, rows: 40 } + }) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;π > capture-cwd\x07', Date.now()) + const result = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 5_000 }) + const assertion = expect(result).rejects.toThrow('timeout') + await Promise.all([assertion, vi.advanceTimersByTimeAsync(5_000)]) + }) + + it.each([ + ['omp-18-composer', true], + ['omp-18-setup', false] + ] as const)('%s under the bracketed-paste keepalive has readiness %s', async (name, ready) => { + expect( + await waitsReady( + { name, title: 'π > capture-cwd', launchAgent: 'omp', keepalive: true }, + 10_000 + ) + ).toBe(ready) + }) + + it('accepts a post-turn answer that mentions a setup step on the normal screen', async () => { + expect( + await waitsReady({ + name: 'omp-18-composer', + title: 'π - capture-cwd', + launchAgent: 'omp', + repaint: '\x1b[12;1H\x1b[2K Setup step 2 of 4: install the dependencies', + busyFirst: true + }) + ).toBe(true) + }) + + it.each(['π - capture-cwd', 'π: capture-cwd'])( + 'refuses %s while the setup wizard is on screen', + async (title) => { + expect(await waitsReady({ name: 'omp-18-setup', title, launchAgent: 'omp' })).toBe(false) + } + ) + + it('refuses `π >` with no trustworthy screen to rule the setup wizard out', async () => { + expect( + await waitsReady({ + name: 'omp-18-composer', + title: 'π > capture-cwd', + launchAgent: 'omp', + size: null + }) + ).toBe(false) + // A post-turn title cannot come from setup, so it needs no screen. + expect( + await waitsReady({ + name: 'omp-18-composer', + title: 'π - capture-cwd', + launchAgent: 'omp', + size: null + }) + ).toBe(true) + }) + + it.each([['pi' as const], [undefined]])( + 'does not make the setup wizard ready at once for agent identity %s', + async (launchAgent) => { + // Identities other than omp keep main's quiet name-only lane, which cannot settle this soon. + expect( + await waitsReady( + { + name: 'omp-18-setup', + title: 'π > capture-cwd', + ...(launchAgent ? { launchAgent } : {}) + }, + 1_000 + ) + ).toBe(false) + } + ) +}) diff --git a/src/main/runtime/omp-terminal-readiness.ts b/src/main/runtime/omp-terminal-readiness.ts new file mode 100644 index 00000000000..de27a5bf59e --- /dev/null +++ b/src/main/runtime/omp-terminal-readiness.ts @@ -0,0 +1,17 @@ +import { getPiStateTitleStatus } from '../../shared/pi-state-title-marker' +import type { RuledScreen } from './screen-input-veto' + +/** + * OMP 18.4 runs its first-run setup wizard (splash, steps, outro) as a fullscreen overlay on the + * alternate screen, and keys typed there drive the wizard, not the composer. Every other OMP + * alternate-screen user is an overlay that owns input too. Why not the "Setup step N of M" + * heading: it only ever appears there, and on the normal screen it is an answer's own text. + */ +export function isOmpOverlayScreen(screen: RuledScreen): boolean { + return screen.alternateScreen +} + +/** OMP's own idle state title (`π > cwd`), which it already paints before the setup wizard. */ +export function isOmpIdleStateTitle(title: string | null | undefined): boolean { + return title !== null && title !== undefined && getPiStateTitleStatus(title) === 'idle' +} diff --git a/src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts b/src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts index 3bc5b570704..57158256605 100644 --- a/src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts +++ b/src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts @@ -18,6 +18,7 @@ import { resolveTerminalSessionWorktreeId } from './runtime-worktree-path-identi import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import { adoptRuntimeTerminalOrphansFromInventory } from './runtime-terminal-orphan-adoption' import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' +import { getLatestPtyTitle } from './runtime-worktree-status-projection' import type { PtyLivenessVerdict } from '../../shared/pty-liveness-verdict' export class OrcaRuntimeWithAdoptTerminalOrphansFromInventory extends OrcaRuntimeWithSubscribeToTerminalResize { @@ -62,6 +63,7 @@ export class OrcaRuntimeWithAdoptTerminalOrphansFromInventory extends OrcaRuntim getPty: (handle) => this.getLivePtyForHandle(handle)?.pty ?? null, getLeaves: (ptyId) => this.getLeavesForPty(ptyId), getLeaf: (tabId, leafId) => this.leaves.get(this.getLeafKey(tabId, leafId)), + getDisplayTitle: (pty) => getLatestPtyTitle(this.getPtyDisplayRecord(pty)), replayPersistedSurface: (pty, tabId, paneKey) => recordPtySurface(pty, tabId, paneKey, SURFACE_CLAIM_WITHOUT_STANDING), recordAdoptedSurface: (pty, tabId, paneKey) => diff --git a/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts b/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts index 2976c4dff9b..a0df20716ce 100644 --- a/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts +++ b/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts @@ -6,13 +6,16 @@ import { terminalTitleBlocksExplicitAgentStatus } from './runtime-worktree-statu export class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnpersistedTrackedTitleForPty { /** Apply one observed OSC title (raw form) to the PTY and leaf records. - * Returns true when the PTY record's title or status changed. */ + * Returns true when what the PTY shows changed: its record, or its display-only clear. */ protected applyTrackedPtyTitle( ptyId: string, rawTitle: string, normalizedTitle: string, meta?: TerminalTitleFactMeta ): boolean { + if (meta?.staleWorkingTitleClear) { + return this.applyStaleWorkingTitleClear(ptyId, rawTitle, normalizedTitle) + } // Why: status is detected from the RAW title (mirrors the renderer tracker), // so working/idle transitions are unaffected by normalization; the records // store the NORMALIZED title so rotating Grok/Pi/Gemini frames collapse to @@ -25,10 +28,12 @@ export class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnper const identityOnlyTitle = this.isLiveCursorNativeTitle(rawTitle, meta) const recordedTitle = identityOnlyTitle ? null : normalizedTitle const agentStatus = identityOnlyTitle ? null : detectAgentStatusFromTitle(rawTitle) + // Why before retiring the clear: the lifecycle counts a turn from the status the clear showed. this.recordAgentPromptLifecycleState(ptyId, agentStatus) let ptyRecordChanged = false const pty = this.ptysById.get(ptyId) if (pty) { + pty.titleDisplayClear = null const prevStatus = pty.lastAgentStatus const prevTitle = pty.lastOscTitle const observedAt = this.nextTitleObservationSequence() @@ -152,6 +157,41 @@ export class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnper return ptyRecordChanged } + /** + * The stale-working timer's clear: it only guesses that the agent may have exited behind a + * working title. Of what a genuine title does, it skips the title/status evidence that + * readiness and delivery read (records, waiters, mailbox, delivery), and still re-derives the + * process evidence that guess is about: the foreground agent and the exit/completion check. + * Display takes the cleared title from `titleDisplayClear`; the caller publishes it. + */ + private applyStaleWorkingTitleClear( + ptyId: string, + rawTitle: string, + normalizedTitle: string + ): boolean { + const pty = this.ptysById.get(ptyId) + if (!pty) { + return false + } + const nativeStatus = pty.lastAgentStatus + const clearedStatus = detectAgentStatusFromTitle(rawTitle) + const observedAt = this.nextTitleObservationSequence() + const previousTitle = pty.titleDisplayClear?.title ?? pty.lastOscTitle + pty.titleDisplayClear = { title: normalizedTitle, observedAt, observedAtEpochMs: Date.now() } + if (nativeStatus === 'working' && clearedStatus === null) { + this.confirmPtyAgentExit(ptyId, true) + } + if (nativeStatus === clearedStatus) { + return previousTitle !== normalizedTitle + } + const foregroundRefresh = this.ptyForegroundAgent.refresh(ptyId, observedAt) + if (this.shouldDelayPtyBackedMobileSnapshotForForegroundAgent(pty, normalizedTitle)) { + this.delayPtyBackedMobileSnapshotForForegroundAgent(ptyId, observedAt, foregroundRefresh) + return false + } + return true + } + /** Cancel the per-PTY title tracker (stale-title timer included) on PTY * teardown so it cannot fire into pruned records. */ protected disposePtyTitleTracker(ptyId: string): void { @@ -179,6 +219,7 @@ export class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnper pty.lastOscTitle = null pty.lastOscTitleAt = null pty.lastOscTitleEpochMs = null + pty.titleDisplayClear = null pty.lastAgentStatus = null // Why: the prior process's first-party status would otherwise veto idle for its // replacement — a stale `working` keeps tui-idle unresolved on the new generation. diff --git a/src/main/runtime/orca-runtime-build-pty-terminal-summary.ts b/src/main/runtime/orca-runtime-build-pty-terminal-summary.ts index 2211e2fbecd..5158625a7a2 100644 --- a/src/main/runtime/orca-runtime-build-pty-terminal-summary.ts +++ b/src/main/runtime/orca-runtime-build-pty-terminal-summary.ts @@ -25,7 +25,7 @@ export class OrcaRuntimeWithBuildPtyTerminalSummary extends OrcaRuntimeWithGetPt ): RuntimeTerminalSummary { const worktree = worktreesById.get(pty.worktreeId) - const title = getLatestPtyTitle(pty) + const title = getLatestPtyTitle(this.getPtyDisplayRecord(pty)) const pane = parsePaneKey(pty.paneKey ?? '') const orphaned = !ptyHoldsRecordedSurface(pty, this.ptySurfaceTopology()) // A live process awaiting its pane binding is not evidence of an orphan. diff --git a/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts b/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts index dbbb124d34a..8ce88334e1b 100644 --- a/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts +++ b/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts @@ -5,7 +5,17 @@ import type { TerminalSideEffectBatch, TerminalSideEffectFact } from '../../shared/terminal-side-effect-facts' -import { isCursorNativeAgentTitle, normalizeTerminalTitle } from '../../shared/agent-detection' +import { + detectAgentStatusFromTitle, + isCursorNativeAgentTitle, + normalizeTerminalTitle +} from '../../shared/agent-detection' +import { + getLeafDisplayRecord, + getPtyDisplayRecord, + type TitleDisplayClear +} from './runtime-worktree-status-projection' +import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import type { TerminalTitleFactMeta } from '../../shared/terminal-output-side-effects' export class OrcaRuntimeWithEmitDaemonPtyTransientFact extends OrcaRuntimeWithScheduleWaitBlockedCheck { @@ -186,9 +196,24 @@ export class OrcaRuntimeWithEmitDaemonPtyTransientFact extends OrcaRuntimeWithSc /** Display fallback for identities intentionally omitted from liveness records. */ protected getTrackedDisplayTitleForPty(ptyId: string): string | null { return ( + this.getPtyTitleDisplayClear(ptyId)?.title ?? this.getTrackedRawTitleForPty(ptyId) ?? this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.getLastNormalizedTitle() ?? null ) } + + /** The stale-working timer's display-only clear of this PTY's native title, while it stands. */ + protected getPtyTitleDisplayClear(ptyId: string | null | undefined): TitleDisplayClear | null { + const clear = ptyId ? this.ptysById.get(ptyId)?.titleDisplayClear : null + return clear ? { ...clear, status: detectAgentStatusFromTitle(clear.title) } : null + } + + protected getPtyDisplayRecord(pty: RuntimePtyWorktreeRecord): RuntimePtyWorktreeRecord { + return getPtyDisplayRecord(pty, this.getPtyTitleDisplayClear(pty.ptyId)) + } + + protected getLeafDisplayRecord(leaf: RuntimeLeafRecord): RuntimeLeafRecord { + return getLeafDisplayRecord(leaf, this.getPtyTitleDisplayClear(leaf.ptyId)) + } } diff --git a/src/main/runtime/orca-runtime-focus-terminal.ts b/src/main/runtime/orca-runtime-focus-terminal.ts index e0a74325918..d7fc9a8d9fe 100644 --- a/src/main/runtime/orca-runtime-focus-terminal.ts +++ b/src/main/runtime/orca-runtime-focus-terminal.ts @@ -77,7 +77,7 @@ export class OrcaRuntimeWithFocusTerminal extends OrcaRuntimeWithWaitForLeafPtyI const parsedPaneKey = parsePaneKey(live.pty.paneKey ?? '') const revealed = await notifier.revealTerminalSession(live.pty.worktreeId, { ptyId: live.pty.ptyId, - title: getLatestPtyTitle(live.pty), + title: getLatestPtyTitle(this.getPtyDisplayRecord(live.pty)), ...(live.pty.launchConfig ? { launchConfig: copySleepingAgentLaunchConfig(live.pty.launchConfig) } : {}), diff --git a/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts b/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts index a87f6cc03f2..80796cb28bb 100644 --- a/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts +++ b/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts @@ -6,6 +6,7 @@ import type { RuntimeTerminalInteractiveWait } from '../../shared/runtime-types' import type { RuntimeTerminalAgentStatusSnapshot } from './runtime-terminal-agent-status-query' +import type { AgentStatus } from '../../shared/agent-detection' import { withTimeout } from './runtime-async-boundaries' import { TERMINAL_INTERACTIVE_WAIT_PROBE_TIMEOUT_MS } from './orca-runtime-core' import { parsePaneKey } from '../../shared/stable-pane-id' @@ -20,18 +21,22 @@ export class OrcaRuntimeWithGetTerminalInteractiveWait extends OrcaRuntimeWithAd handle: string ): Promise { let ptyId: string - let terminal: RuntimeTerminalAgentStatusSnapshot + let inputs: { + terminal: RuntimeTerminalAgentStatusSnapshot + lifecycle: { status: AgentStatus | null; updatedAt: number } | null | undefined + } try { ptyId = this.getTerminalAgentStatusPtyId(handle) - terminal = this.getTerminalAgentStatusSnapshot(handle, ptyId) + inputs = this.getTerminalWaitPermissionInputs(handle, ptyId) } catch { return undefined } + const { terminal, lifecycle } = inputs const explicitStatus = this.getFreshExplicitAgentStatusForHandle(handle) const promptReason = this.resolveAuthoritativeTerminalWaitPermission( terminal, explicitStatus, - this.agentPromptLifecycleByPtyId.get(ptyId) + lifecycle ) if (promptReason) { return { diff --git a/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts b/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts index 2e6f30770b0..1fb48756300 100644 --- a/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts +++ b/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts @@ -49,7 +49,10 @@ export class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends OrcaRuntime // app relaunch the PTY/leaf records can already hold a persisted title; a // cold tracker would miss the parked working→idle completion and never // arm the stale-title timer for a persisted 'working' title. - let initialTitle = this.ptysById.get(ptyId)?.lastOscTitle ?? null + // Why the display clear first: a tracker recreated after a relay drop must not re-arm on the + // working title the timer already retired. + const pty = this.ptysById.get(ptyId) + let initialTitle = pty?.titleDisplayClear?.title ?? pty?.lastOscTitle ?? null if (initialTitle === null) { for (const leaf of this.getLeavesForPty(ptyId)) { if (leaf.lastOscTitle) { diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index b25ce381def..4333cc7e3a5 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -78,6 +78,7 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis tabs: this.tabs, session, getPaneKey: (leaf) => this.makeRuntimePaneKey(leaf), + getTitleDisplayClear: (ptyId) => this.getPtyTitleDisplayClear(ptyId), getSummary: (summaryMap, pathIndex, missingIds, worktreeId) => this.getSummaryForRuntimeWorktreeId(summaryMap, pathIndex, missingIds, worktreeId) }) diff --git a/src/main/runtime/orca-runtime-has-exact-persisted-terminal-surface-identity.ts b/src/main/runtime/orca-runtime-has-exact-persisted-terminal-surface-identity.ts index 7aea8a0d0d7..265f2ac28c0 100644 --- a/src/main/runtime/orca-runtime-has-exact-persisted-terminal-surface-identity.ts +++ b/src/main/runtime/orca-runtime-has-exact-persisted-terminal-surface-identity.ts @@ -180,7 +180,7 @@ export class OrcaRuntimeWithHasExactPersistedTerminalSurfaceIdentity extends Orc } const reveal = await this.notifier.revealTerminalSession(candidate.worktreeId, { ptyId: candidate.ptyId, - title: getLatestPtyTitle(pty) ?? pty.controllerTitle, + title: getLatestPtyTitle(this.getPtyDisplayRecord(pty)) ?? pty.controllerTitle, activate: false, presentation: 'background', tabId: candidate.tabId, diff --git a/src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts b/src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts index 665a5ef2253..40706f4730f 100644 --- a/src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts +++ b/src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts @@ -127,6 +127,8 @@ export class OrcaRuntimeWithMaybeHydrateHeadlessFromRenderer extends OrcaRuntime // once a live title was observed, so live state always wins. this.getOrCreatePtyTitleTrackerEntry(ptyId).tracker.seedInitialTitle(title) const status = detectAgentStatusFromTitle(title) + // Why evidence, not display: display readers project a stale-working clear over the record, + // so re-seeding the native working title cannot bring a cleared spinner back. // Why: live observations store normalized titles, so seeds must match — // otherwise the first live frame after hydration compares unequal and // touches session tabs once for no visible change. diff --git a/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts b/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts index 75005726c2d..42a4dadd25f 100644 --- a/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts +++ b/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts @@ -99,6 +99,7 @@ export class OrcaRuntimeWithPruneMobileSessionTabGroupLayout extends OrcaRuntime getRetainedStatus: (paneKey, pty, tab, getRows) => this.getFreshRetainedAgentStatusForMobileTab(paneKey, pty, tab, getRows), getTrackedTitle: (ptyId) => this.getUnpersistedTrackedTitleForPty(ptyId), + getTitleDisplayClear: (ptyId) => this.getPtyTitleDisplayClear(ptyId), issuePtyHandle: (pty) => this.issuePtyHandle(pty), recordPty: (ptyId, worktreeId, state) => this.recordPtyWorktree(ptyId, worktreeId, state), buildPtyStatus: (pty, tab, terminalHandle, retained, getRows) => @@ -117,12 +118,23 @@ export class OrcaRuntimeWithPruneMobileSessionTabGroupLayout extends OrcaRuntime retained: RuntimeAgentRowSnapshot | null, getHookRowsForPane: (paneKey: string) => AgentStatusIpcPayload[] ): { agentStatus: AgentStatusEntry } | Record { - return buildRuntimeMobileAgentStatus(pty, tab, terminalHandle, retained, getHookRowsForPane, { - getPaneKey: (candidate) => this.getMobileTerminalPaneKey(candidate), - getLeaf: (candidate) => - this.leaves.get(this.getLeafKey(candidate.parentTabId, candidate.leafId)) ?? null, - getTrackedTitle: (ptyId) => this.getUnpersistedTrackedTitleForPty(ptyId) - }) + // Why display records: a phone status is presentation, so it shows the stale-working clear. + const displayPty = pty ? this.getPtyDisplayRecord(pty) : null + return buildRuntimeMobileAgentStatus( + displayPty, + tab, + terminalHandle, + retained, + getHookRowsForPane, + { + getPaneKey: (candidate) => this.getMobileTerminalPaneKey(candidate), + getLeaf: (candidate) => { + const leaf = this.leaves.get(this.getLeafKey(candidate.parentTabId, candidate.leafId)) + return leaf ? this.getLeafDisplayRecord(leaf) : null + }, + getTrackedTitle: (ptyId) => this.getUnpersistedTrackedTitleForPty(ptyId) + } + ) } protected getFreshRetainedAgentStatusForMobileTab( diff --git a/src/main/runtime/orca-runtime-publish-pty-backed-mobile-session-terminal.ts b/src/main/runtime/orca-runtime-publish-pty-backed-mobile-session-terminal.ts index 778128d7736..ea220e9a4b4 100644 --- a/src/main/runtime/orca-runtime-publish-pty-backed-mobile-session-terminal.ts +++ b/src/main/runtime/orca-runtime-publish-pty-backed-mobile-session-terminal.ts @@ -44,7 +44,7 @@ export class OrcaRuntimeWithPublishPtyBackedMobileSessionTerminal extends OrcaRu const existing = this.mobileSessionTabsByWorktree.get(worktreeId) const ownerAgent = pty.launchAgent ?? pty.foregroundAgent const title = normalizeCompatibleAgentTitleForOwner( - args.title ?? getLatestPtyTitle(pty) ?? 'Terminal', + args.title ?? getLatestPtyTitle(this.getPtyDisplayRecord(pty)) ?? 'Terminal', ownerAgent, { ownerIsLaunch: Boolean(pty.launchAgent) } ) diff --git a/src/main/runtime/orca-runtime-record-agent-prompt-lifecycle-state.ts b/src/main/runtime/orca-runtime-record-agent-prompt-lifecycle-state.ts index 999f23d6ad3..001f5f4ebff 100644 --- a/src/main/runtime/orca-runtime-record-agent-prompt-lifecycle-state.ts +++ b/src/main/runtime/orca-runtime-record-agent-prompt-lifecycle-state.ts @@ -1,6 +1,7 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { OrcaRuntimeWithCreateTerminalSideEffectCommandCodeDetector } from './orca-runtime-create-terminal-side-effect-command-code-detector' import type { AgentStatus } from '../../shared/agent-detection' +import { getDisplayPromptLifecycle } from './runtime-worktree-status-projection' import { findLastCompleteOscTitleRange } from './orca-runtime-core' import { extractLastOscTitle } from '../../shared/osc-title-extraction' import { detectAgentStatusFromTitle } from '../../shared/agent-detection' @@ -13,6 +14,12 @@ export class OrcaRuntimeWithRecordAgentPromptLifecycleState extends OrcaRuntimeW this.recordAgentPromptPermissionObservation(ptyId) } const current = this.agentPromptLifecycleByPtyId.get(ptyId) + // Why projected: main recorded a stale-working clear here, so the next working status after + // one starts a new turn for prompt verification, as it did there. + const previousStatus = getDisplayPromptLifecycle( + current, + this.getPtyTitleDisplayClear(ptyId) + )?.status const updatedAt = Date.now() if (!current) { this.agentPromptLifecycleByPtyId.set(ptyId, { @@ -25,7 +32,7 @@ export class OrcaRuntimeWithRecordAgentPromptLifecycleState extends OrcaRuntimeW this.agentPromptLifecycleByPtyId.set(ptyId, { status, workingSequence: - current.workingSequence + (status === 'working' && current.status !== 'working' ? 1 : 0), + current.workingSequence + (status === 'working' && previousStatus !== 'working' ? 1 : 0), updatedAt }) } diff --git a/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts b/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts index a86c95d8742..d7aa946f477 100644 --- a/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts +++ b/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts @@ -8,6 +8,7 @@ import { isOpenCodeNativeTitle } from '../../shared/agent-detection' import type { AgentStatusEntry, AgentStatusIpcPayload } from '../../shared/agent-status-types' import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import { renewRuntimeMobileAgentStatusFromPtyTitle } from './runtime-mobile-agent-status-projection' +import { getDisplayPromptLifecycle } from './runtime-worktree-status-projection' import type { RuntimeTerminalWriteOptions } from './runtime-terminal-writer' import { getRegisteredSshState } from '../ssh/ssh-target-registry' import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' @@ -50,6 +51,28 @@ export class OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission extends O return newestPermissionAt >= 0 && newestPermissionAt >= newestClearAt ? blockedByWaitText : null } + /** + * The title snapshot and prompt lifecycle the blocked-dialog check compares, both as display + * shows them, so the check reaches main's verdict: main compared the pair a stale-working clear + * left, and comparing a renderer's echoed clear with the native lifecycle hid a later dialog. + */ + protected getTerminalWaitPermissionInputs( + handle: string, + ptyId: string, + waitTextOverride?: string + ): { + terminal: RuntimeTerminalAgentStatusSnapshot + lifecycle: { status: AgentStatus | null; updatedAt: number } | null | undefined + } { + const clear = this.getPtyTitleDisplayClear(ptyId) + const snapshot = this.terminalAgentStatus.getSnapshot(handle, ptyId, clear) + return { + terminal: + waitTextOverride === undefined ? snapshot : { ...snapshot, waitText: waitTextOverride }, + lifecycle: getDisplayPromptLifecycle(this.agentPromptLifecycleByPtyId.get(ptyId), clear) + } + } + /** The pane's main-agent turn from the hook server's store, for tui-idle's hook lane. */ protected readTuiIdleHookTurnForPty(ptyId: string, agent: TuiAgent): TuiIdleHookTurn | null { const pty = this.ptysById.get(ptyId) diff --git a/src/main/runtime/orca-runtime-resolve-exit-waiters.ts b/src/main/runtime/orca-runtime-resolve-exit-waiters.ts index f7ead603e02..2bfce095ff4 100644 --- a/src/main/runtime/orca-runtime-resolve-exit-waiters.ts +++ b/src/main/runtime/orca-runtime-resolve-exit-waiters.ts @@ -11,13 +11,8 @@ import { import type { AgentStatus } from '../../shared/agent-detection' import { detectExplicitIdleStatusFromTitle } from './terminal-wait-detection' import { buildTerminalWaitText } from './terminal-wait-tail-state' -import { - evaluateTuiIdle, - isTuiIdleReadyVerdict, - leafTuiIdleEvidence, - ptyTuiIdleEvidence, - type TuiIdleVerdict -} from './tui-idle-evidence' +import { evaluateTuiIdle, isTuiIdleReadyVerdict, type TuiIdleVerdict } from './tui-idle-evidence' +import { leafTuiIdleEvidence, ptyTuiIdleEvidence } from './tui-idle-evidence-source' import { TUI_IDLE_QUIESCENCE_MS } from './orca-runtime-postlude' export class OrcaRuntimeWithResolveExitWaiters extends OrcaRuntimeWithBindPtyIncarnationHandle { @@ -218,7 +213,7 @@ export class OrcaRuntimeWithResolveExitWaiters extends OrcaRuntimeWithBindPtyInc } protected getAdoptedPtyExplicitIdleStatus(pty: RuntimePtyWorktreeRecord): AgentStatus | null { - const title = this.getAdoptedPtyTitle(pty) + const title = pty.lastOscTitle ?? this.getAdoptedPtyTitle(pty) return title ? detectExplicitIdleStatusFromTitle(title) : null } diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index dbdec24d0fc..5a471276d85 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -47,7 +47,7 @@ import { RuntimeTerminalWriter } from './runtime-terminal-writer' import { RuntimeTerminalIdlePolls } from './runtime-terminal-idle-polls' import { TerminalIntentionalStops } from './terminal-intentional-stops' import { TerminalRunFactsRegister, type TerminalSpawnCommit } from './terminal-run-facts' -import type { TuiIdleEvidenceSource } from './tui-idle-evidence' +import type { TuiIdleEvidenceSource } from './tui-idle-evidence-source' import { hasTerminalCommandPainted } from './terminal-command-paint' import { TUI_IDLE_DEFAULT_TIMEOUT_MS, @@ -350,7 +350,9 @@ export class OrcaRuntimeWithRuntimeId { (ptyId ? this.ptysById.get(ptyId)?.lastExplicitAgentStatus : null) ?? null, getHookTurn: (ptyId, agent) => this.readTuiIdleHookTurnForPty(ptyId, agent), readScreenLines: (ptyId) => this.readLiveTerminalScreenLines(ptyId), - readScreenRuledLines: (ptyId) => this.readScreenRuledLines(ptyId) + readRuledScreen: (ptyId) => this.readRuledScreen(ptyId), + getTitleObservedAtEpochMs: (ptyId) => + (ptyId ? this.ptysById.get(ptyId)?.lastOscTitleEpochMs : null) ?? null } protected readonly terminalIdlePolls = new RuntimeTerminalIdlePolls({ diff --git a/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts b/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts index 99a3a56943d..8bfd39bfa22 100644 --- a/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts +++ b/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts @@ -3,6 +3,7 @@ import { selectFreshExplicitAgentStatus } from './runtime-hook-agent-row-selecti import { OrcaRuntimeWithControllerKnowsPtyIsLive } from './orca-runtime-controller-knows-pty-is-live' import type { RuntimeTerminalAgentStatus } from '../../shared/runtime-types' import type { RuntimeTerminalAgentStatusSnapshot } from './runtime-terminal-agent-status-query' +import { getDisplayPromptLifecycle } from './runtime-worktree-status-projection' import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import { hasCompatibleAgentTitleIdentity } from '../../shared/agent-title-owner' import type { PtyForegroundProcessRead } from './runtime-terminal-contracts' @@ -124,7 +125,8 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi this.resolvePtyTuiIdleWaiters(current, ptyId) } for (const leaf of this.getLeavesForPty(ptyId)) { - if (leaf.lastAgentStatus !== null) { + // Why the clear too: a stale-working clear leaves the native status, not the neutral one. + if (leaf.lastAgentStatus !== null && !current.titleDisplayClear) { continue } // Why: the live agent disproved the neutral title's exit signal; keep runtime delivery state aligned with the restored tracker. @@ -263,7 +265,12 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi const outputSequence = this.getPtyOutputSequence(ptyId) const explicit = this.getFreshExplicitAgentStatusForPty(handle, ptyId) const explicitFloor = this.agentPromptExplicitStatusFloorByPtyId.get(ptyId) - const lifecycle = this.agentPromptLifecycleByPtyId.get(ptyId) + const nativeLifecycle = this.agentPromptLifecycleByPtyId.get(ptyId) + // Why projected: verification compares against main's baseline, which held a stale-working clear. + const lifecycle = getDisplayPromptLifecycle( + nativeLifecycle, + this.getPtyTitleDisplayClear(ptyId) + ) const ptyStatus = lifecycle || explicitFloor === undefined ? (this.ptysById.get(ptyId)?.lastAgentStatus ?? null) @@ -280,8 +287,12 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi () => this.getTerminalAgentStatusSnapshot(handle, ptyId).waitText ) : undefined - const terminal = this.getTerminalAgentStatusSnapshot(handle, ptyId, waitText) - const status = this.hasAuthoritativeTerminalWaitPermission(terminal, explicit, lifecycle) + const waitInputs = this.getTerminalWaitPermissionInputs(handle, ptyId, waitText) + const status = this.hasAuthoritativeTerminalWaitPermission( + waitInputs.terminal, + explicit, + waitInputs.lifecycle + ) ? 'permission' : lifecycleIsNewer ? lifecycle.status @@ -289,7 +300,7 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi return { generation: this.getPtyLifecycleGeneration(ptyId), permissionSequence: this.agentPromptPermissionSequenceByPtyId.get(ptyId) ?? 0, - workingSequence: lifecycle?.workingSequence ?? 0, + workingSequence: nativeLifecycle?.workingSequence ?? 0, explicitWorkingStartedAt: explicit?.status === 'working' ? explicit.stateStartedAt : null, outputSequence, status diff --git a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts index 1e54583435c..4e8087e5a95 100644 --- a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts +++ b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts @@ -52,7 +52,8 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId getPrimaryLeaf: (ptyId) => this.getLeavesForPty(ptyId)[0] ?? null, getTrackedPty: (ptyId) => this.ptysById.get(ptyId) ?? null, getTabTitle: (tabId) => this.tabs.get(tabId)?.title?.trim() || null, - getForegroundProcess: (ptyId) => this.ptyController?.getForegroundProcess(ptyId) ?? null + getForegroundProcess: (ptyId) => this.ptyController?.getForegroundProcess(ptyId) ?? null, + getTitleDisplayClear: (ptyId) => this.getPtyTitleDisplayClear(ptyId) }) protected notifier: RuntimeNotifier | null = null @@ -200,7 +201,8 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId getTabTitle: (tabId) => this.tabs.get(tabId)?.title ?? null, getExplicitStatus: (handle) => this.getFreshExplicitAgentStatusForHandle(handle), getLifecycleStatus: (ptyId) => this.agentPromptLifecycleByPtyId.get(ptyId), - isRunning: (handle) => this.isTerminalRunningAgent(handle) + isRunning: (handle) => this.isTerminalRunningAgent(handle), + getTitleDisplayClear: (ptyId) => this.getPtyTitleDisplayClear(ptyId) }) protected _orchestrationDb: OrchestrationDb | null = null diff --git a/src/main/runtime/orca-runtime-visible-snapshot-preview.ts b/src/main/runtime/orca-runtime-visible-snapshot-preview.ts index 64cda7b73f2..fd53d34f9a3 100644 --- a/src/main/runtime/orca-runtime-visible-snapshot-preview.ts +++ b/src/main/runtime/orca-runtime-visible-snapshot-preview.ts @@ -12,6 +12,7 @@ import { } from './orca-runtime-postlude' import { projectTerminalVisibleLines } from './orca-runtime-terminal-projection' import { visibleNonBlankTerminalLines } from './terminal-tail-read' +import type { RuledScreen } from './screen-input-veto' import { HeadlessEmulator } from '../daemon/headless-emulator' import { withTimeout } from './runtime-async-boundaries' @@ -152,8 +153,8 @@ export class OrcaRuntimeWithVisibleSnapshotPreview extends OrcaRuntimeWithCaptur return state ? projectTerminalVisibleLines(state.emulator).lines : null } - /** The rows a screen-ruled agent's rule reads: as painted, and only on the PTY's own grid. */ - protected readScreenRuledLines(ptyId: string | null | undefined): string[] | null { + /** The grid a screen-ruled agent's rule reads: as painted, and only on the PTY's own size. */ + protected readRuledScreen(ptyId: string | null | undefined): RuledScreen | null { const state = this.readWholeScreenModel(ptyId) if (!ptyId || !state || state.unrepaintedReflowGrid !== undefined) { return null @@ -166,7 +167,10 @@ export class OrcaRuntimeWithVisibleSnapshotPreview extends OrcaRuntimeWithCaptur } // Why raw rows, not the read projection: it blanks a composer it takes for a draft, and Cline's // placeholder reads as one, so a typed draft and an empty composer would look the same. - return visibleNonBlankTerminalLines(state.emulator.getVisibleLines()) + return { + lines: visibleNonBlankTerminalLines(state.emulator.getVisibleLines()), + alternateScreen: state.emulator.isAlternateScreen + } } protected readWholeScreenModel(ptyId: string | null | undefined): RuntimeHeadlessTerminal | null { diff --git a/src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts b/src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts index 538ab4d6d4d..66d6e50335d 100644 --- a/src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts +++ b/src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts @@ -148,7 +148,7 @@ export class OrcaRuntimeWithWriteOrchestrationPointerPty extends OrcaRuntimeWith const tab = this.tabs.get(leaf.tabId) ?? null const pty = leaf.ptyId ? this.ptysById.get(leaf.ptyId) : undefined - const title = getLatestLeafTitle(leaf, tab?.title ?? null) + const title = getLatestLeafTitle(this.getLeafDisplayRecord(leaf), tab?.title ?? null) // Why: leaf.connected mirrors the renderer graph (`ptyId !== null`), so a // restored surface whose PTY died with a prior run still reads connected. // Demote only on a controller-proven absence, and only for locally-scoped diff --git a/src/main/runtime/runtime-mobile-session-projection-contract.ts b/src/main/runtime/runtime-mobile-session-projection-contract.ts index f4174b7715a..d2cb14123f2 100644 --- a/src/main/runtime/runtime-mobile-session-projection-contract.ts +++ b/src/main/runtime/runtime-mobile-session-projection-contract.ts @@ -10,6 +10,7 @@ import type { import type { TabGroupLayoutNode } from '../../shared/tab-types' import type { RuntimeAgentRowSnapshot } from './runtime-worktree-agent-rows' import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' +import type { TitleDisplayClear } from './runtime-worktree-status-projection' export type RuntimeMobileSessionProjectionHost = { tabs: ReadonlyMap @@ -32,6 +33,8 @@ export type RuntimeMobileSessionProjectionHost = { getRows: (paneKey: string, terminalHandle: string | null) => AgentStatusIpcPayload[] ): RuntimeAgentRowSnapshot | null getTrackedTitle(ptyId: string | null): string | null + /** Display-only clear of a PTY's native title; every title/status read here is display. */ + getTitleDisplayClear(ptyId: string | null): TitleDisplayClear | null issuePtyHandle(pty: RuntimePtyWorktreeRecord): string recordPty( ptyId: string, diff --git a/src/main/runtime/runtime-mobile-session-projection.ts b/src/main/runtime/runtime-mobile-session-projection.ts index db1ef0619ca..36e44b58d3b 100644 --- a/src/main/runtime/runtime-mobile-session-projection.ts +++ b/src/main/runtime/runtime-mobile-session-projection.ts @@ -20,6 +20,8 @@ import { finalizeRuntimeMobileSessionTabsResult } from './runtime-mobile-session import type { RuntimeMobileSessionProjectionHost } from './runtime-mobile-session-projection-contract' import { getLatestAgentCandidateTitle, + getLeafDisplayRecord, + getPtyDisplayRecord, terminalTitleBlocksExplicitAgentStatus } from './runtime-worktree-status-projection' @@ -146,16 +148,20 @@ export function projectRuntimeMobileSessionTabs( // null, because persisted ids can collide with an unrelated pane after restart — reading // that pane's tracker would publish its title here, ahead of every other source. const trackerOnlyTitle = host.getTrackedTitle(liveLeafPtyId ?? pty?.ptyId ?? null) - const leafTitle = leaf + const displayLeaf = leaf + ? getLeafDisplayRecord(leaf, host.getTitleDisplayClear(leaf.ptyId)) + : null + const displayPty = pty ? getPtyDisplayRecord(pty, host.getTitleDisplayClear(pty.ptyId)) : null + const leafTitle = displayLeaf ? getLatestAgentCandidateTitle( - { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, - { title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt } + { title: displayLeaf.paneTitle, updatedAt: displayLeaf.paneTitleUpdatedAt }, + { title: displayLeaf.lastOscTitle, updatedAt: displayLeaf.lastOscTitleAt } ) : null - const ptyTitle = pty + const ptyTitle = displayPty ? getLatestAgentCandidateTitle( - { title: pty.title, updatedAt: pty.titleUpdatedAt }, - { title: pty.lastOscTitle, updatedAt: pty.lastOscTitleAt } + { title: displayPty.title, updatedAt: displayPty.titleUpdatedAt }, + { title: displayPty.lastOscTitle, updatedAt: displayPty.lastOscTitleAt } ) : null // Renderer omission is authoritative: PTY launch provenance outlives agent exit. @@ -198,7 +204,10 @@ export function projectRuntimeMobileSessionTabs( (hookAgentStatus.providerSessionReceivedAt ?? -1) >= tab.agentStatus.updatedAt) ? hookAgentStatus.providerSession : tab.agentStatus?.providerSession - const statusPty = liveLeafPty ?? mobileStatusPty + const nativeStatusPty = liveLeafPty ?? mobileStatusPty + const statusPty = nativeStatusPty + ? getPtyDisplayRecord(nativeStatusPty, host.getTitleDisplayClear(nativeStatusPty.ptyId)) + : null const normalizedTabAgentStatus = renewRuntimeMobileAgentStatusFromPtyTitle( tab.agentStatus ? normalizeCompatibleAgentStatusEntryForOwner( diff --git a/src/main/runtime/runtime-terminal-agent-presence.ts b/src/main/runtime/runtime-terminal-agent-presence.ts index aad71c1a08a..119ce1d8f04 100644 --- a/src/main/runtime/runtime-terminal-agent-presence.ts +++ b/src/main/runtime/runtime-terminal-agent-presence.ts @@ -13,7 +13,10 @@ import { classifyLatestAgentTitle, getLatestAgentCandidateTitle, getLatestLeafTitle, - ptyTitleProvesAgentPresence + getLeafDisplayRecord, + getPtyDisplayRecord, + ptyTitleProvesAgentPresence, + type TitleDisplayClear } from './runtime-worktree-status-projection' const WRAPPER_RETRY_INTERVAL_MS = 150 @@ -28,6 +31,8 @@ type RuntimeTerminalAgentPresenceDependencies = { getTrackedPty(ptyId: string): RuntimePtyWorktreeRecord | null getTabTitle(tabId: string): string | null getForegroundProcess(ptyId: string): Promise | null + /** The stale-working timer's display-only clear of the PTY's own title, if one stands. */ + getTitleDisplayClear(ptyId: string): TitleDisplayClear | null } export type RuntimeTerminalAgentPresenceOptions = { @@ -51,11 +56,24 @@ export class RuntimeTerminalAgentPresence { return true } try { + // Why display records: presence reads what the pane shows, as before the stale-working + // clear stopped rewriting records. A cwd spinner clears to a neutral title, so the + // foreground process decides for an agent that exited behind it. const pty = this.deps.getLivePty(handle) if (pty) { - return await this.isPtyRunning(pty, this.deps.getPrimaryLeaf(pty.ptyId), options) + const clear = this.deps.getTitleDisplayClear(pty.ptyId) + const leaf = this.deps.getPrimaryLeaf(pty.ptyId) + return await this.isPtyRunning( + getPtyDisplayRecord(pty, clear), + leaf ? getLeafDisplayRecord(leaf, clear) : null, + options + ) } - const leaf = this.deps.getLiveLeaf(handle) + const liveLeaf = this.deps.getLiveLeaf(handle) + const leaf = getLeafDisplayRecord( + liveLeaf, + liveLeaf.ptyId ? this.deps.getTitleDisplayClear(liveLeaf.ptyId) : null + ) const trackedPty = leaf.ptyId ? this.deps.getTrackedPty(leaf.ptyId) : null const paneTitle = getLatestLeafTitle(leaf, null) const paneClassification = classifyAgentTitle(paneTitle) diff --git a/src/main/runtime/runtime-terminal-agent-status-query.ts b/src/main/runtime/runtime-terminal-agent-status-query.ts index 5a06ea3f688..cc0c4cf1a62 100644 --- a/src/main/runtime/runtime-terminal-agent-status-query.ts +++ b/src/main/runtime/runtime-terminal-agent-status-query.ts @@ -11,7 +11,11 @@ import type { RuntimePtyController } from './runtime-pty-controller-contract' import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import { terminalTitleBlocksExplicitAgentStatus, - getLatestAgentCandidateTitleInfo + getDisplayPromptLifecycle, + getLatestAgentCandidateTitleInfo, + getLeafDisplayRecord, + getPtyDisplayRecord, + type TitleDisplayClear } from './runtime-worktree-status-projection' import { detectTerminalWaitBlockedReason } from './terminal-wait-detection' import { getTerminalState } from './terminal-wait-results' @@ -38,6 +42,7 @@ type Dependencies = { ptyId: string ): { status: AgentStatus | null; updatedAt: number } | null | undefined isRunning(handle: string): Promise + getTitleDisplayClear(ptyId: string): TitleDisplayClear | null } export class RuntimeTerminalAgentStatusQuery { @@ -63,9 +68,13 @@ export class RuntimeTerminalAgentStatusQuery { private async readStatus(handle: string): Promise { const ptyId = this.getPtyId(handle) - const terminal = this.getSnapshot(handle, ptyId) + // Why display: whether an agent is here is read off what the pane shows, as before the + // stale-working clear stopped rewriting records; a cwd spinner clears to a neutral title, + // so the foreground process decides for an agent that exited behind it. + const clear = this.deps.getTitleDisplayClear(ptyId) + const terminal = this.getSnapshot(handle, ptyId, clear) const explicitStatus = this.deps.getExplicitStatus(handle) - const lifecycle = this.deps.getLifecycleStatus(ptyId) + const lifecycle = getDisplayPromptLifecycle(this.deps.getLifecycleStatus(ptyId), clear) const blockedByWaitText = detectTerminalWaitBlockedReason(terminal.waitText) const liveTitleClearsBlockedText = terminal.titleStatusIsLive && @@ -156,22 +165,20 @@ export class RuntimeTerminalAgentStatusQuery { throw new Error('terminal_handle_stale') } + /** `displayClear` projects the snapshot as display shows it; evidence callers omit it. */ getSnapshot( handle: string, - expectedPtyId: string - ): { - waitText: string - waitBlockedAt: number | null - title: string | null - titleStatus: AgentStatus | null - titleStatusIsLive: boolean - } { - const pty = this.deps.getLivePty(handle) - if (pty) { - if (!pty.pty.connected || pty.pty.ptyId !== expectedPtyId) { + expectedPtyId: string, + displayClear: TitleDisplayClear | null = null + ): RuntimeTerminalAgentStatusSnapshot { + const live = this.deps.getLivePty(handle) + if (live) { + if (!live.pty.connected || live.pty.ptyId !== expectedPtyId) { throw new Error('terminal_not_writable') } - const leaf = this.deps.getPrimaryLeaf(pty.pty.ptyId) + const pty = { pty: getPtyDisplayRecord(live.pty, displayClear) } + const primaryLeaf = this.deps.getPrimaryLeaf(live.pty.ptyId) + const leaf = primaryLeaf ? getLeafDisplayRecord(primaryLeaf, displayClear) : null const leafTitle = leaf ? getLatestAgentCandidateTitleInfo( { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, @@ -200,7 +207,7 @@ export class RuntimeTerminalAgentStatusQuery { } } - const { leaf } = this.deps.getLiveLeaf(handle) + const leaf = getLeafDisplayRecord(this.deps.getLiveLeaf(handle).leaf, displayClear) if (getTerminalState(leaf) !== 'running') { throw new Error('terminal_exited') } diff --git a/src/main/runtime/runtime-terminal-idle-polls.ts b/src/main/runtime/runtime-terminal-idle-polls.ts index d63b59974ee..c022c3ee661 100644 --- a/src/main/runtime/runtime-terminal-idle-polls.ts +++ b/src/main/runtime/runtime-terminal-idle-polls.ts @@ -11,14 +11,12 @@ import { buildTerminalWaitResult } from './terminal-wait-results' import { buildTerminalWaitText } from './terminal-wait-tail-state' +import { evaluateTuiIdle, type QuietForegroundLane, type TuiIdleVerdict } from './tui-idle-evidence' import { - evaluateTuiIdle, leafTuiIdleEvidence, ptyTuiIdleEvidence, - type QuietForegroundLane, - type TuiIdleEvidenceSource, - type TuiIdleVerdict -} from './tui-idle-evidence' + type TuiIdleEvidenceSource +} from './tui-idle-evidence-source' /** * Why null counts as quiet on an `open` lane: a record with no output timestamp has produced diff --git a/src/main/runtime/runtime-terminal-orphan-adoption.ts b/src/main/runtime/runtime-terminal-orphan-adoption.ts index 51e56d0b508..610cc7ae6dd 100644 --- a/src/main/runtime/runtime-terminal-orphan-adoption.ts +++ b/src/main/runtime/runtime-terminal-orphan-adoption.ts @@ -20,6 +20,8 @@ type RuntimeTerminalOrphanAdoptionPorts = { getPty: (handle: string) => RuntimePtyWorktreeRecord | null getLeaves: (ptyId: string) => readonly RuntimeLeafRecord[] getLeaf: (tabId: string, leafId: string) => RuntimeLeafRecord | undefined + /** The title display surfaces show for the PTY, which an adopted tab persists. */ + getDisplayTitle: (pty: RuntimePtyWorktreeRecord) => string | null /** Replays a binding the session already held: names the pane without claiming the graph holds it. */ replayPersistedSurface: (pty: RuntimePtyWorktreeRecord, tabId: string, paneKey: string) => void /** Names a pane this adoption just wrote, ahead of the graph statement that will carry it. */ @@ -225,7 +227,8 @@ export async function adoptRuntimeTerminalOrphansFromInventory(args: { request, validated, topologyTabsById, - topologyGroups + topologyGroups, + getDisplayTitle: ports.getDisplayTitle }) let staged: WorkspaceSessionState | null = null try { diff --git a/src/main/runtime/runtime-terminal-orphan-session-adoption.ts b/src/main/runtime/runtime-terminal-orphan-session-adoption.ts index e9b2e160d50..106799cf7e4 100644 --- a/src/main/runtime/runtime-terminal-orphan-session-adoption.ts +++ b/src/main/runtime/runtime-terminal-orphan-session-adoption.ts @@ -3,7 +3,6 @@ import type { RuntimeTerminalOrphanAdoptionRequest } from '../../shared/runtime- import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { collectPersistedTerminalLeafIds } from './mobile-session-layout-projection' import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' -import { getLatestPtyTitle } from './runtime-worktree-status-projection' import { mergeTerminalOrphanGroupLayout } from './terminal-orphan-topology' import { canonicalizeTerminalSessionWorktreeId } from './workspace-session-worktree-id' import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority' @@ -19,6 +18,7 @@ export function buildRuntimeTerminalOrphanSession(args: { validated: readonly { claim: Claim; pty: RuntimePtyWorktreeRecord; paneKey: string }[] topologyTabsById: ReadonlyMap topologyGroups: Topology['groups'] + getDisplayTitle: (pty: RuntimePtyWorktreeRecord) => string | null }): WorkspaceSessionState { const { session, @@ -27,7 +27,8 @@ export function buildRuntimeTerminalOrphanSession(args: { request, validated, topologyTabsById, - topologyGroups + topologyGroups, + getDisplayTitle } = args const next = structuredClone(session) canonicalizeTerminalSessionWorktreeId(next, sessionWorktreeId, worktreeId) @@ -36,7 +37,7 @@ export function buildRuntimeTerminalOrphanSession(args: { for (const { claim, pty, paneKey } of validated) { let tab = tabsById.get(claim.tabId) if (!tab) { - const title = getLatestPtyTitle(pty) ?? pty.controllerTitle ?? `Terminal ${tabsById.size + 1}` + const title = getDisplayTitle(pty) ?? pty.controllerTitle ?? `Terminal ${tabsById.size + 1}` tab = { id: claim.tabId, ptyId: claim.ptyId, diff --git a/src/main/runtime/runtime-terminal-state-records.ts b/src/main/runtime/runtime-terminal-state-records.ts index d3fd9a0d7b3..93e28cf7518 100644 --- a/src/main/runtime/runtime-terminal-state-records.ts +++ b/src/main/runtime/runtime-terminal-state-records.ts @@ -87,6 +87,10 @@ export type RuntimePtyWorktreeRecord = RuntimeTerminalTailState & { lastOscTitle: string | null lastOscTitleAt: number | null lastOscTitleEpochMs: number | null + /** The stale-working timer's cleared title, dated as a genuine title would be, while it stands + * over `lastOscTitle`. Display readers project through it (getPtyDisplayRecord); evidence never + * reads it. On the record so it lives as long as the native title it retires. In memory only. */ + titleDisplayClear?: { title: string; observedAt: number; observedAtEpochMs: number } | null managementTitle: string | null managementTitleAt: number | null controllerTitle: string | null diff --git a/src/main/runtime/runtime-terminal-wait.ts b/src/main/runtime/runtime-terminal-wait.ts index 9087d8ad7db..21f56b7be32 100644 --- a/src/main/runtime/runtime-terminal-wait.ts +++ b/src/main/runtime/runtime-terminal-wait.ts @@ -12,13 +12,12 @@ import { import { readsTrustedScreen } from './agent-state-rules/agent-state-rules-engine' import { showsScreenProbeBanner } from './agent-state-rules/agent-state-text-anchors' import { buildTerminalWaitText } from './terminal-wait-tail-state' +import { evaluateTuiIdle, type TuiIdleVerdict } from './tui-idle-evidence' import { - evaluateTuiIdle, leafTuiIdleEvidence, ptyTuiIdleEvidence, - type TuiIdleEvidenceSource, - type TuiIdleVerdict -} from './tui-idle-evidence' + type TuiIdleEvidenceSource +} from './tui-idle-evidence-source' import type { TuiAgent } from '../../shared/tui-agent' import type { TerminalWaiter } from './runtime-terminal-contracts' import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' diff --git a/src/main/runtime/runtime-worktree-ps-activity.ts b/src/main/runtime/runtime-worktree-ps-activity.ts index ae3ecdee4c3..d8d0b6a0109 100644 --- a/src/main/runtime/runtime-worktree-ps-activity.ts +++ b/src/main/runtime/runtime-worktree-ps-activity.ts @@ -8,10 +8,13 @@ import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-term import type { RuntimeWorktreeSummaryPathIndex } from './runtime-worktree-summary-paths' import { getLatestPtyTitle, + getLeafDisplayRecord, getLeafWorktreeStatus, + getPtyDisplayRecord, getSavedTabWorktreeStatus, maxTimestamp, - mergeWorktreeSummaryStatus + mergeWorktreeSummaryStatus, + type TitleDisplayClear } from './runtime-worktree-status-projection' import { runtimeWorktreeIdsEqual } from './runtime-worktree-path-identity' @@ -38,6 +41,7 @@ export function applyRuntimeWorktreePsTerminalActivity(args: { tabs: ReadonlyMap session: WorkspaceSessionState | null | undefined getPaneKey: (leaf: RuntimeLeafRecord) => string + getTitleDisplayClear: (ptyId: string) => TitleDisplayClear | null getSummary: SummaryLookup }): Map { const workingEvidence = new Map() @@ -87,7 +91,10 @@ export function applyRuntimeWorktreePsTerminalActivity(args: { summary.liveTerminalCount += 1 summary.hasAttachedPty = true summary.lastOutputAt = maxTimestamp(summary.lastOutputAt, leaf.lastOutputAt) - const leafStatus = getLeafWorktreeStatus(leaf, args.tabs.get(leaf.tabId)?.title ?? null) + const leafStatus = getLeafWorktreeStatus( + getLeafDisplayRecord(leaf, args.getTitleDisplayClear(leaf.ptyId)), + args.tabs.get(leaf.tabId)?.title ?? null + ) if (leafStatus === 'working') { addWorkingTerminalEvidence(workingEvidence, summary.worktreeId, { paneKey: args.getPaneKey(leaf), @@ -112,12 +119,15 @@ export function applyRuntimeWorktreePsTerminalActivity(args: { continue } const persistedTabId = savedLayoutTabIdByPtyId.get(pty.ptyId) + const displayTitle = getLatestPtyTitle( + getPtyDisplayRecord(pty, args.getTitleDisplayClear(pty.ptyId)) + ) let owner = persistedTabId ? savedTabOwnerById.get(persistedTabId) : undefined if (args.freshPtyLiveness !== null) { - owner = { worktreeId: pty.worktreeId, title: owner?.title ?? getLatestPtyTitle(pty) ?? '' } + owner = { worktreeId: pty.worktreeId, title: owner?.title ?? displayTitle ?? '' } } if (!owner && persistedTabId && pty.tabId === persistedTabId) { - owner = { worktreeId: pty.worktreeId, title: getLatestPtyTitle(pty) ?? '' } + owner = { worktreeId: pty.worktreeId, title: displayTitle ?? '' } } const pane = parsePaneKey(pty.paneKey ?? '') const hasExplicitOwner = @@ -128,7 +138,7 @@ export function applyRuntimeWorktreePsTerminalActivity(args: { if (!owner && hasExplicitOwner && !hasSavedLayout) { owner = { worktreeId: savedOwner?.worktreeId ?? pty.worktreeId, - title: savedOwner?.title ?? getLatestPtyTitle(pty) ?? '' + title: savedOwner?.title ?? displayTitle ?? '' } } if (!owner) { diff --git a/src/main/runtime/runtime-worktree-status-projection.ts b/src/main/runtime/runtime-worktree-status-projection.ts index 7e8244a8836..fce985b3d94 100644 --- a/src/main/runtime/runtime-worktree-status-projection.ts +++ b/src/main/runtime/runtime-worktree-status-projection.ts @@ -39,6 +39,8 @@ type PtyTitleRecord = { lastOscTitleAt: number | null } +type TitleCandidate = { title: string | null | undefined; updatedAt: number | null | undefined } + type PtyAgentPresenceRecord = { launchAgent: TuiAgent | null launchToken: string | null @@ -46,6 +48,78 @@ type PtyAgentPresenceRecord = { incarnationId: PtyIncarnationId | null } +/** The stale-working timer's display-only clear of a PTY's native title, while it stands. */ +export type TitleDisplayClear = { + title: string + status: AgentStatus | null + /** Dated as a genuine title would be: observation sequence and wall clock. */ + observedAt: number + observedAtEpochMs: number +} + +type PtyTitleEvidence = PtyTitleRecord & { + lastOscTitleEpochMs: number | null + lastAgentStatus: AgentStatus | null + lastAgentStatusStartedAtEpochMs: number | null +} + +/** + * A PTY record as display surfaces show it. Records keep the agent's own title for readiness, + * delivery and presence; display readers project through here so they show the cleared title + * the tab shows. Why max(): a restore seed re-stamps the native title after the clear. + */ +export function getPtyDisplayRecord( + pty: T, + clear: TitleDisplayClear | null +): T { + if (!clear) { + return pty + } + return { + ...pty, + lastOscTitle: clear.title, + lastOscTitleAt: Math.max(pty.lastOscTitleAt ?? 0, clear.observedAt), + lastOscTitleEpochMs: clear.observedAtEpochMs, + lastAgentStatus: clear.status, + lastAgentStatusStartedAtEpochMs: + clear.status === pty.lastAgentStatus + ? pty.lastAgentStatusStartedAtEpochMs + : clear.observedAtEpochMs + } +} + +/** The leaf counterpart of {@link getPtyDisplayRecord}; `clear` is its PTY's. */ +export function getLeafDisplayRecord( + leaf: T, + clear: TitleDisplayClear | null +): T { + if (!clear) { + return leaf + } + return { + ...leaf, + lastOscTitle: clear.title, + lastOscTitleAt: Math.max(leaf.lastOscTitleAt ?? 0, clear.observedAt), + lastAgentStatus: clear.status + } +} + +/** + * The prompt lifecycle as display shows it. Main recorded the clear's status there as well, so a + * title snapshot projected through the clear is only comparable with a lifecycle projected the + * same way. Display, blocked-dialog checks and prompt-submission verification read it; readiness + * and delivery never do. + */ +export function getDisplayPromptLifecycle( + lifecycle: { status: AgentStatus | null; updatedAt: number } | null | undefined, + clear: TitleDisplayClear | null +): { status: AgentStatus | null; updatedAt: number } | null | undefined { + if (!clear || (lifecycle && lifecycle.updatedAt > clear.observedAtEpochMs)) { + return lifecycle + } + return { status: clear.status, updatedAt: clear.observedAtEpochMs } +} + export function getLeafWorktreeStatus( leaf: LeafStatusRecord, tabTitle: string | null @@ -62,7 +136,7 @@ export function getLeafWorktreeStatus( } export function classifyLatestAgentTitle( - ...titles: { title: string | null | undefined; updatedAt: number | null | undefined }[] + ...titles: TitleCandidate[] ): 'agent' | 'management' | 'neutral' { return classifyAgentTitle(getLatestAgentCandidateTitle(...titles)) } @@ -132,24 +206,27 @@ export function terminalTitleBlocksExplicitAgentStatus(title: string | null): bo return isClaudeManagementTitle(title) || isShellProcess(title) } -export function getLatestAgentCandidateTitle( - ...titles: { title: string | null | undefined; updatedAt: number | null | undefined }[] -): string | null { +export function getLatestAgentCandidateTitle(...titles: TitleCandidate[]): string | null { return getLatestAgentCandidateTitleInfo(...titles)?.title ?? null } export function getLatestAgentCandidateTitleInfo( - ...titles: { title: string | null | undefined; updatedAt: number | null | undefined }[] + ...titles: TitleCandidate[] ): { title: string; updatedAt: number } | null { - let latest: { title: string; updatedAt: number } | null = null - for (const candidate of titles) { - const title = candidate.title?.trim() - if (!title) { + const latest = getLatestAgentCandidate(...titles) + const title = latest?.title?.trim() + return latest && title ? { title, updatedAt: latest.updatedAt ?? 0 } : null +} + +/** The newest non-blank candidate itself, so a caller can tell which source won. */ +export function getLatestAgentCandidate(...candidates: T[]): T | null { + let latest: T | null = null + for (const candidate of candidates) { + if (!candidate.title?.trim()) { continue } - const updatedAt = candidate.updatedAt ?? 0 - if (!latest || updatedAt > latest.updatedAt) { - latest = { title, updatedAt } + if (!latest || (candidate.updatedAt ?? 0) > (latest.updatedAt ?? 0)) { + latest = candidate } } return latest diff --git a/src/main/runtime/screen-input-veto.ts b/src/main/runtime/screen-input-veto.ts new file mode 100644 index 00000000000..040d8ec255c --- /dev/null +++ b/src/main/runtime/screen-input-veto.ts @@ -0,0 +1,23 @@ +import type { TuiAgent } from '../../shared/tui-agent' +import { isOmpOverlayScreen } from './omp-terminal-readiness' + +/** An agent's grid as painted on its own PTY, and whether that is the alternate screen. */ +export type RuledScreen = { lines: readonly string[]; alternateScreen: boolean } + +/** + * Agents whose screen can refuse input whatever the other evidence says. OMP paints its idle + * title before its setup wizard opens, so a title, hook or quiet lane alone would type into it. + */ +const SCREEN_INPUT_VETOES: Partial boolean>> = { + omp: isOmpOverlayScreen +} + +/** Whether the agent's live screen refuses input; null with no veto rule or no trustworthy screen. */ +export function readScreenInputVeto( + agent: TuiAgent | null | undefined, + readScreen: () => RuledScreen | null +): boolean | null { + const veto = agent ? SCREEN_INPUT_VETOES[agent] : undefined + const screen = veto ? readScreen() : null + return veto && screen ? veto(screen) : null +} diff --git a/src/main/runtime/stale-title-completion-recovery.test.ts b/src/main/runtime/stale-title-completion-recovery.test.ts new file mode 100644 index 00000000000..517d032e350 --- /dev/null +++ b/src/main/runtime/stale-title-completion-recovery.test.ts @@ -0,0 +1,70 @@ +import { rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' +import { settledWriteStub } from '../providers/settled-pty-write-stub' +import { + createBoundRun, + createDatabase, + createRuntime, + insertDirectRunMessage, + PANE_KEY, + PTY_ID, + TERMINAL_HANDLE, + temporaryDirectories +} from './orchestration-mailbox-notification-test-harness' + +vi.mock('electron', () => ({ + app: { getPath: vi.fn(() => tmpdir()), isPackaged: false }, + BrowserWindow: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + webContents: { fromId: vi.fn(() => null) } +})) + +describe('stale-working title clear and completion recovery', () => { + afterEach(() => { + vi.useRealTimers() + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + }) + + // Synthetic: Codex's done hook, then a final native spinner that only the timer clears (#19243). + it('delivers mail once the clear confirms a live Codex behind a fresh done hook', async () => { + vi.useFakeTimers() + const db = createDatabase('orca-stale-title-completion-') + const hook: AgentStatusIpcPayload = { + paneKey: PANE_KEY, + terminalHandle: TERMINAL_HANDLE, + agentType: 'codex', + state: 'done', + prompt: '', + connectionId: null, + receivedAt: Date.now(), + stateStartedAt: Date.now() + } + const { runtime } = createRuntime(db, { getAgentStatusSnapshot: () => [hook] }) + const write = vi.fn((_ptyId: string, _data: string) => true) + runtime.setPtyController({ + write, + writeWithSettlement: settledWriteStub(write), + kill: vi.fn(), + getForegroundProcess: vi.fn(async () => 'codex') + }) + const run = createBoundRun(db, 'Timer Run') + await runtime.listTerminals() + runtime.ingestSyntheticTitleFrame(PTY_ID, '\x1b]0;Codex ready\x07') + runtime.onPtyData(PTY_ID, '\x1b]0;⠋ mobile-rearch\x07', 1) + runtime.onPtyData(PTY_ID, 'composer repaint without a title', 2) + insertDirectRunMessage(db, run.id, 'Worker progress') + runtime.notifyMessageArrived(`run:${run.id}`, 'status') + await vi.advanceTimersByTimeAsync(1_000) + expect(write).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(4_000) + expect(write.mock.calls.map(([, data]) => data)).toEqual([ + expect.stringContaining('You have 1 orchestration message'), + '\r' + ]) + db.close() + }) +}) diff --git a/src/main/runtime/stale-title-display-clear.test.ts b/src/main/runtime/stale-title-display-clear.test.ts new file mode 100644 index 00000000000..3a5c169573c --- /dev/null +++ b/src/main/runtime/stale-title-display-clear.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it, vi } from 'vitest' +import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' +import { clearWorkingIndicators } from '../../shared/agent-title-status' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +// Synthetic protocol cases: they test which title readiness, display and presence each read, +// not what a particular CLI paints. The 3 s timer is the stale-working-title clear. +describe('the stale-working title clear is display-only', () => { + it.each([ + ['claude', '. claude', '* claude'], + ['codex', '⠋ Codex working', 'Codex ready'], + ['gemini', '✦ Gemini CLI', '◇ Gemini CLI'], + ['opencode', '⠋ OC | task', 'OC | task'], + ['pi', 'π : project', 'π - project'] + ] as const)( + '%s does not become ready when its display title clears', + async (agent, busy, idle) => { + vi.useFakeTimers() + try { + const titleChanges: string[] = [] + const { runtime, handle } = await createTranscriptPane( + { + paneTitle: busy, + foregroundProcess: agent, + data: '', + launchAgent: agent + }, + { + onTerminalSideEffects: (batch) => { + for (const fact of batch.facts) { + if (fact.kind === 'title') { + titleChanges.push(fact.normalizedTitle) + } + } + } + } + ) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, `\x1b]0;${busy}\x07`, Date.now()) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, 'still running\r\n', Date.now()) + await vi.advanceTimersByTimeAsync(3_000) + expect(titleChanges.at(-1)).toBe(clearWorkingIndicators(busy)) + // A renderer graph publish can carry the display-only cleared title back to main. + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'wt-1', + title: clearWorkingIndicators(busy), + activeLeafId: '11111111-1111-4111-8111-111111111111', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'wt-1', + leafId: '11111111-1111-4111-8111-111111111111', + paneRuntimeId: 1, + ptyId: TRANSCRIPT_PANE_PTY_ID, + paneTitle: clearWorkingIndicators(busy) + } + ] + }) + const waiting = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 200 }) + const assertion = expect(waiting).rejects.toThrow('timeout') + await Promise.all([assertion, vi.advanceTimersByTimeAsync(200)]) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, `\x1b]0;${idle}\x07`, Date.now()) + const ready = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_000 }) + const readyAssertion = expect(ready).resolves.toMatchObject({ satisfied: true }) + await Promise.all([readyAssertion, vi.advanceTimersByTimeAsync(2_000)]) + } finally { + vi.useRealTimers() + } + } + ) + + // A name-shaped title (`⠋ Codex working`) clears to `Codex`, which still names an agent, as on main. + it.each([['⠋ repo']])( + 'an agent that exited behind %s does not pass for a running agent', + async (title) => { + vi.useFakeTimers() + try { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'zsh', + data: '' + }) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, `\x1b]0;${title}\x07`, Date.now()) + // The agent exits and the shell prints its prompt: output, but no title. + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]133;D;0\x07\x1b]133;A\x07% ', Date.now()) + await vi.advanceTimersByTimeAsync(3_000) + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: false, + status: null + }) + await expect( + runtime.isTerminalRunningAgent(handle, { retryForegroundWrappers: false }) + ).resolves.toBe(false) + } finally { + vi.useRealTimers() + } + } + ) + + it('a live agent behind a cleared title is still found by its process', async () => { + vi.useFakeTimers() + try { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'codex', + data: '' + }) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;⠋ repo\x07', Date.now()) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, 'still running\r\n', Date.now()) + await vi.advanceTimersByTimeAsync(3_000) + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: null + }) + await expect( + runtime.isTerminalRunningAgent(handle, { retryForegroundWrappers: false }) + ).resolves.toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it("the agent's next genuine title proves presence again", async () => { + vi.useFakeTimers() + try { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: null, + data: '' + }) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;⠋ repo\x07', Date.now()) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, 'still running\r\n', Date.now()) + await vi.advanceTimersByTimeAsync(3_000) + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: false + }) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;⠙ repo\x07', Date.now()) + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: 'working' + }) + } finally { + vi.useRealTimers() + } + }) + + it('a live Cursor spinner title proves presence when no foreground read can answer', async () => { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: null, + data: '' + }) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;⠋ Cursor Agent\x07', Date.now()) + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: 'working' + }) + }) +}) diff --git a/src/main/runtime/stale-title-display-surfaces.test.ts b/src/main/runtime/stale-title-display-surfaces.test.ts new file mode 100644 index 00000000000..668f0efe35d --- /dev/null +++ b/src/main/runtime/stale-title-display-surfaces.test.ts @@ -0,0 +1,376 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime-test-mocks.spec' +import './orca-runtime-test-lifecycle.spec' +import { assertTerminalAgentSendable } from './rpc/terminal-agent-send-guard' +import { + HEADLESS_LEAF_ID, + TEST_WORKTREE_ID, + store, + syncSinglePty +} from './orca-runtime-test-fixtures.spec' + +async function psStatus(runtime: OrcaRuntimeService): Promise { + return (await runtime.getWorktreePs()).worktrees.find((w) => w.worktreeId === TEST_WORKTREE_ID) + ?.status +} + +// Synthetic titles: display surfaces must show what the tab shows once the stale-working timer +// clears a title, although the runtime keeps the agent's own title as evidence. +describe('display surfaces after the stale-working title clear', () => { + it('worktree ps and the phone show the cleared status for a terminal with no renderer pane', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'bg-pty' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'renderer-empty', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) + const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + command: 'codex', + tabId: 'bg-tab', + leafId: HEADLESS_LEAF_ID + }) + const psStatus = async () => + (await runtime.getWorktreePs()).worktrees.find((w) => w.worktreeId === TEST_WORKTREE_ID) + ?.status + const phoneTabs = async () => + (await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs.map((tab) => ({ + title: 'title' in tab ? tab.title : undefined, + state: 'agentStatus' in tab ? tab.agentStatus?.state : undefined + })) + runtime.onPtyData('bg-pty', '\x1b]0;Codex working\x07', Date.now()) + runtime.onPtyData('bg-pty', 'output without a title\r\n', Date.now()) + expect(await psStatus()).toBe('working') + expect(await phoneTabs()).toEqual([{ title: 'Codex working', state: 'working' }]) + + await vi.advanceTimersByTimeAsync(3_000) + + expect(await psStatus()).toBe('active') + expect(await phoneTabs()).toEqual([{ title: 'Codex', state: 'done' }]) + const listed = (await runtime.listTerminals()).terminals.find( + (candidate) => candidate.handle === terminal.handle + ) + expect(listed?.title).toBe('Codex') + + // The agent's next genuine title retires the clear. + runtime.onPtyData('bg-pty', '\x1b]0;Codex working\x07', Date.now()) + expect(await psStatus()).toBe('working') + expect(await phoneTabs()).toEqual([{ title: 'Codex working', state: 'working' }]) + } finally { + vi.useRealTimers() + } + }) + + it('a reattach restore seed does not bring the cleared working status back', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'codex' + }) + syncSinglePty(runtime, 'pty-1', { paneTitle: 'Codex working' }) + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', Date.now()) + runtime.onPtyData('pty-1', 'output without a title\r\n', Date.now()) + await vi.advanceTimersByTimeAsync(3_000) + // The renderer applies the cleared fact and republishes its pane title. + syncSinglePty(runtime, 'pty-1', { paneTitle: 'Codex' }) + const psStatus = async () => + (await runtime.getWorktreePs()).worktrees.find((w) => w.worktreeId === TEST_WORKTREE_ID) + ?.status + expect(await psStatus()).toBe('active') + // A renderer reload reattaches with the daemon's last title, as the spawn RPC path does. + runtime.seedTerminalRestoreTail('pty-1', { lastTitle: 'Codex working' }) + syncSinglePty(runtime, 'pty-1', { paneTitle: 'Codex' }) + expect(await psStatus()).toBe('active') + } finally { + vi.useRealTimers() + } + }) + + it('an SSH relay drop and same-incarnation reattach keep the cleared display', async () => { + vi.useFakeTimers() + try { + const sshPtyId = 'ssh:conn-1@@relay-9' + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + const register = () => + runtime.registerPty(sshPtyId, TEST_WORKTREE_ID, 'conn-1', { + tabId: 'tab-1', + leafId: HEADLESS_LEAF_ID, + incarnationId: 'inc-1' + }) + register() + runtime.onPtyData(sshPtyId, '\x1b]0;Codex working\x07', Date.now()) + runtime.onPtyData(sshPtyId, 'output without a title\r\n', Date.now()) + await vi.advanceTimersByTimeAsync(3_000) + expect(await psStatus(runtime)).toBe('active') + // An abnormal relay exit keeps the record for the reconnect grace; the relay reattaches. + runtime.onPtyExit(sshPtyId, -1) + register() + await vi.advanceTimersByTimeAsync(60_000) + expect(await psStatus(runtime)).toBe('active') + expect((await runtime.listTerminals()).terminals.map((t) => t.title)).toEqual(['Codex']) + } finally { + vi.useRealTimers() + } + }) + + it.each(['⠋ Codex working', '⠋ repo'])( + 'an agent that exits behind %s loses its foreground identity', + async (spinner) => { + vi.useFakeTimers() + try { + let foreground: string | null = 'codex' + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => foreground + }) + syncSinglePty(runtime, 'pty-1', { paneTitle: null, tabTitle: 'Terminal' }) + runtime.onPtyData('pty-1', '\x1b]0;Codex ready\x07', Date.now()) + runtime.onPtyData('pty-1', `\x1b]0;${spinner}\x07`, Date.now()) + await vi.advanceTimersByTimeAsync(100) + const identity = async () => + (await runtime.listTerminals()).terminals[0]?.agentIdentity ?? null + expect(await identity()).not.toBeNull() + foreground = 'zsh' + runtime.onPtyData('pty-1', '\x1b]133;D;0\x07\x1b]133;A\x07% ', Date.now()) + await vi.advanceTimersByTimeAsync(3_500) + expect(await identity()).toBeNull() + } finally { + vi.useRealTimers() + } + } + ) + + it('a renderer pane title older than the clear cannot prove presence', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'zsh' + }) + syncSinglePty(runtime, 'pty-1', { paneTitle: null }) + runtime.onPtyData('pty-1', '\x1b]0;⠋ repo\x07', Date.now()) + // The renderer republishes the spinner after main recorded it, and has not echoed the clear. + syncSinglePty(runtime, 'pty-1', { paneTitle: '⠋ repo' }) + runtime.onPtyData('pty-1', '\x1b]133;D;0\x07\x1b]133;A\x07% ', Date.now()) + await vi.advanceTimersByTimeAsync(3_000) + const handle = (await runtime.listTerminals()).terminals[0]?.handle ?? '' + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: false, + status: null + }) + await expect( + runtime.isTerminalRunningAgent(handle, { retryForegroundWrappers: false }) + ).resolves.toBe(false) + } finally { + vi.useRealTimers() + } + }) + + it('a live agent with an unrecognized process keeps presence through its cleared name title', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'bg-pty' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'Python' + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'bg-tab', + leafId: HEADLESS_LEAF_ID + }) + runtime.onPtyData('bg-pty', '\x1b]0;⠋ Claude Code\x07', Date.now()) + runtime.onPtyData('bg-pty', 'output without a title\r\n', Date.now()) + await vi.advanceTimersByTimeAsync(3_000) + // Main read the cleared `Claude Code`, which names an agent, so a live wrapper-run agent stays found. + await expect(runtime.getTerminalAgentStatus(terminal.handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: 'idle' + }) + await expect( + runtime.isTerminalRunningAgent(terminal.handle, { retryForegroundWrappers: false }) + ).resolves.toBe(true) + } finally { + vi.useRealTimers() + } + }) + + // Synthetic: a trust dialog painted after the clear, behind the name title `⠋ Claude Code`. + it.each(['mounted', 'mountedEcho', 'rendererless'] as const)( + 'a trust dialog painted after the clear still reads as a wait on the user (%s)', + async (mode) => { + vi.useFakeTimers() + try { + const ptyId = mode === 'rendererless' ? 'bg-pty' : 'pty-1' + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: ptyId }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'claude' + }) + let handle: string + if (mode === 'rendererless') { + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + handle = ( + await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'bg-tab', + leafId: HEADLESS_LEAF_ID + }) + ).handle + } else { + syncSinglePty(runtime, ptyId, { tabTitle: 'Terminal 1', paneTitle: null }) + runtime.onPtyData(ptyId, 'boot\r\n', Date.now()) + handle = (await runtime.listTerminals()).terminals[0]?.handle ?? '' + } + runtime.onPtyData(ptyId, '\x1b]0;⠋ Claude Code\x07', Date.now()) + if (mode !== 'rendererless') { + syncSinglePty(runtime, ptyId, { tabTitle: '⠋ Claude Code', paneTitle: '⠋ Claude Code' }) + } + runtime.onPtyData(ptyId, 'still running\r\n', Date.now()) + await vi.advanceTimersByTimeAsync(3_000) + if (mode === 'mountedEcho') { + // The renderer republishes its own cleared title, as a mounted pane does. + syncSinglePty(runtime, ptyId, { tabTitle: 'Claude Code', paneTitle: 'Claude Code' }) + } + await vi.advanceTimersByTimeAsync(1_000) + runtime.onPtyData( + ptyId, + '\r\nDo you trust the files in this folder?\r\n /repo/app\r\n 1. Yes, proceed\r\n 2. No, exit\r\n', + Date.now() + ) + await vi.advanceTimersByTimeAsync(1_000) + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: 'permission' + }) + await expect( + assertTerminalAgentSendable({ runtime, handle, assertWritable: () => {} }) + ).rejects.toThrow('terminal_guard_permission') + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toMatchObject({ + source: 'prompt-text', + reason: 'agent-trust-workspace' + }) + } finally { + vi.useRealTimers() + } + } + ) + + // Synthetic: prompt verification behind a standing clear judges against the baseline main had. + it.each([ + ['mounted', 'output only', false], + ['mountedEcho', 'output only', false], + ['rendererless', 'output only', false], + ['mounted', 'a new spinner frame', true], + ['mountedEcho', 'a new spinner frame', true], + ['rendererless', 'a new spinner frame', true] + ] as const)( + 'a prompt sent behind a cleared spinner (%s) answered by %s settles as on main', + async (mode, _after, spinnerFrame) => { + vi.useFakeTimers() + try { + const ptyId = mode === 'rendererless' ? 'bg-pty' : 'pty-1' + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: ptyId }), + write: (_ptyId, data) => { + if (data === '\r') { + runtime.onPtyData( + ptyId, + spinnerFrame ? '\x1b]0;⠙ Claude Code\x07' : '\r\n', + Date.now() + ) + } + return true + }, + kill: () => true, + getForegroundProcess: async () => 'claude' + }) + let handle: string + if (mode === 'rendererless') { + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + handle = ( + await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'bg-tab', + leafId: HEADLESS_LEAF_ID + }) + ).handle + } else { + syncSinglePty(runtime, ptyId, { tabTitle: 'Terminal 1', paneTitle: null }) + runtime.onPtyData(ptyId, 'boot\r\n', Date.now()) + handle = (await runtime.listTerminals()).terminals[0]?.handle ?? '' + } + runtime.onPtyData(ptyId, '\x1b]0;⠋ Claude Code\x07', Date.now()) + if (mode !== 'rendererless') { + syncSinglePty(runtime, ptyId, { tabTitle: '⠋ Claude Code', paneTitle: '⠋ Claude Code' }) + } + runtime.onPtyData(ptyId, 'still running\r\n', Date.now()) + await vi.advanceTimersByTimeAsync(3_000) + if (mode === 'mountedEcho') { + syncSinglePty(runtime, ptyId, { tabTitle: 'Claude Code', paneTitle: 'Claude Code' }) + } + if (spinnerFrame) { + // The receipt path takes no output as evidence, so only a new turn settles it. + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving', + acceptQueued: true, + requestId: `after-clear-${mode}` + }) + await vi.advanceTimersByTimeAsync(20_000) + await expect(submission).resolves.toMatchObject({ + prompt: { stages: ['input_accepted', 'turn_started'] } + }) + } else { + // Output after Enter is not a turn start, so a swallowed Enter still reads as stalled. + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) + const rejected = expect(submission).rejects.toThrow('agent_prompt_stalled') + // Past the 30 s effect window. + await vi.advanceTimersByTimeAsync(40_000) + await rejected + } + } finally { + vi.useRealTimers() + } + } + ) +}) diff --git a/src/main/runtime/structured-worker-agent-presence.test.ts b/src/main/runtime/structured-worker-agent-presence.test.ts index ca31e891cc9..d500ae5e1cc 100644 --- a/src/main/runtime/structured-worker-agent-presence.test.ts +++ b/src/main/runtime/structured-worker-agent-presence.test.ts @@ -25,7 +25,8 @@ function presence(isLiveStructuredAgent: (handle: string) => boolean) { getPrimaryLeaf: () => null, getTrackedPty: () => null, getTabTitle: () => null, - getForegroundProcess: () => null + getForegroundProcess: () => null, + getTitleDisplayClear: () => null }) } } diff --git a/src/main/runtime/tui-idle-evidence-source.ts b/src/main/runtime/tui-idle-evidence-source.ts new file mode 100644 index 00000000000..3a1b5277999 --- /dev/null +++ b/src/main/runtime/tui-idle-evidence-source.ts @@ -0,0 +1,148 @@ +// Gathers each tui-idle evaluation's evidence from runtime state; tui-idle-evidence.ts ranks it. +import type { AgentStatus } from '../../shared/agent-detection' +import type { TuiAgent } from '../../shared/tui-agent' +import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' +import { + detectTerminalWaitBlockedReason, + isKnownReadyPromptBody, + isQuietReadyScreenBody +} from './terminal-wait-detection' +import { readScreenInputVeto, type RuledScreen } from './screen-input-veto' +import { + evaluateAgentStateRules, + readsTrustedScreen, + type AgentStateVerdict +} from './agent-state-rules/agent-state-rules-engine' +import type { TuiIdleHookTurn } from './tui-idle-hook-lane' +import type { + FirstPartyAgentStatus, + TuiIdleEvaluationInput, + TuiIdleEvidenceRecord +} from './tui-idle-evidence' + +/** The runtime state every tui-idle site reads its evidence from. */ +export type TuiIdleEvidenceSource = { + quiescenceMs: number + getTabTitle(tabId: string): string | null + getAdoptedPtyIdleStatus(pty: RuntimePtyWorktreeRecord): AgentStatus | null + getPaneAgent(ptyId: string | null | undefined): TuiAgent | null + getFirstPartyAgentStatus(ptyId: string | null | undefined): FirstPartyAgentStatus + /** The hook server's fresh row for the pane's main agent; absent on a host with no store. */ + getHookTurn?(ptyId: string, agent: TuiAgent): TuiIdleHookTurn | null + readScreenLines(ptyId: string | null | undefined): readonly string[] | null + /** The painted grid on the PTY's own size, which only agents whose rules read the trusted + * screen, and screen vetoes, use. Absent, they have no trustworthy screen. */ + readRuledScreen?(ptyId: string | null | undefined): RuledScreen | null + /** When the PTY last observed a title of its own. Absent, no title has an age. */ + getTitleObservedAtEpochMs?(ptyId: string | null | undefined): number | null +} + +// Why per agent: every other agent keeps the live screen its rules were recorded against. +// Why read once: several lanes consult the rules, and one evaluation sees one screen. +function screenReader( + source: TuiIdleEvidenceSource, + agent: TuiAgent | null, + ptyId: string | null | undefined +): () => readonly string[] | null { + let lines: readonly string[] | null | undefined + const read = readsTrustedScreen(agent) + ? () => source.readRuledScreen?.(ptyId)?.lines ?? null + : () => source.readScreenLines(ptyId) + return () => (lines === undefined ? (lines = read()) : lines) +} + +function readAgentRuleVerdict( + agent: TuiAgent | null, + record: TuiIdleEvidenceRecord, + readScreenLines: () => readonly string[] | null, + waitText: () => string +): AgentStateVerdict | null { + return evaluateAgentStateRules(agent, { + readScreenLines, + readText: () => waitText().toLowerCase(), + readTitleStatus: () => record.lastAgentStatus, + hasOutputClock: record.lastOutputAt !== null + }) +} + +function hookTurnReader( + source: TuiIdleEvidenceSource, + agent: TuiAgent | null, + ptyId: string | null | undefined +): (() => TuiIdleHookTurn | null) | undefined { + return source.getHookTurn && agent && ptyId + ? () => source.getHookTurn?.(ptyId, agent) ?? null + : undefined +} + +function screenInputVetoReader( + source: TuiIdleEvidenceSource, + agent: TuiAgent | null, + ptyId: string | null | undefined +): () => boolean | null { + // Why memoized: the OMP lane and the veto over the verdict both ask within one evaluation. + let read = false + let veto: boolean | null = null + return () => { + if (!read) { + veto = readScreenInputVeto(agent, () => source.readRuledScreen?.(ptyId) ?? null) + read = true + } + return veto + } +} + +function lazyWaitText(readWaitText: () => string): () => string { + let waitText: string | null = null + return () => (waitText ??= readWaitText()) +} + +export function leafTuiIdleEvidence( + source: TuiIdleEvidenceSource, + leaf: RuntimeLeafRecord, + readWaitText: () => string +): TuiIdleEvaluationInput { + const waitText = lazyWaitText(readWaitText) + const agent = source.getPaneAgent(leaf.ptyId) + const readScreen = screenReader(source, agent, leaf.ptyId) + return { + record: leaf, + readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText()), + rendererTitle: leaf.paneTitle ?? source.getTabTitle(leaf.tabId), + readPositiveBodyEvidence: () => + isKnownReadyPromptBody(waitText(), agent, readScreen, leaf.lastOutputAt !== null), + readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText(), agent, readScreen), + readAgentRuleVerdict: () => readAgentRuleVerdict(agent, leaf, readScreen, waitText), + readScreenInputVeto: screenInputVetoReader(source, agent, leaf.ptyId), + titleObservedAtEpochMs: source.getTitleObservedAtEpochMs?.(leaf.ptyId) ?? null, + agent, + firstPartyStatus: source.getFirstPartyAgentStatus(leaf.ptyId), + readHookTurn: hookTurnReader(source, agent, leaf.ptyId), + quiescenceMs: source.quiescenceMs + } +} + +export function ptyTuiIdleEvidence( + source: TuiIdleEvidenceSource, + pty: RuntimePtyWorktreeRecord, + readWaitText: () => string +): TuiIdleEvaluationInput { + const waitText = lazyWaitText(readWaitText) + const agent = source.getPaneAgent(pty.ptyId) + const readScreen = screenReader(source, agent, pty.ptyId) + return { + record: pty, + readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText()), + readPositiveBodyEvidence: () => + (agent !== 'qoder' && source.getAdoptedPtyIdleStatus(pty) === 'idle') || + isKnownReadyPromptBody(waitText(), agent, readScreen, pty.lastOutputAt !== null), + readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText(), agent, readScreen), + readAgentRuleVerdict: () => readAgentRuleVerdict(agent, pty, readScreen, waitText), + readScreenInputVeto: screenInputVetoReader(source, agent, pty.ptyId), + titleObservedAtEpochMs: pty.lastOscTitleEpochMs, + agent, + firstPartyStatus: source.getFirstPartyAgentStatus(pty.ptyId), + readHookTurn: hookTurnReader(source, agent, pty.ptyId), + quiescenceMs: source.quiescenceMs + } +} diff --git a/src/main/runtime/tui-idle-evidence.test.ts b/src/main/runtime/tui-idle-evidence.test.ts index 43c9789af12..c0016b38de7 100644 --- a/src/main/runtime/tui-idle-evidence.test.ts +++ b/src/main/runtime/tui-idle-evidence.test.ts @@ -36,6 +36,8 @@ function input(overrides: Partial = {}): TuiIdleEvaluati readPositiveBodyEvidence: () => false, readQuietReadyBodyEvidence: () => true, readAgentRuleVerdict: () => null, + readScreenInputVeto: () => null, + titleObservedAtEpochMs: null, agent: 'muse', firstPartyStatus: null, quiescenceMs: QUIESCENCE_MS, @@ -275,6 +277,8 @@ describe('a DSH pane settles tui-idle on its own hook', () => { readPositiveBodyEvidence: () => false, readQuietReadyBodyEvidence: () => false, readAgentRuleVerdict: () => null, + readScreenInputVeto: () => null, + titleObservedAtEpochMs: null, readTailBlockedReason: () => null, agent: 'dsh' as const, firstPartyStatus: { state: 'done' as const, updatedAt: Date.now() }, @@ -306,3 +310,61 @@ describe('a DSH pane settles tui-idle on its own hook', () => { expect(ready({ agent: 'claude' })).toBe(false) }) }) + +describe('evaluateTuiIdle screen input veto', () => { + const omp = (overrides: Partial = {}) => + input({ agent: 'omp', readQuietReadyBodyEvidence: () => false, ...overrides }) + + it('refuses every ready lane while the screen vetoes input', () => { + const lanes: Partial[] = [ + { record: record({ lastOscTitle: 'π - repo', lastAgentStatus: 'idle' }) }, + { readPositiveBodyEvidence: () => true }, + { firstPartyStatus: { state: 'done', updatedAt: Date.now(), sessionBoundary: true } }, + { record: record({ lastOscTitle: 'OMP', lastAgentStatus: 'idle' }) } + ] + for (const lane of lanes) { + expect(isTuiIdleReadyVerdict(evaluateTuiIdle(omp(lane)))).toBe(true) + expect(evaluateTuiIdle(omp({ ...lane, readScreenInputVeto: () => true }))).toEqual({ + kind: 'pending', + quietForeground: 'closed' + }) + } + }) + + it("accepts OMP's own idle title once it has stood on a screen read clear of the wizard", () => { + const idle = { + record: record({ + lastOscTitle: 'π > repo', + lastAgentStatus: 'idle', + lastOutputAt: Date.now() + }), + titleObservedAtEpochMs: Date.now() - QUIESCENCE_MS + } + // Output still flowing (OMP's bracketed-paste keepalive) does not hold the title back. + expect(evaluateTuiIdle(omp({ ...idle, readScreenInputVeto: () => false }))).toEqual({ + kind: 'ready-strong' + }) + for (const unproven of [ + { readScreenInputVeto: () => null }, + { readScreenInputVeto: () => true }, + { + titleObservedAtEpochMs: Date.now() - QUIESCENCE_MS + 1_000, + readScreenInputVeto: () => false + }, + { titleObservedAtEpochMs: null, readScreenInputVeto: () => false } + ]) { + expect(isTuiIdleReadyVerdict(evaluateTuiIdle(omp({ ...idle, ...unproven })))).toBe(false) + } + }) + + it('keeps an OMP pane waiting on the user pending, so the poll still reads its screen', () => { + expect( + evaluateTuiIdle( + omp({ + record: record({ lastOscTitle: 'OMP', lastAgentStatus: 'idle' }), + firstPartyStatus: { state: 'blocked', updatedAt: Date.now() } + }) + ) + ).toEqual({ kind: 'pending', quietForeground: 'closed' }) + }) +}) diff --git a/src/main/runtime/tui-idle-evidence.ts b/src/main/runtime/tui-idle-evidence.ts index ce51d3104c5..2c28f335232 100644 --- a/src/main/runtime/tui-idle-evidence.ts +++ b/src/main/runtime/tui-idle-evidence.ts @@ -9,18 +9,11 @@ import { getSyntheticAgentTerminalTitle } from '../../shared/synthetic-agent-tit import { resolveExplicitTerminalTitleAgentType } from '../../shared/terminal-title-agent-type' import type { TuiAgent } from '../../shared/tui-agent' import { getTuiAgentRestSignal } from '../../shared/tui-agent-rest-signal' -import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' +import { detectExplicitIdleStatusFromTitle } from './terminal-wait-detection' +import { isOmpIdleStateTitle } from './omp-terminal-readiness' import { - detectExplicitIdleStatusFromTitle, - detectTerminalWaitBlockedReason, - isKnownReadyPromptBody, - isQuietReadyScreenBody -} from './terminal-wait-detection' -import { - evaluateAgentStateRules, hasQuietReadyRules, idleTitleRequiresQuiet, - readsTrustedScreen, type AgentStateVerdict } from './agent-state-rules/agent-state-rules-engine' import { evaluateHookTurn, type TuiIdleHookTurn } from './tui-idle-hook-lane' @@ -42,7 +35,9 @@ import { evaluateHookTurn, type TuiIdleHookTurn } from './tui-idle-hook-lane' * title, or a known ready-prompt body. * 1b. QUIET READY SCREEN — Muse titles no rest signal, and agents whose rules * (agent-state-rules/) read a ready screen or text they also paint mid-turn (Codex's - * header and composer), so that body is believed only once quiet. + * header and composer), so that body is believed only once quiet. OMP's own idle title is + * believed once it has stood that long, because it paints that title before its setup + * wizard opens. * 2. WORKING — a fresh first-party agent status (OSC 9999) saying working/blocked/ * waiting, or a working title. The agent's own account of itself outranks anything * inferred. @@ -53,6 +48,8 @@ import { evaluateHookTurn, type TuiIdleHookTurn } from './tui-idle-hook-lane' * start-up dialog the line tail lost (Claude's workspace trust), so ONLY the poll may settle * on it, after the rendered screen shows no blocker. Synchronous sites settle on tiers 0-1b. * + * Over every ready tier: a screen veto (OMP's setup wizard) refuses input whichever lane settled. + * * Why derived here rather than stamped onto the record at write time: `syncWindowGraph` * rebuilds every leaf from an explicit field list, so a bespoke provenance field is * silently dropped on any renderer publish and the verdict silently flips. `lastOscTitle` @@ -76,16 +73,9 @@ export function hasExplicitIdleTitle( record: TuiIdleEvidenceRecord, rendererTitle?: string | null ): boolean { - // Why lastOscTitle too, not just the renderer's pane title: a daemon-hosted or - // background pane has no renderer publishing a title, so reading only the synced - // one dropped an explicit `Codex ready` to the tier-3 lane and delayed it by the - // whole quiescence window. - for (const title of [rendererTitle, record.lastOscTitle]) { - if (title && detectExplicitIdleStatusFromTitle(title) === 'idle') { - return true - } - } - return false + // Renderer titles may be display-only decay; native evidence takes precedence. + const title = record.lastOscTitle ?? rendererTitle + return Boolean(title && detectExplicitIdleStatusFromTitle(title) === 'idle') } /** @@ -211,6 +201,10 @@ export type TuiIdleEvaluationInput = { readQuietReadyBodyEvidence: () => boolean /** The agent's own rules' answer; any answer shuts the lanes that cannot see its screen. */ readAgentRuleVerdict: () => AgentStateVerdict | null + /** Whether an overlay on the agent's screen refuses input; null with no rule or no screen. */ + readScreenInputVeto: () => boolean | null + /** When the PTY's own current title was observed; null when it has none or no clock. */ + titleObservedAtEpochMs: number | null agent: TuiAgent | null | undefined firstPartyStatus: FirstPartyAgentStatus /** Tier 0: the hook server's fresh row for the pane, read only for an authoritative agent. */ @@ -262,6 +256,15 @@ export function hasQuietReadyScreen( /** The one place the tiers are combined; every settle site branches only on the verdict. */ export function evaluateTuiIdle(input: TuiIdleEvaluationInput): TuiIdleVerdict { + const verdict = rankTuiIdleEvidence(input) + // Why over the verdict rather than per lane: an overlay refuses input whichever lane would + // settle, and only a ready verdict pays for the screen read. + return isTuiIdleReadyVerdict(verdict) && input.readScreenInputVeto() === true + ? { kind: 'pending', quietForeground: 'closed' } + : verdict +} + +function rankTuiIdleEvidence(input: TuiIdleEvaluationInput): TuiIdleVerdict { const hookVerdict = input.readHookTurn ? evaluateHookTurn(input.agent, input.readHookTurn) : null if (hookVerdict) { return hookVerdict @@ -299,6 +302,17 @@ export function evaluateTuiIdle(input: TuiIdleEvaluationInput): TuiIdleVerdict { ? WORKING : { kind: 'pending', quietForeground: 'closed' } } + // OMP paints `π >` before the rest of its startup runs and its setup wizard opens, so the title + // counts only once it has stood a quiescence window on a screen read clear of the wizard. Why + // title age, not output quiet: OMP re-asserts bracketed paste every second once a terminal + // answers its probe. Unreadable, the screen cannot rule setup out, and no lane may settle. + if (input.agent === 'omp' && isOmpIdleStateTitle(input.record.lastOscTitle)) { + return input.titleObservedAtEpochMs !== null && + Date.now() - input.titleObservedAtEpochMs >= input.quiescenceMs && + input.readScreenInputVeto() === false + ? READY_STRONG + : { kind: 'pending', quietForeground: 'closed' } + } // Why after the veto: a first-party working account outranks inferred body evidence. // Why before the working title: Codex can leave a stale spinner title after a turn, and a // live spinner emits output every ~100 ms, so a spinning pane is never quiet here. @@ -348,107 +362,3 @@ function isSettledWeakIdle( export function isTuiIdleReadyVerdict(verdict: TuiIdleVerdict): boolean { return verdict.kind === 'ready-strong' || verdict.kind === 'ready-weak' } - -/** The runtime state every tui-idle site reads its evidence from. */ -export type TuiIdleEvidenceSource = { - quiescenceMs: number - getTabTitle(tabId: string): string | null - getAdoptedPtyIdleStatus(pty: RuntimePtyWorktreeRecord): AgentStatus | null - getPaneAgent(ptyId: string | null | undefined): TuiAgent | null - getFirstPartyAgentStatus(ptyId: string | null | undefined): FirstPartyAgentStatus - /** The hook server's fresh row for the pane's main agent; absent on a host with no store. */ - getHookTurn?(ptyId: string, agent: TuiAgent): TuiIdleHookTurn | null - readScreenLines(ptyId: string | null | undefined): readonly string[] | null - /** The painted rows on the PTY's own grid, which only agents whose rules read the trusted - * screen use. Absent, they have no trustworthy screen. */ - readScreenRuledLines?(ptyId: string | null | undefined): readonly string[] | null -} - -// Why per agent: every other agent keeps the live screen its rules were recorded against. -// Why read once: several lanes consult the rules, and one evaluation sees one screen. -function screenReader( - source: TuiIdleEvidenceSource, - agent: TuiAgent | null, - ptyId: string | null | undefined -): () => readonly string[] | null { - let lines: readonly string[] | null | undefined - const read = readsTrustedScreen(agent) - ? () => source.readScreenRuledLines?.(ptyId) ?? null - : () => source.readScreenLines(ptyId) - return () => (lines === undefined ? (lines = read()) : lines) -} - -function readAgentRuleVerdict( - agent: TuiAgent | null, - record: TuiIdleEvidenceRecord, - readScreenLines: () => readonly string[] | null, - waitText: () => string -): AgentStateVerdict | null { - return evaluateAgentStateRules(agent, { - readScreenLines, - readText: () => waitText().toLowerCase(), - readTitleStatus: () => record.lastAgentStatus, - hasOutputClock: record.lastOutputAt !== null - }) -} - -function hookTurnReader( - source: TuiIdleEvidenceSource, - agent: TuiAgent | null, - ptyId: string | null | undefined -): (() => TuiIdleHookTurn | null) | undefined { - return source.getHookTurn && agent && ptyId - ? () => source.getHookTurn?.(ptyId, agent) ?? null - : undefined -} - -function lazyWaitText(readWaitText: () => string): () => string { - let waitText: string | null = null - return () => (waitText ??= readWaitText()) -} - -export function leafTuiIdleEvidence( - source: TuiIdleEvidenceSource, - leaf: RuntimeLeafRecord, - readWaitText: () => string -): TuiIdleEvaluationInput { - const waitText = lazyWaitText(readWaitText) - const agent = source.getPaneAgent(leaf.ptyId) - const readScreen = screenReader(source, agent, leaf.ptyId) - return { - record: leaf, - readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText()), - rendererTitle: leaf.paneTitle ?? source.getTabTitle(leaf.tabId), - readPositiveBodyEvidence: () => - isKnownReadyPromptBody(waitText(), agent, readScreen, leaf.lastOutputAt !== null), - readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText(), agent, readScreen), - readAgentRuleVerdict: () => readAgentRuleVerdict(agent, leaf, readScreen, waitText), - agent, - firstPartyStatus: source.getFirstPartyAgentStatus(leaf.ptyId), - readHookTurn: hookTurnReader(source, agent, leaf.ptyId), - quiescenceMs: source.quiescenceMs - } -} - -export function ptyTuiIdleEvidence( - source: TuiIdleEvidenceSource, - pty: RuntimePtyWorktreeRecord, - readWaitText: () => string -): TuiIdleEvaluationInput { - const waitText = lazyWaitText(readWaitText) - const agent = source.getPaneAgent(pty.ptyId) - const readScreen = screenReader(source, agent, pty.ptyId) - return { - record: pty, - readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText()), - readPositiveBodyEvidence: () => - (agent !== 'qoder' && source.getAdoptedPtyIdleStatus(pty) === 'idle') || - isKnownReadyPromptBody(waitText(), agent, readScreen, pty.lastOutputAt !== null), - readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText(), agent, readScreen), - readAgentRuleVerdict: () => readAgentRuleVerdict(agent, pty, readScreen, waitText), - agent, - firstPartyStatus: source.getFirstPartyAgentStatus(pty.ptyId), - readHookTurn: hookTurnReader(source, agent, pty.ptyId), - quiescenceMs: source.quiescenceMs - } -} diff --git a/src/main/runtime/tui-idle-hook-lane.test.ts b/src/main/runtime/tui-idle-hook-lane.test.ts index f79af4e2c4d..c282fd1fb85 100644 --- a/src/main/runtime/tui-idle-hook-lane.test.ts +++ b/src/main/runtime/tui-idle-hook-lane.test.ts @@ -237,6 +237,8 @@ function input(overrides: Partial = {}): TuiIdleEvaluati readPositiveBodyEvidence: () => false, readQuietReadyBodyEvidence: () => false, readAgentRuleVerdict: () => null, + readScreenInputVeto: () => null, + titleObservedAtEpochMs: null, agent: 'pi', firstPartyStatus: null, quiescenceMs: QUIESCENCE_MS, From 1913d279f7fc68868c818ede21285d04d96675ca Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:50:05 -0700 Subject: [PATCH 13/21] fix(files): replace watcher subscriptions after native failure (#24723) --- .../ipc/parcel-watcher-event-delivery.test.ts | 65 ++++++- src/main/ipc/parcel-watcher-event-delivery.ts | 17 ++ .../ipc/parcel-watcher-host-subscriptions.ts | 121 ++++++++----- ...el-watcher-native-error-retirement.test.ts | 168 ++++++++++++++++++ .../ipc/parcel-watcher-process-entry.test.ts | 86 +++++++++ src/main/ipc/parcel-watcher-process-entry.ts | 13 ++ .../ipc/parcel-watcher-process-supervisor.ts | 23 +-- src/main/ipc/parcel-watcher-process.test.ts | 8 +- .../ipc/parcel-watcher-supervisor-message.ts | 18 +- src/main/runtime/file-watcher-host.test.ts | 12 +- .../relay-filesystem-watch-registry.test.ts | 17 +- 11 files changed, 475 insertions(+), 73 deletions(-) create mode 100644 src/main/ipc/parcel-watcher-native-error-retirement.test.ts diff --git a/src/main/ipc/parcel-watcher-event-delivery.test.ts b/src/main/ipc/parcel-watcher-event-delivery.test.ts index 66c6232a63f..a7efb5b299c 100644 --- a/src/main/ipc/parcel-watcher-event-delivery.test.ts +++ b/src/main/ipc/parcel-watcher-event-delivery.test.ts @@ -5,7 +5,10 @@ const { statMock } = vi.hoisted(() => ({ statMock: vi.fn() })) vi.mock('node:fs/promises', () => ({ stat: statMock })) -import { prepareWatcherProcessEvents } from './parcel-watcher-event-delivery' +import { + createWatcherProcessEventDeliveryQueue, + prepareWatcherProcessEvents +} from './parcel-watcher-event-delivery' describe('prepareWatcherProcessEvents', () => { beforeEach(() => { @@ -42,3 +45,63 @@ describe('prepareWatcherProcessEvents', () => { expect(maxActiveStats).toBe(8) }) }) + +describe('watcher event queue flush', () => { + it('waits for active delivery and the bounded pending overflow', async () => { + const firstDelivery = Promise.withResolvers() + const deliver = vi + .fn() + .mockImplementationOnce(() => firstDelivery.promise) + .mockResolvedValue(undefined) + const queue = createWatcherProcessEventDeliveryQueue({ maxEventsPerBatch: 1 }, deliver, vi.fn()) + queue.enqueue([{ type: 'update', path: '/repo/first' }]) + await vi.waitFor(() => expect(deliver).toHaveBeenCalledTimes(1)) + queue.enqueue([ + { type: 'delete', path: '/repo/child' }, + { type: 'delete', path: '/repo' } + ]) + let flushed = false + const pending = queue.flush().then(() => { + flushed = true + }) + await Promise.resolve() + expect(flushed).toBe(false) + firstDelivery.resolve() + await pending + expect(deliver.mock.calls.map(([events]) => events)).toEqual([ + [{ type: 'update', path: '/repo/first' }], + null + ]) + await expect(queue.flush()).resolves.toBeUndefined() + queue.close() + }) + + it('settles all flush callers when closed while delivery is blocked', async () => { + const blocked = Promise.withResolvers() + const queue = createWatcherProcessEventDeliveryQueue({}, () => blocked.promise, vi.fn()) + queue.enqueue([{ type: 'update', path: '/repo/file' }]) + const first = queue.flush() + const second = queue.flush() + expect(second).toBe(first) + queue.close() + await expect(first).resolves.toBeUndefined() + await expect(queue.flush()).resolves.toBeUndefined() + blocked.resolve() + }) + + it('settles flush after reporting a delivery error', async () => { + const onError = vi.fn() + const failure = new Error('delivery failed') + const queue = createWatcherProcessEventDeliveryQueue( + {}, + async () => { + throw failure + }, + onError + ) + queue.enqueue([{ type: 'update', path: '/repo/file' }]) + await queue.flush() + expect(onError).toHaveBeenCalledWith(failure) + queue.close() + }) +}) diff --git a/src/main/ipc/parcel-watcher-event-delivery.ts b/src/main/ipc/parcel-watcher-event-delivery.ts index ed208b964f8..c190e2ee045 100644 --- a/src/main/ipc/parcel-watcher-event-delivery.ts +++ b/src/main/ipc/parcel-watcher-event-delivery.ts @@ -13,6 +13,7 @@ const directoryStatSlots = new PrioritySemaphore(DIRECTORY_STAT_CONCURRENCY) export type WatcherProcessEventDeliveryQueue = { enqueue(events: readonly ParcelWatcherEvent[]): void + flush(): Promise close(): void } @@ -75,6 +76,12 @@ export function createWatcherProcessEventDeliveryQueue( let draining = false let pendingOverflow = false let pendingEvents: ParcelWatcherEvent[] = [] + let flushCompletion: ReturnType> | undefined + + const finishFlush = (): void => { + flushCompletion?.resolve() + flushCompletion = undefined + } const drain = async (): Promise => { if (!active || draining) { @@ -104,6 +111,8 @@ export function createWatcherProcessEventDeliveryQueue( draining = false if (active && (pendingOverflow || pendingEvents.length > 0)) { void drain() + } else { + finishFlush() } } } @@ -123,11 +132,19 @@ export function createWatcherProcessEventDeliveryQueue( } void drain() }, + flush(): Promise { + if (!active || !draining) { + return Promise.resolve() + } + flushCompletion ??= Promise.withResolvers() + return flushCompletion.promise + }, close(): void { active = false controller.abort() pendingEvents = [] pendingOverflow = false + finishFlush() } } } diff --git a/src/main/ipc/parcel-watcher-host-subscriptions.ts b/src/main/ipc/parcel-watcher-host-subscriptions.ts index 3dad4dee73c..6394486497e 100644 --- a/src/main/ipc/parcel-watcher-host-subscriptions.ts +++ b/src/main/ipc/parcel-watcher-host-subscriptions.ts @@ -16,6 +16,11 @@ export type PendingWatcherUnsubscribe = (error?: Error) => void export const WATCHER_PROCESS_UNSUBSCRIBE_TIMEOUT_MS = RUNTIME_FILE_WATCH_CANCEL_TIMEOUT_MS const terminalTeardownFailures = new WeakMap() +const pendingRecordUnsubscribes = new WeakMap>() +const hostSubscriptions = new WeakMap< + WatcherProcessSubscriptionRecord, + WatcherProcessSubscription +>() export function handleWatcherHostMessage( message: Exclude< @@ -138,49 +143,85 @@ export function createHostWatcherSubscription({ terminateUnavailableChild, sendToChild }: CreateHostWatcherSubscriptionOptions): WatcherProcessSubscription { - return { + const existing = hostSubscriptions.get(record) + if (existing) { + return existing + } + const unsubscribe = (): Promise => { + const terminalFailure = terminalTeardownFailures.get(record) + if (terminalFailure) { + return Promise.reject(terminalFailure) + } + const termination = getTerminationPromise() + if (!records.delete(record.id)) { + return termination ?? Promise.resolve() + } + resetPendingSubscribeAttempt(record) + if (termination) { + return termination + } + if (records.size === 0) { + return killWatcherChildIfIdle() + } + const child = getChild() + if (!child?.connected) { + return terminateUnavailableChild(child) + } + return new Promise((resolve, reject) => { + const onTimeout = (): void => { + if (pendingUnsubscribes.get(record.id) !== settle) { + return + } + // Why: canary setup itself can fail, so native teardown needs an + // independent host deadline that exits the child and releases handles. + void terminateUnavailableChild(child).catch(() => undefined) + } + const timer = setTimeout(onTimeout, WATCHER_PROCESS_UNSUBSCRIBE_TIMEOUT_MS) + timer.unref?.() + const settle: PendingWatcherUnsubscribe = (error) => { + clearTimeout(timer) + if (error) { + reject(error) + } else { + resolve() + } + } + pendingUnsubscribes.set(record.id, settle) + sendToChild(child, { op: 'unsubscribe', id: record.id }) + }) + } + const subscription: WatcherProcessSubscription = { unsubscribe: (): Promise => { - const terminalFailure = terminalTeardownFailures.get(record) - if (terminalFailure) { - return Promise.reject(terminalFailure) + const pending = pendingRecordUnsubscribes.get(record) + if (pending) { + return pending } - const termination = getTerminationPromise() - if (!records.delete(record.id)) { - return termination ?? Promise.resolve() - } - resetPendingSubscribeAttempt(record) - if (termination) { - return termination - } - if (records.size === 0) { - return killWatcherChildIfIdle() - } - const child = getChild() - if (!child?.connected) { - return terminateUnavailableChild(child) - } - return new Promise((resolve, reject) => { - const onTimeout = (): void => { - if (pendingUnsubscribes.get(record.id) !== settle) { - return - } - // Why: canary setup itself can fail, so native teardown needs an - // independent host deadline that exits the child and releases handles. - void terminateUnavailableChild(child).catch(() => undefined) + const closing = unsubscribe() + pendingRecordUnsubscribes.set(record, closing) + const clearPending = (): void => { + if (pendingRecordUnsubscribes.get(record) === closing) { + pendingRecordUnsubscribes.delete(record) } - const timer = setTimeout(onTimeout, WATCHER_PROCESS_UNSUBSCRIBE_TIMEOUT_MS) - timer.unref?.() - const settle: PendingWatcherUnsubscribe = (error) => { - clearTimeout(timer) - if (error) { - reject(error) - } else { - resolve() - } - } - pendingUnsubscribes.set(record.id, settle) - sendToChild(child, { op: 'unsubscribe', id: record.id }) - }) + } + void closing.then(clearPending, clearPending) + return closing } } + hostSubscriptions.set(record, subscription) + return subscription +} + +export async function retireHostWatcherSubscription( + record: WatcherProcessSubscriptionRecord, + error: Error, + shouldReport: () => boolean +): Promise { + try { + await hostSubscriptions.get(record)?.unsubscribe() + } catch (teardownError) { + error = teardownError instanceof Error ? teardownError : new Error(String(teardownError)) + } + if (shouldReport()) { + reportWatcherTerminalError(record, error) + } } diff --git a/src/main/ipc/parcel-watcher-native-error-retirement.test.ts b/src/main/ipc/parcel-watcher-native-error-retirement.test.ts new file mode 100644 index 00000000000..eb75dc9a8ef --- /dev/null +++ b/src/main/ipc/parcel-watcher-native-error-retirement.test.ts @@ -0,0 +1,168 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + acknowledgeWatcherSubscribe, + currentWatcherChild, + FakeWatcherChild, + trackPromiseSettlement +} from './parcel-watcher-process-test-child' + +const { forkMock } = vi.hoisted(() => ({ forkMock: vi.fn() })) +vi.mock('node:child_process', () => ({ fork: forkMock })) +vi.mock('node:fs', () => ({ + existsSync: () => true, + mkdtempSync: () => '/tmp/orca-watcher-retirement-test', + rmSync: vi.fn() +})) + +import { WatcherProcessSupervisor } from './parcel-watcher-process-supervisor' +import { WATCHER_PROCESS_EXIT_DEADLINE_MS } from './parcel-watcher-child-termination' + +describe('native watcher error retirement', () => { + let supervisor: WatcherProcessSupervisor + + beforeEach(() => { + forkMock.mockReset().mockImplementation(() => new FakeWatcherChild()) + supervisor = new WatcherProcessSupervisor({ useInProcessVitestFallback: false }) + }) + + afterEach(() => supervisor.dispose()) + + it('closes only the failed handle before recovery and joins concurrent unsubscribe', async () => { + const callback = vi.fn() + const onTerminalError = vi.fn() + const pending = supervisor.subscribe('/repo', callback, {}, { onTerminalError }) + const child = currentWatcherChild(forkMock) + const id = acknowledgeWatcherSubscribe(child) + const subscription = await pending + const keepAlive = supervisor.subscribe('/other', vi.fn(), {}) + const otherId = acknowledgeWatcherSubscribe(child) + await keepAlive + + child.emit('message', { op: 'watch-error', id, message: 'native watcher stopped' }) + expect(child.sent.at(-1)).toEqual({ op: 'unsubscribe', id }) + expect(onTerminalError).not.toHaveBeenCalled() + expect(callback).not.toHaveBeenCalled() + const closing = subscription.unsubscribe() + const closed = trackPromiseSettlement(closing) + child.emit('message', { op: 'watch-error', id, message: 'duplicate error' }) + child.emit('message', { op: 'events', id, events: [{ type: 'update', path: '/repo/stale' }] }) + await Promise.resolve() + expect(closed()).toBe(false) + expect(child.sent.filter((message) => message.op === 'unsubscribe')).toHaveLength(1) + + child.emit('message', { op: 'unsubscribed', id }) + await closing + await vi.waitFor(() => expect(onTerminalError).toHaveBeenCalledTimes(1)) + expect(onTerminalError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'native watcher stopped' }) + ) + expect(callback).not.toHaveBeenCalled() + expect(child.kill).not.toHaveBeenCalled() + + child.connected = false + child.emit('exit', 1, null) + const replacement = currentWatcherChild(forkMock) + expect(replacement.sent).toEqual([ + expect.objectContaining({ op: 'subscribe', id: otherId, dir: '/other' }) + ]) + }) + + it('waits for physical exit before reporting the last failed subscription', async () => { + const callback = vi.fn() + const pending = supervisor.subscribe('/repo', callback, {}) + const child = currentWatcherChild(forkMock) + const id = acknowledgeWatcherSubscribe(child) + const subscription = await pending + + child.emit('message', { op: 'watch-error', id, message: 'root removed' }) + expect(child.kill).toHaveBeenCalledTimes(1) + expect(callback).not.toHaveBeenCalled() + const closing = subscription.unsubscribe() + const closed = trackPromiseSettlement(closing) + await Promise.resolve() + expect(closed()).toBe(false) + child.emit('exit', 0, null) + await closing + await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(1)) + expect(callback).toHaveBeenCalledWith(expect.objectContaining({ message: 'root removed' }), []) + expect(forkMock).toHaveBeenCalledTimes(1) + }) + + it('suppresses delayed recovery after supervisor disposal', async () => { + const onTerminalError = vi.fn() + const pending = supervisor.subscribe('/repo', vi.fn(), {}, { onTerminalError }) + const child = currentWatcherChild(forkMock) + const id = acknowledgeWatcherSubscribe(child) + await pending + const keepAlive = supervisor.subscribe('/other', vi.fn(), {}) + acknowledgeWatcherSubscribe(child) + await keepAlive + child.emit('message', { op: 'watch-error', id, message: 'native watcher stopped' }) + supervisor.dispose() + child.emit('message', { op: 'unsubscribed', id }) + await Promise.resolve() + await Promise.resolve() + expect(onTerminalError).not.toHaveBeenCalled() + }) + + it('fences replacement behind physical exit when termination misses its deadline', async () => { + vi.useFakeTimers() + try { + const onTerminalError = vi.fn() + const pending = supervisor.subscribe('/repo', vi.fn(), {}, { onTerminalError }) + const child = currentWatcherChild(forkMock) + const id = acknowledgeWatcherSubscribe(child) + const subscription = await pending + child.emit('message', { op: 'watch-error', id, message: 'native watcher stopped' }) + await vi.advanceTimersByTimeAsync(WATCHER_PROCESS_EXIT_DEADLINE_MS) + + expect(onTerminalError).toHaveBeenCalledTimes(1) + const error = onTerminalError.mock.calls[0]?.[0] + expect(error).toMatchObject({ scope: 'supervisor', physicalExit: expect.any(Promise) }) + let exited = false + void error.physicalExit.then(() => { + exited = true + }) + await expect(subscription.unsubscribe()).rejects.toBe(error) + expect(exited).toBe(false) + child.emit('exit', 0, null) + await error.physicalExit + expect(exited).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('reports the removed owner when shared-child teardown misses its exit deadline', async () => { + vi.useFakeTimers() + try { + const onTerminalError = vi.fn() + const pending = supervisor.subscribe('/repo', vi.fn(), {}, { onTerminalError }) + const child = currentWatcherChild(forkMock) + const id = acknowledgeWatcherSubscribe(child) + const subscription = await pending + const otherTerminalError = vi.fn() + const other = supervisor.subscribe( + '/other', + vi.fn(), + {}, + { onTerminalError: otherTerminalError } + ) + acknowledgeWatcherSubscribe(child) + await other + child.emit('message', { op: 'watch-error', id, message: 'native watcher stopped' }) + child.emit('message', { op: 'unsubscribe-failed', id, message: 'native teardown failed' }) + await vi.advanceTimersByTimeAsync(WATCHER_PROCESS_EXIT_DEADLINE_MS) + + expect(onTerminalError).toHaveBeenCalledTimes(1) + expect(otherTerminalError).toHaveBeenCalledTimes(1) + const error = onTerminalError.mock.calls[0]?.[0] + expect(error).toMatchObject({ scope: 'supervisor', physicalExit: expect.any(Promise) }) + await expect(subscription.unsubscribe()).rejects.toBe(error) + child.emit('exit', 0, null) + await error.physicalExit + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/ipc/parcel-watcher-process-entry.test.ts b/src/main/ipc/parcel-watcher-process-entry.test.ts index 6ebdb5385dd..24cfc5981e2 100644 --- a/src/main/ipc/parcel-watcher-process-entry.test.ts +++ b/src/main/ipc/parcel-watcher-process-entry.test.ts @@ -496,6 +496,92 @@ describe('parcel watcher process canary', () => { ) }) + it.each(['/repo', '/repo/Café'])( + 'invalidates only the deleted root %s after delivering its batch', + async (dir) => { + let callback: + | ((err: Error | null, events: { type: string; path: string }[]) => void) + | undefined + subscribeMock + .mockResolvedValueOnce({ unsubscribe: vi.fn() }) + .mockImplementationOnce(async (_dir, nextCallback) => { + callback = nextCallback + return { unsubscribe: vi.fn() } + }) + const sendMock = vi.fn() + process.send = sendMock + await import('./parcel-watcher-process-entry') + await vi.advanceTimersByTimeAsync(0) + process.emit('message', { op: 'subscribe', id: 1, dir, opts: {} }) + await vi.advanceTimersByTimeAsync(0) + sendMock.mockClear() + + callback?.(null, [{ type: 'delete', path: `${dir}/nested` }]) + callback?.(null, [{ type: 'delete', path: `${dir}-other` }]) + await vi.advanceTimersByTimeAsync(0) + expect(sendMock.mock.calls.some(([message]) => message.op === 'watch-error')).toBe(false) + sendMock.mockClear() + + callback?.(null, [{ type: 'delete', path: dir }]) + callback?.(null, [{ type: 'update', path: `${dir}/late.txt` }]) + callback?.(null, [{ type: 'delete', path: dir }]) + await vi.advanceTimersByTimeAsync(0) + + expect(sendMock.mock.calls.map(([message]) => message)).toEqual([ + { op: 'events', id: 1, events: [{ type: 'delete', path: dir }] }, + { op: 'watch-error', id: 1, message: expect.stringContaining('root') } + ]) + } + ) + + it('invalidates an overflowing root-delete batch after IPC backpressure clears', async () => { + let callback: + | ((err: Error | null, events: { type: string; path: string }[]) => void) + | undefined + subscribeMock + .mockResolvedValueOnce({ unsubscribe: vi.fn() }) + .mockImplementationOnce(async (_dir, nextCallback) => { + callback = nextCallback + return { unsubscribe: vi.fn() } + }) + let releaseEvent: (() => void) | undefined + const sendMock = vi.fn((message, onSent) => { + if (message.op === 'events') { + releaseEvent = onSent + return false + } + return true + }) + process.send = sendMock + await import('./parcel-watcher-process-entry') + await vi.advanceTimersByTimeAsync(0) + process.emit('message', { + op: 'subscribe', + id: 1, + dir: '/repo', + opts: {}, + delivery: { maxEventsPerBatch: 1 } + }) + await vi.advanceTimersByTimeAsync(0) + sendMock.mockClear() + + callback?.(null, [{ type: 'update', path: '/repo/first.txt' }]) + await vi.advanceTimersByTimeAsync(0) + callback?.(null, [ + { type: 'delete', path: '/repo/child.txt' }, + { type: 'delete', path: '/repo' } + ]) + await vi.advanceTimersByTimeAsync(0) + expect(sendMock.mock.calls.map(([message]) => message.op)).toEqual(['events']) + releaseEvent?.() + await vi.advanceTimersByTimeAsync(0) + expect(sendMock.mock.calls.map(([message]) => message.op)).toEqual([ + 'events', + 'overflow', + 'watch-error' + ]) + }) + it('bounds pending event batches while child IPC reports backpressure', async () => { let callback: | ((err: Error | null, events: { type: string; path: string }[]) => void) diff --git a/src/main/ipc/parcel-watcher-process-entry.ts b/src/main/ipc/parcel-watcher-process-entry.ts index 0de05010f47..ce024aa36a4 100644 --- a/src/main/ipc/parcel-watcher-process-entry.ts +++ b/src/main/ipc/parcel-watcher-process-entry.ts @@ -180,6 +180,7 @@ function main(): void { opts: WatcherProcessSubscribeOptions, delivery: WatcherProcessDeliveryOptions | undefined ): Promise => { + let rootDeleted = false const eventDelivery = createWatcherProcessEventDeliveryQueue( delivery, async (events) => { @@ -216,11 +217,23 @@ function main(): void { return await watcher.subscribe( dir, (err, events) => { + if (rootDeleted) { + return + } if (err) { send({ op: 'watch-error', id, message: errorMessage(err) }) return } + rootDeleted = events.some((event) => event.type === 'delete' && event.path === dir) eventDelivery.enqueue(events) + if (rootDeleted) { + // Native backends stop deleted roots without reporting an error. + void eventDelivery + .flush() + .then(() => + send({ op: 'watch-error', id, message: 'File watcher root was deleted' }) + ) + } }, opts as ParcelWatcher.Options ) diff --git a/src/main/ipc/parcel-watcher-process-supervisor.ts b/src/main/ipc/parcel-watcher-process-supervisor.ts index 3f0a534c198..86f3fecdec8 100644 --- a/src/main/ipc/parcel-watcher-process-supervisor.ts +++ b/src/main/ipc/parcel-watcher-process-supervisor.ts @@ -44,7 +44,7 @@ export class WatcherProcessSupervisor { private child: ChildProcess | null = null private nextSubscriptionId = 1 private readonly crashFuse = new WatcherProcessCrashFuse() - private shutdownRequested = false + private shutdown = { requested: false, disposalRevision: 0 } private canaryDir: string | null = null private terminatingChild: ChildProcess | null = null private readonly terminationQueue = new termination.WatcherTerminationQueue() @@ -71,7 +71,7 @@ export class WatcherProcessSupervisor { callback, opts, hooks, - shutdownRequested: this.shutdownRequested, + shutdownRequested: this.shutdown.requested, entryPath: this.options.entryPath ?? getWatcherProcessEntryPath(), useInProcessVitestFallback: this.options.useInProcessVitestFallback ?? true, allocateId: () => this.nextSubscriptionId++, @@ -92,7 +92,8 @@ export class WatcherProcessSupervisor { } dispose(): void { - this.shutdownRequested = true + this.shutdown.requested = true + this.shutdown.disposalRevision++ this.capacityWait.dispose() const proc = this.child this.child = null @@ -107,7 +108,7 @@ export class WatcherProcessSupervisor { resetForTest(): void { this.dispose() - this.shutdownRequested = false + this.shutdown.requested = false this.terminatingChild = null this.terminationQueue.resetForTest() this.crashFuse.reset() @@ -117,7 +118,7 @@ export class WatcherProcessSupervisor { private ensureWatcherProcess( entryPath = this.options.entryPath ?? getWatcherProcessEntryPath() ): ChildProcess | null { - if (this.shutdownRequested || this.terminatingChild) { + if (this.shutdown.requested || this.terminatingChild) { return null } if (this.child?.connected) { @@ -151,6 +152,7 @@ export class WatcherProcessSupervisor { private handleChildMessage(message: WatcherToHostMessage): void { const child = this.child + const disposalRevision = this.shutdown.disposalRevision handleWatcherSupervisorMessage(message, { records: this.records, pendingUnsubscribes: this.pendingUnsubscribes, @@ -171,6 +173,7 @@ export class WatcherProcessSupervisor { this.terminateUnavailableChild(child) } }, + shouldReportTerminalError: () => this.shutdown.disposalRevision === disposalRevision, killWatcherChildIfIdle: () => termination.ignoreWatcherTermination(this.killWatcherChildIfIdle()) }) @@ -194,7 +197,7 @@ export class WatcherProcessSupervisor { recoverWatcherRecordsAfterChildGone( this.records, this.crashFuse, - this.shutdownRequested, + this.shutdown.requested, () => this.ensureWatcherProcess(), sendWatcherSubscribe, () => { @@ -227,9 +230,9 @@ export class WatcherProcessSupervisor { (exited) => { this.terminatingChild = null if (!exited) { - this.shutdownRequested = true + this.shutdown.requested = true } - return !this.shutdownRequested + return !this.shutdown.requested }, () => this.ensureWatcherProcess(), sendWatcherSubscribe, @@ -299,9 +302,9 @@ export class WatcherProcessSupervisor { (exited) => { this.terminatingChild = null if (!exited) { - this.shutdownRequested = true + this.shutdown.requested = true } - return !this.shutdownRequested + return !this.shutdown.requested }, () => this.ensureWatcherProcess(), sendWatcherSubscribe, diff --git a/src/main/ipc/parcel-watcher-process.test.ts b/src/main/ipc/parcel-watcher-process.test.ts index be5000fc49a..4fc98835328 100644 --- a/src/main/ipc/parcel-watcher-process.test.ts +++ b/src/main/ipc/parcel-watcher-process.test.ts @@ -90,7 +90,7 @@ describe('subscribeViaWatcherProcess', () => { expect(callback).toHaveBeenCalledWith(null, events) }) - it('forwards watcher errors to the callback', async () => { + it('retires the failed native subscription before reporting its error', async () => { const callback = vi.fn() const promise = subscribeViaWatcherProcess('/repo', callback, {}) const child = currentChild() @@ -98,6 +98,10 @@ describe('subscribeViaWatcherProcess', () => { await promise child.emit('message', { op: 'watch-error', id, message: 'boom' }) + expect(callback).not.toHaveBeenCalled() + expect(child.kill).toHaveBeenCalledTimes(1) + child.emit('exit', 0, null) + await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(1)) expect(callback).toHaveBeenCalledWith(expect.objectContaining({ message: 'boom' }), []) }) @@ -126,7 +130,7 @@ describe('subscribeViaWatcherProcess', () => { expect(forkMock).toHaveBeenCalledTimes(1) }) - it('reports a terminal resubscribe failure separately from recoverable watch errors', async () => { + it('reports a resubscribe failure through the terminal hook', async () => { const callback = vi.fn() const onTerminalError = vi.fn() const promise = subscribeViaWatcherProcess('/repo', callback, {}, { onTerminalError }) diff --git a/src/main/ipc/parcel-watcher-supervisor-message.ts b/src/main/ipc/parcel-watcher-supervisor-message.ts index e5fbce4342f..cc3e090c5ea 100644 --- a/src/main/ipc/parcel-watcher-supervisor-message.ts +++ b/src/main/ipc/parcel-watcher-supervisor-message.ts @@ -3,7 +3,8 @@ import type { WatcherCancellationTracker } from './parcel-watcher-cancellation-t import { handleWatcherHostMessage, type PendingWatcherUnsubscribe, - reportWatcherTerminalError + reportWatcherTerminalError, + retireHostWatcherSubscription } from './parcel-watcher-host-subscriptions' import { startInterruptedSubscribeTimeout, @@ -28,6 +29,7 @@ type WatcherSupervisorMessageContext = { ) => void restartAfterCancelledSubscribe: (child: ChildProcess | null) => void terminateUnavailableChild: (child: ChildProcess | null) => void + shouldReportTerminalError: () => boolean killWatcherChildIfIdle: () => void } @@ -61,13 +63,13 @@ export function handleWatcherSupervisorMessage( } } if (message.op === 'watch-error') { - handleWatcherHostMessage( - message, - context.records, - context.pendingUnsubscribes, - reportWatcherTerminalError, - context.killWatcherChildIfIdle - ) + if (record) { + void retireHostWatcherSubscription( + record, + new Error(message.message), + context.shouldReportTerminalError + ) + } return } if (message.op === 'cancel-requires-restart') { diff --git a/src/main/runtime/file-watcher-host.test.ts b/src/main/runtime/file-watcher-host.test.ts index 72608084a9a..f924d86db04 100644 --- a/src/main/runtime/file-watcher-host.test.ts +++ b/src/main/runtime/file-watcher-host.test.ts @@ -176,7 +176,13 @@ describe('watchFileExplorerInWatcherProcess', () => { expect(onEvents).toHaveBeenCalledWith([{ kind: 'overflow', absolutePath: '/repo' }]) }) - it('keeps same-root subscribers alive while a failed shard is replaced', async () => { + it.each([ + ['native watcher failure', new Error('native subscription stopped')], + [ + 'watcher process failure', + new WatcherProcessFailure('crashed repeatedly', 'supervisor', 'supervisor_crash_fuse') + ] + ])('keeps same-root subscribers alive after a %s', async (_kind, error) => { const watch = installSuccessfulWatch() const replacement = installSuccessfulWatch() const firstEvents = vi.fn<(events: FsChangeEvent[]) => void>() @@ -186,9 +192,7 @@ describe('watchFileExplorerInWatcherProcess', () => { await watchFileExplorerInWatcherProcess('/repo', firstEvents, firstError) await watchFileExplorerInWatcherProcess('/repo', secondEvents, secondError) - watch.hooks.onTerminalError?.( - new WatcherProcessFailure('crashed repeatedly', 'supervisor', 'supervisor_crash_fuse') - ) + watch.hooks.onTerminalError?.(error) await vi.waitFor(() => expect(subscribeViaRuntimeWatcherProcessMock).toHaveBeenCalledTimes(2)) watch.callback(null, [{ type: 'update', path: '/repo/late.txt' }]) replacement.callback(null, [{ type: 'update', path: '/repo/recovered.txt' }]) diff --git a/src/relay/relay-filesystem-watch-registry.test.ts b/src/relay/relay-filesystem-watch-registry.test.ts index dcea47e7d44..a8018bad37c 100644 --- a/src/relay/relay-filesystem-watch-registry.test.ts +++ b/src/relay/relay-filesystem-watch-registry.test.ts @@ -138,20 +138,21 @@ describe('RelayFilesystemWatchRegistry', () => { ]) }) - it('moves a terminal shard failure into recovery without dropping shared clients', async () => { + it.each([ + ['native watcher failure', new Error('native subscription stopped')], + [ + 'watcher process failure', + new WatcherProcessFailure('crashed repeatedly', 'supervisor', 'supervisor_crash_fuse') + ] + ])('recovers after a %s without dropping shared clients', async (_kind, error) => { await registry.watch('/repo', context(1)) await registry.watch('/repo', context(2)) const first = pool.installed[0] - first.hooks.onTerminalError?.( - new WatcherProcessFailure( - 'file watcher process crashed repeatedly', - 'supervisor', - 'supervisor_crash_fuse' - ) - ) + first.hooks.onTerminalError?.(error) await Promise.resolve() expect(pool.installed).toHaveLength(2) + first.callback(null, [{ type: 'create', path: '/repo/late.txt' }]) pool.installed[1].callback(null, [{ type: 'create', path: '/repo/recovered.txt' }]) expect(dispatcher.fsChanged).toEqual([ { events: [{ kind: 'overflow', absolutePath: '/repo' }] }, From 5f1dece0284c63a2236618c705ede730222e1571 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:50:09 -0700 Subject: [PATCH 14/21] fix(files): retry failed shallow directory watch replacement (#24724) --- ...arcel-watcher-shallow-subscription.test.ts | 101 +++++++++++++++++- .../parcel-watcher-shallow-subscription.ts | 18 ++-- 2 files changed, 111 insertions(+), 8 deletions(-) diff --git a/src/main/ipc/parcel-watcher-shallow-subscription.test.ts b/src/main/ipc/parcel-watcher-shallow-subscription.test.ts index b2eefd73e4e..ca17ce4df5a 100644 --- a/src/main/ipc/parcel-watcher-shallow-subscription.test.ts +++ b/src/main/ipc/parcel-watcher-shallow-subscription.test.ts @@ -1,5 +1,6 @@ import { EventEmitter } from 'node:events' -import { mkdir, mkdtemp, rm } from 'node:fs/promises' +import { watch } from 'node:fs' +import { mkdir, mkdtemp, rename, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -103,6 +104,29 @@ describe('shallow watcher subscription', () => { } }) + it.skipIf(process.platform === 'win32')( + 'preserves a literal backslash in an included filename', + async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-shallow-watcher-')) + const events: string[] = [] + const subscription = startShallowWatcher( + root, + ['plugin\\name'], + (nextEvents) => events.push(...nextEvents.map((event) => event.path)), + (error) => { + throw error + } + ) + try { + emit(root, 'plugin\\name') + expect(events).toEqual([join(root, 'plugin\\name')]) + } finally { + await subscription.unsubscribe() + await rm(root, { recursive: true, force: true }) + } + } + ) + it('rebinds a nested directory that is replaced, which leaves fs.watch deaf', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-shallow-watcher-')) try { @@ -152,4 +176,79 @@ describe('shallow watcher subscription', () => { await rm(root, { recursive: true, force: true }) } }) + + it('retries a failed replacement binding and resyncs after recovery', async () => { + vi.useFakeTimers() + const root = await mkdtemp(join(tmpdir(), 'orca-shallow-watcher-')) + const logs = join(root, 'logs') + let subscription: ReturnType | undefined + try { + await mkdir(logs) + const events: string[] = [] + subscription = startShallowWatcher( + root, + ['HEAD', 'logs/HEAD'], + (nextEvents) => events.push(...nextEvents.map((event) => event.path)), + (error) => { + throw error + } + ) + const initialAttempts = vi.mocked(watch).mock.calls.length + await rename(logs, join(root, 'old-logs')) + await mkdir(logs) + vi.mocked(watch).mockImplementationOnce(() => { + throw new Error('ENOSPC: watch limit reached') + }) + + await vi.advanceTimersByTimeAsync(30_000) + await vi.waitFor(() => expect(watch).toHaveBeenCalledTimes(initialAttempts + 1)) + await vi.advanceTimersByTimeAsync(30_000) + await vi.waitFor(() => expect(watch).toHaveBeenCalledTimes(initialAttempts + 2)) + + expect(events).toContain(join(logs, 'HEAD')) + events.length = 0 + emit(logs, 'HEAD') + expect(events).toEqual([join(logs, 'HEAD')]) + } finally { + await subscription?.unsubscribe() + vi.useRealTimers() + await rm(root, { recursive: true, force: true }) + } + }) + + it('resyncs included files when an initially missing directory becomes watchable', async () => { + vi.useFakeTimers() + const root = await mkdtemp(join(tmpdir(), 'orca-shallow-watcher-')) + let subscription: ReturnType | undefined + try { + const defaultWatch = vi.mocked(watch).getMockImplementation() + if (!defaultWatch) { + throw new Error('Missing watcher test implementation') + } + vi.mocked(watch) + .mockImplementationOnce(defaultWatch) + .mockImplementationOnce(() => { + throw new Error('ENOENT: logs directory does not exist') + }) + const events: string[] = [] + subscription = startShallowWatcher( + root, + ['HEAD', 'logs/HEAD'], + (nextEvents) => events.push(...nextEvents.map((event) => event.path)), + (error) => { + throw error + } + ) + const logs = join(root, 'logs') + await mkdir(logs) + await vi.advanceTimersByTimeAsync(30_000) + await vi.waitFor(() => expect(watcherState.has(logs)).toBe(true)) + + expect(events).toContain(join(logs, 'HEAD')) + } finally { + await subscription?.unsubscribe() + vi.useRealTimers() + await rm(root, { recursive: true, force: true }) + } + }) }) diff --git a/src/main/ipc/parcel-watcher-shallow-subscription.ts b/src/main/ipc/parcel-watcher-shallow-subscription.ts index 13319281535..639ee8b36f8 100644 --- a/src/main/ipc/parcel-watcher-shallow-subscription.ts +++ b/src/main/ipc/parcel-watcher-shallow-subscription.ts @@ -33,7 +33,9 @@ export function startShallowWatcher( ): ShallowWatcherSubscription { const pathsByDirectory = new Map>() for (const relativePath of relativePaths) { - const parts = relativePath.split(/[\\/]+/).filter(Boolean) + const parts = relativePath + .split(process.platform === 'win32' ? /[\\/]+/ : /\/+/) + .filter(Boolean) const fileName = parts.pop() if (!fileName) { continue @@ -66,11 +68,11 @@ export function startShallowWatcher( ) } - const watchDirectory = (parent: string, fileNames: Set, rebind = false): void => { + const watchDirectory = (parent: string, fileNames: Set, rebind = false): boolean => { const existing = watchers.get(parent) if (existing) { if (!rebind) { - return + return true } watchers.delete(parent) boundIdentities.delete(parent) @@ -102,11 +104,13 @@ export function startShallowWatcher( }) watcher.on('error', reportError) watchers.set(parent, watcher) + return true } catch (error) { // Nested metadata directories may not exist until Git creates them. if (parent === '') { reportError(error) } + return false } } @@ -139,11 +143,11 @@ export function startShallowWatcher( } // Either the directory appeared after we started, or it was replaced while // watched. Both leave the old binding deaf, so rebind and resync. - watchDirectory(parent, fileNames, true) - boundIdentities.set(parent, identity) - if (bound !== undefined) { - emitUpdates(parent, fileNames) + if (!watchDirectory(parent, fileNames, true)) { + return } + boundIdentities.set(parent, identity) + emitUpdates(parent, fileNames) } const rebindTimer = setInterval(() => { From 9273b5f9af557d59d617ffa6b12ccbe8351b2c19 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:50:13 -0700 Subject: [PATCH 15/21] fix(git): share pending watcher reconciliation startup (#24725) --- ...ee-git-common-watch-reconciliation.test.ts | 159 ++++++++++++++++++ ...orktree-git-common-watch-reconciliation.ts | 104 ++++++------ 2 files changed, 215 insertions(+), 48 deletions(-) create mode 100644 src/main/ipc/worktree-git-common-watch-reconciliation.test.ts diff --git a/src/main/ipc/worktree-git-common-watch-reconciliation.test.ts b/src/main/ipc/worktree-git-common-watch-reconciliation.test.ts new file mode 100644 index 00000000000..464ecf828b5 --- /dev/null +++ b/src/main/ipc/worktree-git-common-watch-reconciliation.test.ts @@ -0,0 +1,159 @@ +import { join, resolve } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { subscribeViaWatcherProcess, type WatcherProcessCallback } from './parcel-watcher-process' +import { startGitCommonPolling } from './worktree-git-common-polling' +import { startGitCommonNarrowWatch } from './worktree-git-common-narrow-watch' +import { createGitCommonWatchReconciliation } from './worktree-git-common-watch-reconciliation' +import type { + WorktreeBaseSubscription, + WorktreePollerWindowVisibility +} from './worktree-base-directory-poller' + +vi.mock('node:fs/promises', () => ({ stat: vi.fn(async () => ({ isDirectory: () => true })) })) +vi.mock('./parcel-watcher-process', () => ({ subscribeViaWatcherProcess: vi.fn() })) +vi.mock('./worktree-git-common-polling', () => ({ startGitCommonPolling: vi.fn() })) + +const commonDirPath = resolve('repository', '.git') +const visibility: WorktreePollerWindowVisibility = { + isWindowVisible: () => true, + onWindowBecameVisible: () => () => {} +} +const pollMock = vi.mocked(startGitCommonPolling) +const subscribeMock = vi.mocked(subscribeViaWatcherProcess) +const baselines: { + settle: () => void + unsubscribe: ReturnType Promise>> + onVisible: ReturnType void>> +}[] = [] +const starts: Promise[] = [] +const subscriptions: { callback: WatcherProcessCallback }[] = [] +const reconciliations: ReturnType[] = [] + +beforeEach(() => { + pollMock.mockReset() + subscribeMock.mockReset() + pollMock.mockImplementation((_path, _onEvents, _interval, pollVisibility) => { + const { promise, resolve } = Promise.withResolvers() + const onVisible = vi.fn<() => void>() + let removeVisibilityListener = (): void => {} + let ready = false + const unsubscribe = vi.fn(async () => removeVisibilityListener()) + baselines.push({ + settle: () => { + if (!ready) { + ready = true + removeVisibilityListener = pollVisibility.onWindowBecameVisible(onVisible) + resolve({ unsubscribe }) + } + }, + unsubscribe, + onVisible + }) + return promise + }) + subscribeMock.mockImplementation(async (_path, callback) => { + subscriptions.push({ callback }) + return { unsubscribe: vi.fn<() => Promise>().mockResolvedValue(undefined) } + }) +}) + +afterEach(async () => { + for (const baseline of baselines) { + baseline.settle() + } + for (const starting of starts) { + await (await starting).unsubscribe() + } + for (const reconciliation of reconciliations) { + await reconciliation.unsubscribe() + } + baselines.length = 0 + starts.length = 0 + subscriptions.length = 0 + reconciliations.length = 0 + vi.useRealTimers() +}) + +function createReconciliation(shouldKeep = () => true) { + const reconciliation = createGitCommonWatchReconciliation({ + commonDirPath, + pollIntervalMs: 10, + visibility, + canStart: () => true, + shouldKeep, + onRootReplacement: vi.fn(), + onEvents: vi.fn() + }) + reconciliations.push(reconciliation) + return reconciliation +} + +describe('Git common watcher reconciliation ownership', () => { + it('shares one pending baseline between concurrent starts', async () => { + const reconciliation = createReconciliation() + const first = reconciliation.ensureStarted() + const second = reconciliation.ensureStarted() + expect(pollMock).toHaveBeenCalledOnce() + baselines[0]?.settle() + await Promise.all([first, second]) + reconciliation.notifyWindowBecameVisible() + expect(baselines[0]?.onVisible).toHaveBeenCalledOnce() + await reconciliation.unsubscribe() + expect(baselines[0]?.unsubscribe).toHaveBeenCalledOnce() + }) + + it('keeps one poller when a native failure rearms during the initial baseline', async () => { + vi.useFakeTimers() + const starting = startGitCommonNarrowWatch( + { key: 'git-common', kind: 'git-common', path: commonDirPath, repos: new Map() }, + vi.fn(), + 10, + 'darwin', + visibility + ) + starts.push(starting) + await vi.waitFor(() => expect(pollMock).toHaveBeenCalledOnce()) + subscriptions[0]?.callback(new Error('root failed during baseline'), []) + await vi.advanceTimersByTimeAsync(20) + expect(subscribeMock).toHaveBeenCalledTimes(2) + expect(subscribeMock.mock.calls[1]?.[0]).toBe(join(commonDirPath, 'worktrees')) + expect(pollMock).toHaveBeenCalledOnce() + baselines[0]?.settle() + const subscription = await starting + await vi.advanceTimersByTimeAsync(0) + await subscription.unsubscribe() + expect(baselines[0]?.unsubscribe).toHaveBeenCalledOnce() + }) + + it.each([true, false])( + 'waits for and releases a late baseline when shouldKeep returns %s', + async (keep) => { + const reconciliation = createReconciliation(() => keep) + const starting = reconciliation.ensureStarted() + const stopping = reconciliation.unsubscribe() + const stopped = vi.fn() + void stopping.then(stopped) + await Promise.resolve() + await Promise.resolve() + expect(stopped).not.toHaveBeenCalled() + baselines[0]?.settle() + await Promise.all([starting, stopping]) + expect(stopped).toHaveBeenCalledOnce() + expect(baselines[0]?.unsubscribe).toHaveBeenCalledOnce() + reconciliation.notifyWindowBecameVisible() + expect(baselines[0]?.onVisible).not.toHaveBeenCalled() + } + ) + + it('retries a rejected baseline and releases the successful retry', async () => { + pollMock.mockRejectedValueOnce(new Error('baseline failed')) + const reconciliation = createReconciliation() + await expect(reconciliation.ensureStarted()).rejects.toThrow('baseline failed') + const retry = reconciliation.ensureStarted() + expect(pollMock).toHaveBeenCalledTimes(2) + baselines[0]?.settle() + await retry + await reconciliation.unsubscribe() + expect(baselines[0]?.unsubscribe).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/ipc/worktree-git-common-watch-reconciliation.ts b/src/main/ipc/worktree-git-common-watch-reconciliation.ts index 662ea13a375..4f1aeb5d476 100644 --- a/src/main/ipc/worktree-git-common-watch-reconciliation.ts +++ b/src/main/ipc/worktree-git-common-watch-reconciliation.ts @@ -5,6 +5,7 @@ import type { WorktreePollerWindowVisibility } from './worktree-base-directory-poller' import { startGitCommonPolling } from './worktree-git-common-polling' +import { createSingleFlight } from './single-flight-promise' // The native stream is still the fast path. A scheduled 15-tick reconciliation // bounds silent watcher loss at the existing 30-second backstop without joining @@ -38,6 +39,8 @@ export function createGitCommonWatchReconciliation({ }: GitCommonWatchReconciliationOptions): GitCommonWatchReconciliation { const worktreesDir = join(commonDirPath, 'worktrees') let subscription: WorktreeBaseSubscription | null = null + const starting = createSingleFlight() + let generation = 0 const visibilityListeners = new Set<() => void>() const pollVisibility: WorktreePollerWindowVisibility = { isWindowVisible: visibility.isWindowVisible, @@ -50,57 +53,60 @@ export function createGitCommonWatchReconciliation({ } return { - ensureStarted: async () => { + ensureStarted: () => { if (subscription || !canStart()) { - return + return Promise.resolve() } - const reconciliation = await startGitCommonPolling( - commonDirPath, - (events) => { - const rootWasReplaced = - events.some((event) => event.type === 'delete' && event.path === worktreesDir) && - events.some((event) => event.type === 'create' && event.path === worktreesDir) - // Why: this backstop lags the native stream by up to 15 ticks, so it - // routinely reports entry creates the stream already delivered. Only - // treat them as a replacement when the root itself was also recreated - // — otherwise every ordinary `git worktree add` would tear down a - // healthy stream and open a deaf window while it resubscribes. - const rootRecreated = events.some( - (event) => event.type === 'create' && event.path === worktreesDir - ) - const coarseRootReplacement = - rootRecreated && - events.some( - (event) => - event.type === 'create' && - event.path !== worktreesDir && - dirname(event.path) === worktreesDir + return starting.run(async () => { + const startGeneration = generation + const reconciliation = await startGitCommonPolling( + commonDirPath, + (events) => { + const rootWasReplaced = + events.some((event) => event.type === 'delete' && event.path === worktreesDir) && + events.some((event) => event.type === 'create' && event.path === worktreesDir) + // Why: this backstop lags the native stream by up to 15 ticks, so it + // routinely reports entry creates the stream already delivered. Only + // treat them as a replacement when the root itself was also recreated + // — otherwise every ordinary `git worktree add` would tear down a + // healthy stream and open a deaf window while it resubscribes. + const rootRecreated = events.some( + (event) => event.type === 'create' && event.path === worktreesDir ) - if (rootWasReplaced || coarseRootReplacement) { - onRootReplacement() - } - onEvents( - coarseRootReplacement - ? events.map((event) => - event.type === 'update' && event.path === worktreesDir - ? { ...event, type: 'create' } - : event - ) - : events - ) - }, - pollIntervalMs * NARROW_WATCH_RECONCILIATION_TICKS, - pollVisibility, - undefined, - false, - () => [], - { forceFullScanEveryTick: true } - ) - if (!shouldKeep()) { - await reconciliation.unsubscribe() - } else { - subscription = reconciliation - } + const coarseRootReplacement = + rootRecreated && + events.some( + (event) => + event.type === 'create' && + event.path !== worktreesDir && + dirname(event.path) === worktreesDir + ) + if (rootWasReplaced || coarseRootReplacement) { + onRootReplacement() + } + onEvents( + coarseRootReplacement + ? events.map((event) => + event.type === 'update' && event.path === worktreesDir + ? { ...event, type: 'create' } + : event + ) + : events + ) + }, + pollIntervalMs * NARROW_WATCH_RECONCILIATION_TICKS, + pollVisibility, + undefined, + false, + () => [], + { forceFullScanEveryTick: true } + ) + if (startGeneration !== generation || !shouldKeep()) { + await reconciliation.unsubscribe() + } else { + subscription = reconciliation + } + }) }, notifyWindowBecameVisible: () => { for (const listener of visibilityListeners) { @@ -108,6 +114,8 @@ export function createGitCommonWatchReconciliation({ } }, unsubscribe: async () => { + generation++ + await starting.pending()?.catch(() => {}) const current = subscription subscription = null await current?.unsubscribe() From ac28e8c85e8c07328a9f4effe78025c92a99f3c4 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:53:56 -0700 Subject: [PATCH 16/21] Skip dependency installation for known headless build inputs (#24716) * ci: defer headless dependency installation until graph analysis is needed * docs: align headless CI rollout with platform and cache policy * test: isolate headless detector output from the parent CI step --- .github/workflows/node-server-tests.yml | 30 +++++-- config/scripts/node-server-change-scope.mjs | 25 +++--- .../scripts/node-server-change-scope.test.mjs | 80 ++++++++++++++++++- docs/reference/ci-demand-rollout.md | 44 ++++++---- 4 files changed, 145 insertions(+), 34 deletions(-) diff --git a/.github/workflows/node-server-tests.yml b/.github/workflows/node-server-tests.yml index d8d6802576c..a8500be97db 100644 --- a/.github/workflows/node-server-tests.yml +++ b/.github/workflows/node-server-tests.yml @@ -74,15 +74,18 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 outputs: - should_run: ${{ steps.scope.outputs.should_run }} - qualification: ${{ steps.scope.outputs.qualification }} - runners: ${{ steps.scope.outputs.runners }} + should_run: ${{ steps.scope.outputs.should_run || steps.graph.outputs.should_run }} + qualification: ${{ steps.scope.outputs.qualification || steps.graph.outputs.qualification }} + runners: ${{ steps.scope.outputs.runners || steps.graph.outputs.runners }} steps: - uses: actions/checkout@v6 with: fetch-depth: 2 persist-credentials: false - - uses: ./.github/actions/install-node-dependencies + - uses: actions/setup-node@v6 + with: + node-version-file: package.json + package-manager-cache: false - name: Detect headless-server build and test inputs id: scope shell: bash @@ -94,7 +97,7 @@ jobs: # Compare the entire push, including multi-commit pushes and removed files. if git fetch --no-tags --depth=1 origin "$PUSH_BASE" && git diff --name-only --no-renames -z "$PUSH_BASE" HEAD > "$RUNNER_TEMP/node-server-changes"; then - node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" --full-qualification + node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" --defer-graph --full-qualification else echo 'should_run=true' >> "$GITHUB_OUTPUT" fi @@ -102,11 +105,26 @@ jobs: fi # Compare the tested merge with its base, retaining both sides of renames. if git diff --name-only --no-renames -z HEAD^1 HEAD > "$RUNNER_TEMP/node-server-changes"; then - node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" + node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" --defer-graph else echo 'should_run=true' >> "$GITHUB_OUTPUT" fi + - uses: ./.github/actions/install-node-dependencies + if: steps.scope.outputs.graph_required == 'true' + - name: Check the headless import graph + id: graph + if: steps.scope.outputs.graph_required == 'true' + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + run: | + if [ "$EVENT_NAME" = push ]; then + node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" --full-qualification + else + node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" + fi + persistence: needs: changes concurrency: diff --git a/config/scripts/node-server-change-scope.mjs b/config/scripts/node-server-change-scope.mjs index 7badba9fa98..ae01e9a8a94 100644 --- a/config/scripts/node-server-change-scope.mjs +++ b/config/scripts/node-server-change-scope.mjs @@ -1,12 +1,6 @@ -import { build } from 'esbuild' import { appendFileSync, globSync, readFileSync } from 'node:fs' import { resolve } from 'node:path' import { pathToFileURL } from 'node:url' -import { - externalNativeAddons, - ORCAD_CHILD_ENTRY_POINTS, - ORCAD_ENTRY_POINT -} from './orcad-entry-build.mjs' import { nodeServerTestPaths } from './node-server-test-paths.mjs' import { nodeServerQualification } from './node-server-qualification.mjs' @@ -68,6 +62,8 @@ export function discoverNodeServerTests(root = ROOT) { } export async function collectNodeServerInputs({ root = ROOT, entryPoints } = {}) { + const [{ build }, { externalNativeAddons, ORCAD_CHILD_ENTRY_POINTS, ORCAD_ENTRY_POINT }] = + await Promise.all([import('esbuild'), import('./orcad-entry-build.mjs')]) const entries = entryPoints ?? [ ORCAD_ENTRY_POINT, ...Object.values(ORCAD_CHILD_ENTRY_POINTS), @@ -99,7 +95,11 @@ export async function collectNodeServerInputs({ root = ROOT, entryPoints } = {}) ) } -export async function classifyNodeServerChanges(changedFiles, collect = collectNodeServerInputs) { +export async function classifyNodeServerChanges( + changedFiles, + collect = collectNodeServerInputs, + { deferGraph = false } = {} +) { if (changedFiles.length === 0) { return { shouldRun: true, reason: 'No complete changed-file evidence' } } @@ -113,6 +113,9 @@ export async function classifyNodeServerChanges(changedFiles, collect = collectN if (forced) { return { shouldRun: true, reason: `Build or CI input changed: ${forced}` } } + if (deferGraph) { + return { graphRequired: true, reason: 'Installed dependencies are needed to check imports' } + } try { const inputs = await collect() const matched = changedFiles.find((file) => inputs.has(file)) @@ -133,12 +136,16 @@ export async function classifyNodeServerChanges(changedFiles, collect = collectN if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { const changedFiles = readFileSync(process.argv[2], 'utf8').split('\0').filter(Boolean) - const result = await classifyNodeServerChanges(changedFiles) + const result = await classifyNodeServerChanges(changedFiles, collectNodeServerInputs, { + deferGraph: process.argv.includes('--defer-graph') + }) console.log(result.reason) const policy = nodeServerQualification(changedFiles, result, { fullQualification: process.argv.includes('--full-qualification') }) - const output = `should_run=${result.shouldRun}\nqualification=${policy.qualification}\nrunners=${JSON.stringify(policy.runners)}\n` + const output = result.graphRequired + ? 'graph_required=true\n' + : `should_run=${result.shouldRun}\nqualification=${policy.qualification}\nrunners=${JSON.stringify(policy.runners)}\n` if (process.env.GITHUB_OUTPUT) { appendFileSync(process.env.GITHUB_OUTPUT, output) } else { diff --git a/config/scripts/node-server-change-scope.test.mjs b/config/scripts/node-server-change-scope.test.mjs index 0ff118ed678..7ae6a07f11e 100644 --- a/config/scripts/node-server-change-scope.test.mjs +++ b/config/scripts/node-server-change-scope.test.mjs @@ -1,4 +1,4 @@ -import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, beforeAll, describe, expect, it } from 'vitest' @@ -11,6 +11,8 @@ import { import { nodeServerTestPaths } from './node-server-test-paths.mjs' import { ORCAD_CHILD_ENTRY_POINTS } from './orcad-entry-build.mjs' import { NODE_RUNTIME_PIN } from '../../src/shared/node-runtime-pin.ts' +import { runProcessSync } from './script-child-process.mjs' +import { NODE_SERVER_RUNNERS } from './node-server-qualification.mjs' const temporaryDirs = [] afterEach(() => { @@ -91,6 +93,80 @@ it.each([ expect((await classifyNodeServerChanges([file], async () => new Set())).shouldRun).toBe(true) }) +it('defers uncertain paths without issuing a qualification verdict', async () => { + const result = await classifyNodeServerChanges( + ['src/renderer/src/example.ts'], + async () => { + throw new Error('graph must not run before installation') + }, + { deferGraph: true } + ) + expect(result.graphRequired).toBe(true) + expect(result.shouldRun).toBeUndefined() +}) + +it.each([ + { files: [], deferred: true, expected: 'should_run=true' }, + { files: ['package.json'], deferred: true, expected: 'should_run=true' }, + { + files: ['src/main/persistence/profile-state/profile-state-windows.ts'], + deferred: true, + fullQualification: false, + qualification: false, + runners: ['ubuntu-22.04', 'windows-2022', 'windows-11-arm'], + expected: 'should_run=true' + }, + { + files: ['src/main/persistence/profile-state/profile-state-windows.ts'], + deferred: true, + expected: 'should_run=true' + }, + { + files: ['src/main/providers/provider-windows.ts'], + deferred: true, + fullQualification: false, + expected: 'should_run=true' + }, + { files: ['src/renderer/src/example.ts'], deferred: true, expected: 'graph_required=true' }, + { files: ['src/renderer/src/example.ts'], deferred: false, expected: 'should_run=true' } +])('fails closed or requests dependencies in an uninstalled checkout: %j', (scenario) => { + const root = moduleTree( + Object.fromEntries( + ['node-server-change-scope', 'node-server-test-paths', 'node-server-qualification'].map( + (name) => [ + `config/scripts/${name}.mjs`, + readFileSync(new URL(`./${name}.mjs`, import.meta.url), 'utf8') + ] + ) + ) + ) + const changes = join(root, 'changes') + const stepOutput = join(root, 'step-output') + writeFileSync(changes, scenario.files.map((file) => `${file}\0`).join('')) + const result = runProcessSync({ + program: process.execPath, + args: [ + realpathSync(join(root, 'config/scripts/node-server-change-scope.mjs')), + changes, + ...(scenario.fullQualification === false ? [] : ['--full-qualification']), + ...(scenario.deferred ? ['--defer-graph'] : []) + ], + cwd: root, + env: { ...process.env, GITHUB_OUTPUT: stepOutput }, + timeoutMs: 5_000 + }) + expect(result.code).toBe(0) + const output = readFileSync(stepOutput, 'utf8') + expect(output).toContain(scenario.expected) + if (scenario.expected === 'graph_required=true') { + expect(output).not.toContain('should_run=') + expect(output).not.toContain('runners=') + } else { + expect(output).toContain(`qualification=${scenario.qualification !== false}`) + expect(output).toContain(`runners=${JSON.stringify(scenario.runners ?? NODE_SERVER_RUNNERS)}`) + } +}) + describe('the actual Bun build and profile-test dependency graph', () => { let inputs beforeAll(async () => { @@ -149,7 +225,7 @@ it('keeps every platform job and runs them when detection is skipped or fails', expect(detect.env.PUSH_BASE).toBe('${{ github.event.before }}') expect(detect.run).toContain('git fetch --no-tags --depth=1 origin "$PUSH_BASE"') expect(detect.run).toContain('git diff --name-only --no-renames -z "$PUSH_BASE" HEAD') - expect(detect.run).toContain('node-server-changes" --full-qualification') + expect(detect.run).toContain('node-server-changes" --defer-graph --full-qualification') expect(workflow.on.pull_request.types).toContain('ready_for_review') expect(workflow.on.schedule).toHaveLength(1) // A pull request may qualify one platform, so the merged commit must re-qualify all six. diff --git a/docs/reference/ci-demand-rollout.md b/docs/reference/ci-demand-rollout.md index c1be4219d6b..00dfdca4f5f 100644 --- a/docs/reference/ci-demand-rollout.md +++ b/docs/reference/ci-demand-rollout.md @@ -10,24 +10,34 @@ or a guaranteed forecast of savings. ## What runs now -| Work | Ordinary draft update | Ready PR / final checks | Main reference | -| -------------------------------------- | ------------------------------------------------------------------------------ | --------------------------------------------- | --------------------------------------- | -| Static analysis and types | Immediately | Immediately | Existing workflows | -| Unit suite | Full, with shadow selection evidence | Full | Existing daily Node 24/26 x86 suite | -| Packages | After successful static analysis and types | Same | Existing release workflows | -| Headless Node persistence | Linux x64 for ordinary runtime changes; all six platforms for sensitive inputs | All six platforms when server inputs changed | Full nightly qualification at 11:30 UTC | -| Headless Node glibc/musl qualification | Sensitive inputs only, after persistence succeeds | Both architectures after persistence succeeds | Both architectures | -| E2E | Existing targeted routing | Existing targeted routing | One complete run at 17:00 UTC | +| Work | Ordinary draft update | Ready PR / final checks | Main reference | +| -------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| Static analysis and types | Immediately | Immediately | Existing workflows | +| Unit suite | Full, with shadow selection evidence | Full | Existing daily Node 24/26 x86 suite | +| Packages | After successful static analysis and types | Same | Existing release workflows | +| Headless Node persistence | Deferred until ready | Linux x64 for ordinary runtime changes; explicit platform families or all six for sensitive inputs | All six platforms for relevant main pushes; full nightly qualification at 11:30 UTC | +| Headless Node glibc/musl qualification | Deferred until ready | Linux-specific or full qualification, after persistence succeeds | Both architectures for relevant main pushes and nightly qualification | +| E2E | Existing targeted routing | Existing targeted routing | One complete run at 17:00 UTC | -Headless-runtime-sensitive inputs include root/toolchain files, configuration, native code, -resources, platform-specific paths, persistence, SQLite, orcad, providers, -daemon, SSH, relay, and child-process code. Dependency discovery failure, a -missing event or an incomplete diff retains full qualification. An unrelated -change still skips these lanes through the existing dependency classifier. No native -artifact is shared across platforms or ABIs. The routine draft reduction is an -explicit coverage-placement change; it does not assert identical per-update -coverage. Every non-draft synchronize event and the ready-for-review event -restores full qualification. +Headless draft updates carry no verdict; readiness starts the checks. Relevant +ready PRs retain Linux x64 smoke coverage. Explicit Windows or macOS paths add both +architectures in that family, while Linux paths add Linux ARM and the glibc/musl +lanes. Root/toolchain inputs, native build inputs, shared execution/storage paths, +SSH, providers and relay changes retain every platform. Missing or incomplete +change evidence and failed import analysis also retain full qualification. +Unrelated changes skip through the dependency classifier. Relevant main pushes +qualify all six platforms and both Linux compatibility architectures; detection +uses the whole push before qualification can supersede an older relevant run. + +The detector checks known build inputs with the pinned Node toolchain first. +Other paths install dependencies for import analysis; an uncertain result never +skips qualification. This changes setup cost, not the qualification policy. + +Windows server prebuilds are cached separately by architecture and pinned build +inputs, including the runner image. Only successful qualification on main publishes +them. Consumers validate the payload and still run the pinned-Node load/spawn smoke; +a cache miss or invalid payload builds fresh. Nightly, manual and release builds +remain fresh. No native artifact is shared across platforms or ABIs. Expensive PR jobs wait for static/type success. This reduces fan-out for failed or rapidly superseded commits without sleeping on a runner. Successful isolated From b94c75c4bd6b1e7454e614b489c0ffd591970ec0 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:54:04 -0700 Subject: [PATCH 17/21] Reuse pnpm verification records in Alpine CI (#24817) * ci: reuse pnpm verification records in Alpine builders * ci: qualify consumers of the verification restore action * ci: match Linux verification cache archive paths --- .../install-node-dependencies/action.yml | 27 +--- .../restore-pnpm-verification/action.yml | 58 ++++++++ .github/workflows/ci-cache-warmup.yml | 1 + .github/workflows/mobile.yml | 1 + .github/workflows/node-server-tests.yml | 10 ++ .github/workflows/ssh-hostile-hosts.yml | 9 ++ .github/workflows/ssh-windows-hosts.yml | 1 + config/scripts/ci-native-cache.test.mjs | 4 +- .../ci-pnpm-verification-cache.test.mjs | 129 +++++++++++++++--- config/scripts/node-server-change-scope.mjs | 1 + .../scripts/node-server-change-scope.test.mjs | 1 + config/scripts/node-server-qualification.mjs | 1 + .../node-server-qualification.test.mjs | 1 + config/scripts/pr-code-change-scope.mjs | 1 + 14 files changed, 205 insertions(+), 40 deletions(-) create mode 100644 .github/actions/restore-pnpm-verification/action.yml diff --git a/.github/actions/install-node-dependencies/action.yml b/.github/actions/install-node-dependencies/action.yml index 053fcdbe0fb..916baf446b7 100644 --- a/.github/actions/install-node-dependencies/action.yml +++ b/.github/actions/install-node-dependencies/action.yml @@ -30,7 +30,7 @@ inputs: outputs: verification-cache-hit: description: Whether pnpm's verification record was restored. - value: ${{ steps.verification-cache-restore.outputs.cache-hit }} + value: ${{ steps.verification-cache.outputs.cache-hit }} verification-cache-path: description: The small pnpm-owned verification record, without registry metadata. value: ${{ steps.verification-cache.outputs.path }} @@ -110,28 +110,11 @@ runs: path: ${{ steps.pnpm-store.outputs.path }} key: node-cache-${{ runner.os }}-${{ steps.pnpm-store.outputs.arch }}-pnpm-${{ hashFiles(inputs.cache-dependency-path) }} - - name: Resolve pnpm verification cache - id: verification-cache - if: >- - inputs.cache-pnpm-verification == 'true' && - (runner.os == 'Linux' || - (runner.os == 'Windows' && (runner.arch == 'X64' || runner.arch == 'ARM64')) || - (runner.os == 'macOS' && runner.arch == 'X64')) - shell: bash - env: - POLICY_HASH: ${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc') }} - run: | - printf 'path=%s/lockfile-verified.jsonl\n' "$(pnpm cache path)" >> "$GITHUB_OUTPUT" - printf 'key=pnpm-verification-v1-%s-%s-%s-%s\n' "$RUNNER_OS" "$RUNNER_ARCH" "$(pnpm --version)" "$POLICY_HASH" >> "$GITHUB_OUTPUT" - - name: Restore pnpm verification record - id: verification-cache-restore - if: steps.verification-cache.outputs.key != '' - continue-on-error: true - uses: actions/cache/restore@v5 + id: verification-cache + uses: ./.github/actions/restore-pnpm-verification with: - path: ${{ steps.verification-cache.outputs.path }} - key: ${{ steps.verification-cache.outputs.key }} + enabled: ${{ inputs.cache-pnpm-verification }} - name: Validate native runtime shell: bash @@ -169,7 +152,7 @@ runs: # pnpm checks the cached record's policy and validity; never bypass verification. - name: Save pnpm verification record on main - if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' && steps.verification-cache.outputs.key != '' && steps.verification-cache-restore.outputs.cache-hit != 'true' + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' && steps.verification-cache.outputs.key != '' && steps.verification-cache.outputs.cache-hit != 'true' continue-on-error: true uses: actions/cache/save@v5 with: diff --git a/.github/actions/restore-pnpm-verification/action.yml b/.github/actions/restore-pnpm-verification/action.yml new file mode 100644 index 00000000000..fcd428f7c80 --- /dev/null +++ b/.github/actions/restore-pnpm-verification/action.yml @@ -0,0 +1,58 @@ +name: Restore pnpm verification +description: Restore the pnpm-owned lockfile verdict without installing dependencies. + +inputs: + enabled: + description: Restore a verification record on supported runners. + default: 'true' + container-toolchain: + description: Use the manifest's pnpm version and default Linux cache path without installing host pnpm. + default: 'false' + +outputs: + path: + description: The pnpm-owned verification record. + value: ${{ steps.verification-cache.outputs.path }} + key: + description: Exact OS, architecture, pnpm version and policy key. + value: ${{ steps.verification-cache.outputs.key }} + cache-hit: + description: Whether the exact record was restored. + value: ${{ steps.verification-cache-restore.outputs.cache-hit }} + +runs: + using: composite + steps: + - name: Resolve pnpm verification cache + id: verification-cache + if: >- + inputs.enabled == 'true' && + (runner.os == 'Linux' || + (runner.os == 'Windows' && (runner.arch == 'X64' || runner.arch == 'ARM64')) || + (runner.os == 'macOS' && runner.arch == 'X64')) + shell: bash + env: + POLICY_HASH: ${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc') }} + CONTAINER_TOOLCHAIN: ${{ inputs.container-toolchain }} + run: | + if [ "$CONTAINER_TOOLCHAIN" = true ]; then + test "$RUNNER_OS" = Linux + # Cache versions include paths, so match the native Linux producer. + cache_path="$(node -p "require('node:path').join(process.env.XDG_CACHE_HOME || require('node:path').join(require('node:os').homedir(), '.cache'), 'pnpm')")" + pnpm_version="$(node -p "require('./package.json').packageManager.match(/^pnpm@([^+]+)/)[1]")" + mkdir -p "$cache_path" + else + cache_path="$(pnpm cache path)" + pnpm_version="$(pnpm --version)" + fi + printf 'path=%s/lockfile-verified.jsonl\n' "$cache_path" >> "$GITHUB_OUTPUT" + printf 'key=pnpm-verification-v1-%s-%s-%s-%s\n' "$RUNNER_OS" "$RUNNER_ARCH" "$pnpm_version" "$POLICY_HASH" >> "$GITHUB_OUTPUT" + + - name: Restore pnpm verification record + id: verification-cache-restore + if: steps.verification-cache.outputs.key != '' + continue-on-error: true + uses: actions/cache/restore@v5 + with: + path: ${{ steps.verification-cache.outputs.path }} + key: ${{ steps.verification-cache.outputs.key }} diff --git a/.github/workflows/ci-cache-warmup.yml b/.github/workflows/ci-cache-warmup.yml index 84ed72cc68b..daad00afb60 100644 --- a/.github/workflows/ci-cache-warmup.yml +++ b/.github/workflows/ci-cache-warmup.yml @@ -9,6 +9,7 @@ on: paths: - '.github/workflows/ci-cache-warmup.yml' - '.github/actions/install-node-dependencies/**' + - '.github/actions/restore-pnpm-verification/**' - '.github/actions/prepare-native-runtime/**' - '.github/actions/prepare-git-compatibility/**' - '.github/actions/prepare-linux-package-fixture/**' diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index 8835c6876b3..9144c36d43a 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -26,6 +26,7 @@ on: # it or to the release workflow it guards must re-run them. - '.github/workflows/mobile.yml' - '.github/actions/install-node-dependencies/**' + - '.github/actions/restore-pnpm-verification/**' - '.github/workflows/mobile-ios-release.yml' - 'config/scripts/mobile-release-check-scope*' - 'config/scripts/mobile-test-change-scope*' diff --git a/.github/workflows/node-server-tests.yml b/.github/workflows/node-server-tests.yml index a8500be97db..16ba68fa6fa 100644 --- a/.github/workflows/node-server-tests.yml +++ b/.github/workflows/node-server-tests.yml @@ -16,6 +16,7 @@ on: - '.npmrc' - '.pnpmfile.cjs' - '.github/actions/install-node-dependencies/**' + - '.github/actions/restore-pnpm-verification/**' - '.github/actions/prepare-native-runtime/**' - '.github/actions/prepare-orcad-prebuilds/**' - '.github/workflows/node-server-tests.yml' @@ -35,6 +36,7 @@ on: - '.npmrc' - '.pnpmfile.cjs' - '.github/actions/install-node-dependencies/**' + - '.github/actions/restore-pnpm-verification/**' - '.github/actions/prepare-native-runtime/**' - '.github/actions/prepare-orcad-prebuilds/**' - '.github/workflows/node-server-tests.yml' @@ -381,11 +383,19 @@ jobs: with: ref: ${{ inputs.ref }} persist-credentials: false + - uses: ./.github/actions/restore-pnpm-verification + id: pnpm-verification + with: + container-toolchain: 'true' - name: Verify native Alpine artifact and persistence + env: + VERIFICATION_CACHE_PATH: ${{ steps.pnpm-verification.outputs.path }} run: | + touch "$VERIFICATION_CACHE_PATH" # Multi-arch index digest of the tag; re-resolve it whenever NODE_RUNTIME_PIN moves. docker run --rm --init -i \ -e ORCA_BACKGROUND_LAUNCH=1 \ + -v "$VERIFICATION_CACHE_PATH:/root/.cache/pnpm/lockfile-verified.jsonl" \ -v "$GITHUB_WORKSPACE:/work" -w /work \ node:24.21.0-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 sh -s <<'NODE_SERVER_QUALIFICATION' set -eu diff --git a/.github/workflows/ssh-hostile-hosts.yml b/.github/workflows/ssh-hostile-hosts.yml index 4a54c81c7ed..e55ac8f99e8 100644 --- a/.github/workflows/ssh-hostile-hosts.yml +++ b/.github/workflows/ssh-hostile-hosts.yml @@ -27,6 +27,7 @@ on: - '!src/**/*.test.ts' - 'src/main/ssh/ssh-relay-hostile-hosts.docker.test.ts' - '.github/workflows/ssh-hostile-hosts.yml' + - '.github/actions/restore-pnpm-verification/**' workflow_dispatch: inputs: cells: @@ -99,11 +100,19 @@ jobs: with: name: hostile-hosts-glibc-slot path: out/orcad-prebuilds + - uses: ./.github/actions/restore-pnpm-verification + id: pnpm-verification + with: + container-toolchain: 'true' - name: Build and smoke the linux-x64-musl slot on Alpine + env: + VERIFICATION_CACHE_PATH: ${{ steps.pnpm-verification.outputs.path }} run: | + touch "$VERIFICATION_CACHE_PATH" # Same digest as the headless-server musl lane; re-resolve it whenever NODE_RUNTIME_PIN moves. docker run --rm --init -i \ -e ORCA_BACKGROUND_LAUNCH=1 \ + -v "$VERIFICATION_CACHE_PATH:/root/.cache/pnpm/lockfile-verified.jsonl" \ -v "$GITHUB_WORKSPACE:/work" -w /work \ node:24.21.0-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 sh -s <<'MUSL_SLOT' set -eu diff --git a/.github/workflows/ssh-windows-hosts.yml b/.github/workflows/ssh-windows-hosts.yml index a677bd8589e..652551fc1d9 100644 --- a/.github/workflows/ssh-windows-hosts.yml +++ b/.github/workflows/ssh-windows-hosts.yml @@ -36,6 +36,7 @@ on: - 'config/ci/windows-ssh-provider/**' - '.github/workflows/ssh-windows-hosts.yml' - '.github/actions/prepare-orcad-prebuilds/**' + - '.github/actions/restore-pnpm-verification/**' - 'config/scripts/orcad-windows-prebuild-cache.mjs' workflow_dispatch: inputs: diff --git a/config/scripts/ci-native-cache.test.mjs b/config/scripts/ci-native-cache.test.mjs index 6851ed62f9b..045cc5de6d8 100644 --- a/config/scripts/ci-native-cache.test.mjs +++ b/config/scripts/ci-native-cache.test.mjs @@ -123,8 +123,8 @@ describe('CI native cache ownership', () => { installerPath, Buffer.from( readFileSync(installerPath, 'utf8').replace( - "inputs.cache-pnpm-verification == 'true'", - "inputs.cache-pnpm-verification == 'false'" + 'enabled: ${{ inputs.cache-pnpm-verification }}', + "enabled: 'false'" ) ) ) diff --git a/config/scripts/ci-pnpm-verification-cache.test.mjs b/config/scripts/ci-pnpm-verification-cache.test.mjs index c64d019d06c..b878b3ef046 100644 --- a/config/scripts/ci-pnpm-verification-cache.test.mjs +++ b/config/scripts/ci-pnpm-verification-cache.test.mjs @@ -1,21 +1,116 @@ -import { readFileSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { runInNewContext } from 'node:vm' import { describe, expect, it } from 'vitest' import { parse } from 'yaml' +import { classifyPrJobs, PR_CHECK_JOBS } from './pr-code-change-scope.mjs' +import { runProcessSync } from './script-child-process.mjs' const action = parse(readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8')) const steps = action.runs.steps -const resolve = steps.find((step) => step.id === 'verification-cache') -const restore = steps.find((step) => step.id === 'verification-cache-restore') +const cache = steps.find((step) => step.id === 'verification-cache') +const cacheAction = parse( + readFileSync('.github/actions/restore-pnpm-verification/action.yml', 'utf8') +) +const resolve = cacheAction.runs.steps.find((step) => step.id === 'verification-cache') +const restore = cacheAction.runs.steps.find((step) => step.id === 'verification-cache-restore') const install = steps.find((step) => step.name === 'Install dependencies') const save = steps.find((step) => step.name === 'Save pnpm verification record on main') -const evaluate = (expression, context) => - runInNewContext( - expression.replaceAll('inputs.cache-pnpm-verification', 'inputs["cache-pnpm-verification"]'), - context - ) +const evaluate = (expression, context) => runInNewContext(expression, context) + +function resolveRecord({ + container = false, + os = 'Linux', + packageManager = 'pnpm@12.8.1+sha512.test' +} = {}) { + const root = mkdtempSync(join(tmpdir(), 'orca-verification-record-')) + try { + const output = join(root, 'outputs') + const calls = join(root, 'pnpm-calls') + const cacheRoot = join(root, 'cache with spaces') + const cachePath = join(cacheRoot, 'pnpm') + writeFileSync(join(root, 'package.json'), JSON.stringify({ packageManager })) + const execution = runProcessSync({ + program: 'bash', + args: [ + '-e', + '-o', + 'pipefail', + '-c', + ` + pnpm() { + printf '%s\\n' "$*" >> "$TEST_CALLS" + case "$*" in + 'cache path') printf '%s\\n' "$TEST_CACHE_PATH" ;; + '--version') printf '%s\\n' '12.8.1' ;; + *) return 2 ;; + esac + } + ${resolve.run} + ` + ], + cwd: root, + env: { + ...process.env, + RUNNER_OS: os, + RUNNER_ARCH: 'ARM64', + POLICY_HASH: 'policy-digest', + GITHUB_OUTPUT: output, + TEST_CALLS: calls, + TEST_CACHE_PATH: cachePath, + CONTAINER_TOOLCHAIN: String(container), + XDG_CACHE_HOME: cacheRoot + } + }) + return { + code: execution.code, + output: existsSync(output) ? readFileSync(output, 'utf8').replaceAll(root, '') : '', + calls: existsSync(calls) ? readFileSync(calls, 'utf8') : '' + } + } finally { + rmSync(root, { recursive: true, force: true }) + } +} describe('pnpm-owned verification record', () => { + it('qualifies every shared-installer consumer when verification restoration changes', () => { + const result = classifyPrJobs(['.github/actions/restore-pnpm-verification/action.yml']) + for (const job of PR_CHECK_JOBS) { + expect(result[job], job).toBe(true) + } + }) + + it.skipIf(process.platform === 'win32')( + 'shares the exact key and archive path with containers without invoking host pnpm', + () => { + const host = resolveRecord() + const container = resolveRecord({ container: true }) + expect(host.code).toBe(0) + expect(container.code).toBe(0) + expect(container.output).toBe(host.output) + expect(container.output).toContain( + 'path=/cache with spaces/pnpm/lockfile-verified.jsonl' + ) + expect(container.output).toContain( + 'key=pnpm-verification-v1-Linux-ARM64-12.8.1-policy-digest' + ) + expect(host.calls).toBe('cache path\n--version\n') + expect(container.calls).toBe('') + } + ) + + it.skipIf(process.platform === 'win32').each([ + { container: true, os: 'Windows' }, + { container: true, os: 'macOS' }, + { container: true, packageManager: 'npm@12.8.1' } + ])('rejects an unsupported container identity: %j', (options) => { + const result = resolveRecord(options) + expect(result.code).not.toBe(0) + expect(result.output).toBe('') + expect(result.calls).toBe('') + }) + it.each([ ['Linux', 'X64', true], ['Linux', 'ARM64', true], @@ -31,7 +126,7 @@ describe('pnpm-owned verification record', () => { expect( evaluate(resolve.if, { runner: { os, arch }, - inputs: { 'cache-pnpm-verification': enabled } + inputs: { enabled } }) ).toBe(expected && enabled === 'true') } @@ -44,12 +139,15 @@ describe('pnpm-owned verification record', () => { expect(resolve.run).toContain('"$(pnpm cache path)"') expect(resolve.run).toContain('lockfile-verified.jsonl') expect(resolve.run).toContain('pnpm-verification-v1-%s-%s-%s-%s') - expect(resolve.run).toContain('"$RUNNER_OS" "$RUNNER_ARCH" "$(pnpm --version)" "$POLICY_HASH"') + expect(resolve.run).toContain('pnpm_version="$(pnpm --version)"') + expect(resolve.run).toContain('"$RUNNER_OS" "$RUNNER_ARCH" "$pnpm_version" "$POLICY_HASH"') + expect(cache.uses).toBe('./.github/actions/restore-pnpm-verification') + expect(cache.with.enabled).toBe('${{ inputs.cache-pnpm-verification }}') expect(restore.uses).toBe('actions/cache/restore@v5') expect(restore.with.path).toBe('${{ steps.verification-cache.outputs.path }}') expect(restore.with['restore-keys']).toBeUndefined() expect(restore['continue-on-error']).toBe(true) - expect(steps.indexOf(restore)).toBeLessThan(steps.indexOf(install)) + expect(steps.indexOf(cache)).toBeLessThan(steps.indexOf(install)) expect(install.if).toBeUndefined() expect(install.run).toContain('pnpm install --frozen-lockfile --ignore-scripts') }) @@ -64,21 +162,20 @@ describe('pnpm-owned verification record', () => { const context = { github: { event_name: event, ref }, steps: { - 'verification-cache': { outputs: { key: 'a-key' } }, - 'verification-cache-restore': { outputs: { 'cache-hit': 'false' } } + 'verification-cache': { outputs: { key: 'a-key', 'cache-hit': 'false' } } } } const expression = save.if .replaceAll( - 'steps.verification-cache-restore.outputs.cache-hit', - 'steps["verification-cache-restore"].outputs["cache-hit"]' + 'steps.verification-cache.outputs.cache-hit', + 'steps["verification-cache"].outputs["cache-hit"]' ) .replaceAll('steps.verification-cache.outputs.key', 'steps["verification-cache"].outputs.key') expect(evaluate(expression, context)).toBe(expected) context.steps['verification-cache'].outputs.key = '' expect(evaluate(expression, context)).toBe(false) context.steps['verification-cache'].outputs.key = 'a-key' - context.steps['verification-cache-restore'].outputs['cache-hit'] = 'true' + context.steps['verification-cache'].outputs['cache-hit'] = 'true' expect(evaluate(expression, context)).toBe(false) expect(save.uses).toBe('actions/cache/save@v5') expect(steps.indexOf(save)).toBeGreaterThan(steps.indexOf(install)) diff --git a/config/scripts/node-server-change-scope.mjs b/config/scripts/node-server-change-scope.mjs index ae01e9a8a94..78c5e572677 100644 --- a/config/scripts/node-server-change-scope.mjs +++ b/config/scripts/node-server-change-scope.mjs @@ -36,6 +36,7 @@ const ALWAYS_FILES = new Set([ ]) const ALWAYS_PREFIXES = [ '.github/actions/install-node-dependencies/', + '.github/actions/restore-pnpm-verification/', '.github/actions/prepare-native-runtime/', '.github/actions/prepare-orcad-prebuilds/', // These areas also contain worker paths and fixtures opened without an import. diff --git a/config/scripts/node-server-change-scope.test.mjs b/config/scripts/node-server-change-scope.test.mjs index 7ae6a07f11e..88ec2c446bb 100644 --- a/config/scripts/node-server-change-scope.test.mjs +++ b/config/scripts/node-server-change-scope.test.mjs @@ -85,6 +85,7 @@ it.each([ 'config/patches/node-pty@1.1.0.patch', 'native/windows-registry/src/addon.cc', '.github/actions/install-node-dependencies/action.yml', + '.github/actions/restore-pnpm-verification/action.yml', '.github/actions/prepare-native-runtime/action.yml', '.github/actions/prepare-orcad-prebuilds/action.yml', '.github/workflows/node-server-tests.yml', diff --git a/config/scripts/node-server-qualification.mjs b/config/scripts/node-server-qualification.mjs index 9901b609a9b..cb556e6d1d3 100644 --- a/config/scripts/node-server-qualification.mjs +++ b/config/scripts/node-server-qualification.mjs @@ -12,6 +12,7 @@ const BUILD_PREFIXES = [ 'native/', 'config/patches/', '.github/actions/install-node-dependencies/', + '.github/actions/restore-pnpm-verification/', '.github/actions/prepare-native-runtime/', '.github/actions/prepare-orcad-prebuilds/' ] diff --git a/config/scripts/node-server-qualification.test.mjs b/config/scripts/node-server-qualification.test.mjs index 6018fe6a7e4..b7692af0d46 100644 --- a/config/scripts/node-server-qualification.test.mjs +++ b/config/scripts/node-server-qualification.test.mjs @@ -27,6 +27,7 @@ it.each([ 'native/windows-registry/src/addon.cc', 'config/patches/node-pty.patch', '.github/actions/install-node-dependencies/action.yml', + '.github/actions/restore-pnpm-verification/action.yml', '.github/actions/prepare-native-runtime/action.yml', '.github/actions/prepare-orcad-prebuilds/action.yml', 'src/main/ssh/ssh-provider.ts', diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 7f34a8304a9..c6448f367ef 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -38,6 +38,7 @@ const ALWAYS_ON_CODE_JOBS = new Set(['static_analysis', 'typecheck', 'test']) const GLOBAL_FORCE_PREFIXES = [ '.github/workflows/pr.yml', '.github/actions/install-node-dependencies/', + '.github/actions/restore-pnpm-verification/', '.github/actions/prepare-native-runtime/', 'config/scripts/pr-code-change-scope' ] From cc73c8e1a72b0e9ee9c29e57458ce307f5f019c2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:00:49 -0700 Subject: [PATCH 18/21] ci: overlap ARM SSH setup and independent observation waits (#24714) --- .github/workflows/ssh-windows-hosts.yml | 25 ++- .../preview-ssh/prove-preview-openssh.ps1 | 88 +++++----- .../preview-ssh/test-preview-diagnostics.ps1 | 163 +++++++++++++++++- .../preview-ssh/windows-ssh-capability.ps1 | 61 +++++++ .../ssh-windows-hosts-workflow.test.mjs | 101 +++++++++++ docs/reference/ci-runner-efficiency.md | 91 ++++++++++ .../structured-chat-coordinator-mail.test.ts | 41 +++-- ...rdinator-observation-clock.test-fixture.ts | 55 ++++++ 8 files changed, 556 insertions(+), 69 deletions(-) create mode 100644 config/ci/windows-ssh-provider/preview-ssh/windows-ssh-capability.ps1 create mode 100644 src/main/runtime/structured-chat-coordinator-observation-clock.test-fixture.ts diff --git a/.github/workflows/ssh-windows-hosts.yml b/.github/workflows/ssh-windows-hosts.yml index 652551fc1d9..cd77ee87b67 100644 --- a/.github/workflows/ssh-windows-hosts.yml +++ b/.github/workflows/ssh-windows-hosts.yml @@ -84,10 +84,6 @@ jobs: - uses: actions/checkout@v6 with: persist-credentials: false - - uses: ./.github/actions/install-node-dependencies - id: dependencies - with: - native-runtime: node - name: Self-test the provisioning scripts before touching the machine shell: pwsh run: | @@ -97,6 +93,22 @@ jobs: if($errors.Count){throw "PowerShell parse failed: $($file.FullName)"} } & config/ci/windows-ssh-provider/preview-ssh/test-preview-diagnostics.ps1 + # ARM inbox servicing can finish while the independent Node artifacts are prepared. + - name: Prepare the Windows inbox SSH capability + id: inbox-capability + background: true + shell: pwsh + run: | + if('${{ matrix.server }}' -eq 'inbox' -and '${{ matrix.arch }}' -eq 'arm64'){ + $receipts=Join-Path $pwd '.build/ssh-windows-host-receipts' + New-Item -ItemType Directory -Force -Path $receipts | Out-Null + . config/ci/windows-ssh-provider/preview-ssh/windows-ssh-capability.ps1 + Initialize-WindowsInboxSshCapability -Arch '${{ matrix.arch }}' -Receipt (Join-Path $receipts 'inbox-capability-preparation.json') + } + - uses: ./.github/actions/install-node-dependencies + id: dependencies + with: + native-runtime: node # The deploy materializes rung A from this template; only this runner's slot exists here. # The process-tree addon carries the launcher that starts the relay outside sshd's job; the # orcad slot and the relay both stage it, and a standard-user host has no other launch route. @@ -122,6 +134,7 @@ jobs: pnpm build:orcad-prebuilds --require-slots "win32-${{ matrix.arch }}" node config/scripts/build-orcad-template.mjs --targets "win32-${{ matrix.arch }}" pnpm run build:relay + - wait: inbox-capability - name: Run the Windows host cells against a private ${{ matrix.server }} sshd shell: pwsh timeout-minutes: 50 @@ -135,6 +148,8 @@ jobs: $cells=@($env:CELLS -split ',' | ForEach-Object {$_.Trim()} | Where-Object {$_}) if(-not $cells.Count){$cells=@('pinned-cmd','pinned-powershell','legacy-opt-out')} $archive='' + $preparation='' + if('${{ matrix.server }}' -eq 'inbox' -and '${{ matrix.arch }}' -eq 'arm64'){$preparation=Join-Path $receipts 'inbox-capability-preparation.json'} if('${{ matrix.server }}' -eq 'preview'){ $archive=Join-Path $env:RUNNER_TEMP 'preview-${{ matrix.archive }}' # The provisioning script refuses the archive unless its sha256 and every binary's match the pin. @@ -144,7 +159,7 @@ jobs: $callback={param($context) & (Join-Path $tools 'invoke-pinned-relay-cells.ps1') -SourceRoot $sourceRoot -Context $context -Target 'win32-${{ matrix.arch }}' -ReceiptRoot $receipts -Cells $cells }.GetNewClosure() - & (Join-Path $tools 'preview-ssh/prove-preview-openssh.ps1') -Archive $archive -Arch '${{ matrix.arch }}' -Server '${{ matrix.server }}' -Receipt (Join-Path $receipts 'provider-server.json') -Accounts $cells.Count -HiddenTools @('npm','npx','node-gyp','gcc','g++','cc','c++','make','cl','clang','clang++','msbuild','cmake') -HostCellProbe $callback 2>&1 | Tee-Object (Join-Path $receipts 'provision.log') + & (Join-Path $tools 'preview-ssh/prove-preview-openssh.ps1') -Archive $archive -Arch '${{ matrix.arch }}' -Server '${{ matrix.server }}' -Receipt (Join-Path $receipts 'provider-server.json') -InboxPreparationReceipt $preparation -Accounts $cells.Count -HiddenTools @('npm','npx','node-gyp','gcc','g++','cc','c++','make','cl','clang','clang++','msbuild','cmake') -HostCellProbe $callback 2>&1 | Tee-Object (Join-Path $receipts 'provision.log') - uses: actions/upload-artifact@v7 if: always() with: diff --git a/config/ci/windows-ssh-provider/preview-ssh/prove-preview-openssh.ps1 b/config/ci/windows-ssh-provider/preview-ssh/prove-preview-openssh.ps1 index 115345cf8a0..10b4d9d67e1 100644 --- a/config/ci/windows-ssh-provider/preview-ssh/prove-preview-openssh.ps1 +++ b/config/ci/windows-ssh-provider/preview-ssh/prove-preview-openssh.ps1 @@ -2,46 +2,34 @@ # -HiddenTools: the private accounts are denied every machine PATH directory holding one of these # executables, and their own PATH carries logging shims for them, so SSH sessions have no host toolchain. # -HostCellProbe receives a context hashtable (accounts, port, keys, shim log) once provisioning passes. -param([Parameter(Mandatory=$true)][string]$Receipt,[string]$Archive,[Parameter(Mandatory=$true)][ValidateSet('arm64','x64')][string]$Arch,[ValidateSet('preview','inbox')][string]$Server='preview',[scriptblock]$ProductionRouteProbe,[ValidateRange(1,4)][int]$Accounts=1,[string[]]$HiddenTools=@(),[scriptblock]$HostCellProbe) +param([Parameter(Mandatory=$true)][string]$Receipt,[string]$Archive,[Parameter(Mandatory=$true)][ValidateSet('arm64','x64')][string]$Arch,[ValidateSet('preview','inbox')][string]$Server='preview',[scriptblock]$ProductionRouteProbe,[ValidateRange(1,4)][int]$Accounts=1,[string[]]$HiddenTools=@(),[scriptblock]$HostCellProbe,[string]$InboxPreparationReceipt) $ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'windows-ssh-capability.ps1') $target=@{arm64=@{os='Arm64';folder='OpenSSH-ARM64';machine='0xAA64';archive='698c6aec31c1dd0fb996206e8741f4531a97355686b5431ef347d531b07fcd42'};x64=@{os='X64';folder='OpenSSH-Win64';machine='0x8664';archive='23f50f3458c4c5d0b12217c6a5ddfde0137210a30fa870e98b29827f7b43aba5'}}[$Arch] $scopeServer=if($Server -eq 'inbox'){'Windows inbox OpenSSH.Server capability binaries'}else{'Microsoft Win32-OpenSSH 10.0.0.0p2-Preview'} $report = @{scope="$scopeServer $Arch private loopback authentication and stock cmd.exe dispatch"; server=$Server; status='running'; imageVersion=$env:ImageVersion; cleanup=@('not-confirmed'); globalBootstrapCleanup='Not qualified: service bootstrap may create ProgramData SSH and OpenSSH registry entries; disposable CI VM destruction is the boundary'; observations=@(); stages=@(); diagnosticCaptureFailures=@()} $script:receiptWritten=$false function Write-Stage([string]$Stage) { - $timestamp=[DateTime]::UtcNow.ToString('o') - $report.stages += @{stage=$Stage; utc=$timestamp} - try { - $bytes=[Text.UTF8Encoding]::new($false).GetBytes(($report | ConvertTo-Json -Depth 6)) - $temporary="$Receipt.pending" - $stream=[IO.FileStream]::new($temporary,[IO.FileMode]::Create,[IO.FileAccess]::Write,[IO.FileShare]::Read) - try {$stream.Write($bytes,0,$bytes.Length);$stream.Flush($true)} finally {$stream.Dispose()} - [IO.File]::Move($temporary,$Receipt,$true) - $script:receiptWritten=$true - } catch {Write-Warning 'Progress receipt could not be updated; cleanup must still run'} - Write-Host "Native SSH stage: $Stage ($timestamp)" + if(Write-WindowsSshReceiptStage $report $Receipt $Stage){$script:receiptWritten=$true} } Write-Stage 'preflight-start' if(-not $script:receiptWritten){throw 'Initial progress receipt unavailable; refuse provisioning'} -if ($env:GITHUB_ACTIONS -ne 'true' -or $env:ORCA_ISOLATED_SSH_CI -ne '1' -or [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() -ne $target.os) { throw "Requires isolated native $Arch GitHub runner" } -$admin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) -if (-not $admin) { throw 'Administrative private service/account setup required' } +Assert-IsolatedWindowsSshCi $Arch Write-Stage 'existing-server-query-start' $inboxDir=Join-Path $env:WINDIR 'System32\OpenSSH' -function Assert-GlobalServerDormant { - $global=Get-CimInstance Win32_Service -Filter "Name='sshd'" - if(-not $global){return} - # Inbox mode may register the global service; it must stay stopped and never be started here. - if($Server -ne 'inbox' -or $global.State -ne 'Stopped' -or $global.PathName.Trim('"') -ne (Join-Path $inboxDir 'sshd.exe')){throw 'Refuse an existing global SSH server'} -} -Assert-GlobalServerDormant +Assert-WindowsSshGlobalServerDormant $Server Write-Stage 'existing-server-query-complete' Write-Stage 'default-shell-query-start' $openSshKey = 'HKLM:\SOFTWARE\OpenSSH' -$registry = Get-ItemProperty -LiteralPath $openSshKey -ErrorAction SilentlyContinue # Host-cell probes may set DefaultShell per cell; cleanup restores this stock state. -if ($registry.DefaultShell -or $registry.DefaultShellCommandOption) { throw 'Requires stock cmd.exe OpenSSH shell at start' } +Assert-WindowsSshStockShell Write-Stage 'default-shell-query-complete' +if($InboxPreparationReceipt){ + if($Server -ne 'inbox'){throw 'Inbox preparation receipt cannot qualify a preview server'} + $preparation=Get-Content -LiteralPath $InboxPreparationReceipt -Raw | ConvertFrom-Json + if($preparation.status -ne 'passed' -or $preparation.arch -ne $Arch -or $preparation.sourceSha -ne $env:GITHUB_SHA -or $preparation.runId -ne $env:GITHUB_RUN_ID -or $preparation.runAttempt -ne $env:GITHUB_RUN_ATTEMPT -or $preparation.runnerName -ne $env:RUNNER_NAME -or $preparation.imageVersion -ne $env:ImageVersion){throw 'Inbox preparation receipt identity or verdict mismatch'} + $report.inboxCapabilityPreparation=$preparation +} $id = [Guid]::NewGuid().ToString('N').Substring(0,10) $name = "orca$id" $accountNames = @($name) + @(if($Accounts -gt 1){2..$Accounts | ForEach-Object {"$name$_"}}) @@ -180,12 +168,7 @@ try { $report.nativeInputs=$verified Write-Stage 'preview-native-input-verification-complete' } else { - Write-Stage 'inbox-capability-start' - $capability=Get-WindowsCapability -Online -Name 'OpenSSH.Server~~~~0.0.1.0' - $report.inboxCapabilityInitialState=[string]$capability.State - if($capability.State -ne 'Installed'){Add-WindowsCapability -Online -Name 'OpenSSH.Server~~~~0.0.1.0' | Out-Null} - Assert-GlobalServerDormant - Write-Stage 'inbox-capability-complete' + Install-WindowsInboxSshCapability $Arch $report {param($stage) Write-Stage $stage} $verified=@() foreach($binary in @('sshd.exe','ssh.exe','ssh-keygen.exe','sftp.exe','sftp-server.exe')){ $path=Join-Path $sshDir $binary @@ -431,21 +414,38 @@ LogLevel DEBUG1 foreach($directory in $deniedToolDirs){Invoke-Bounded icacls.exe (@($directory.TrimEnd('\'),'/remove:d')+@($ownedAccounts | ForEach-Object {"*$($_.sid)"})) 120 | Out-Null} Write-Stage 'cleanup-toolchain-acl-complete' Write-Stage 'cleanup-user-profile-start' + $profileTargets=@(foreach($account in $ownedAccounts){ + if($account.sid){@{sid=$account.sid;watch=$null;done=$false;profiles=@();waitMs=0;disposition=$null}} + }) $report.profileCleanup=@() - foreach($account in $ownedAccounts){ - if(-not $account.sid){continue} - $profileWait=[Diagnostics.Stopwatch]::StartNew() - do { - $profiles=@(Get-CimInstance Win32_UserProfile | Where-Object SID -eq $account.sid) - if(-not @($profiles | Where-Object Loaded).Count){break} - Start-Sleep -Milliseconds 500 - } while($profileWait.Elapsed.TotalSeconds -lt 30) - $report.profileUnloadWaitMs=$profileWait.ElapsedMilliseconds - $report.privateProfile=@($profiles | ForEach-Object {@{loaded=$_.Loaded;status=$_.Status}}) - Write-Stage 'cleanup-user-profile-observed' - $loadedProfiles=@($profiles | Where-Object Loaded) - $report.profileCleanup+=if($loadedProfiles.Count){'Loaded profile retained for disposable CI VM destruction'}else{'Unloaded profile removed'} - $profiles | Where-Object {-not $_.Loaded} | Remove-CimInstance + $report.privateProfiles=@() + do { + foreach($entry in @($profileTargets | Where-Object {-not $_.done})){ + if(-not $entry.watch){$entry.watch=[Diagnostics.Stopwatch]::StartNew()} + $profiles=@(Get-CimInstance Win32_UserProfile -Filter "SID='$($entry.sid)'") + if(@($profiles | Where-Object SID -ne $entry.sid).Count){throw 'Private profile query returned an unrelated SID'} + if(@($profiles | Where-Object Loaded).Count -and $entry.watch.ElapsedMilliseconds -lt 30000){continue} + # A profile may reload after the polling snapshot. + $profiles=@(Get-CimInstance Win32_UserProfile -Filter "SID='$($entry.sid)'") + if(@($profiles | Where-Object SID -ne $entry.sid).Count){throw 'Private profile query returned an unrelated SID'} + $loadedProfiles=@($profiles | Where-Object Loaded) + if($loadedProfiles.Count -and $entry.watch.ElapsedMilliseconds -lt 30000){continue} + $profiles | Where-Object {-not $_.Loaded} | Remove-CimInstance + $entry.profiles=@($profiles | ForEach-Object {@{loaded=$_.Loaded;status=$_.Status}}) + $entry.waitMs=$entry.watch.ElapsedMilliseconds + $entry.disposition=if($loadedProfiles.Count){'Loaded profile retained for disposable CI VM destruction'}else{'Unloaded profile removed'} + $entry.done=$true + $report.profileCleanup=@($profileTargets | Where-Object done | ForEach-Object disposition) + $report.privateProfiles=@($profileTargets | Where-Object done | ForEach-Object {@{sid=$_.sid;waitMs=$_.waitMs;profiles=$_.profiles;disposition=$_.disposition}}) + $report.profileUnloadWaitMs=$entry.waitMs + $report.privateProfile=$entry.profiles + Write-Stage 'cleanup-user-profile-observed' + } + if(@($profileTargets | Where-Object {-not $_.done}).Count){Start-Sleep -Milliseconds 500} + } while(@($profileTargets | Where-Object {-not $_.done}).Count) + if($profileTargets.Count){ + $report.profileUnloadWaitMs=$profileTargets[-1].waitMs + $report.privateProfile=$profileTargets[-1].profiles } Write-Stage 'cleanup-user-profile-complete' Write-Stage 'cleanup-user-start' diff --git a/config/ci/windows-ssh-provider/preview-ssh/test-preview-diagnostics.ps1 b/config/ci/windows-ssh-provider/preview-ssh/test-preview-diagnostics.ps1 index 0304ac7a275..5bd3ccf30cd 100644 --- a/config/ci/windows-ssh-provider/preview-ssh/test-preview-diagnostics.ps1 +++ b/config/ci/windows-ssh-provider/preview-ssh/test-preview-diagnostics.ps1 @@ -33,5 +33,166 @@ try { if(($result.hidden -join '|') -ne "$nodeDir|$gccDir"){throw 'Toolchain PATH entries not hidden'} if(($result.kept -join '|') -ne "$plainDir|$(Join-Path $pathRoot 'missing')|Q:\no-such-drive"){throw 'Plain PATH entries not kept in order'} } finally {Remove-Item -LiteralPath $pathRoot -Recurse -Force -ErrorAction SilentlyContinue} +# Mock only the machine boundary; exercise the shared installer without servicing this host. +. (Join-Path $PSScriptRoot 'windows-ssh-capability.ps1') +function Assert-IsolatedWindowsSshCi([string]$Arch){if($script:refuseCapabilityHost){throw 'Injected host refusal'}} +function Assert-WindowsSshGlobalServerDormant([string]$Server){$script:globalChecks++;if($script:globalChecks -eq $script:refuseGlobalCheck){throw 'Injected global server refusal'}} +function Assert-WindowsSshStockShell {$script:shellChecks++;if($script:shellChecks -eq $script:refuseShellCheck){throw 'Injected shell refusal'}} +function Get-WindowsCapability([switch]$Online,[string]$Name){$script:capabilityQueries++;return @{State=$script:capabilityState}} +function Add-WindowsCapability([switch]$Online,[string]$Name){$script:capabilityAdds++;if($script:failCapabilityInstall){throw 'Injected capability install failure'}} +function Reset-CapabilityControl([string]$State){ + $script:capabilityState=$State;$script:capabilityQueries=0;$script:capabilityAdds=0 + $script:globalChecks=0;$script:shellChecks=0;$script:refuseGlobalCheck=0;$script:refuseShellCheck=0 + $script:refuseCapabilityHost=$false;$script:failCapabilityInstall=$false + $script:capabilityStages=[Collections.Generic.List[string]]::new() +} +foreach($state in @('Installed','NotPresent')){ + Reset-CapabilityControl $state + $capabilityReport=@{} + Install-WindowsInboxSshCapability 'x64' $capabilityReport {param($name) $script:capabilityStages.Add($name)} + $expectedAdds=if($state -eq 'Installed'){0}else{1} + if($capabilityReport.inboxCapabilityInitialState -ne $state -or $script:capabilityAdds -ne $expectedAdds -or $script:globalChecks -ne 2 -or $script:shellChecks -ne 2 -or ($script:capabilityStages -join ',') -ne 'inbox-capability-start,inbox-capability-complete'){throw 'Shared capability preparation did not preserve the install and guard boundaries'} +} +foreach($fault in @('host','global-before','shell-before','global-after','shell-after','install')){ + Reset-CapabilityControl 'NotPresent' + switch($fault){ + 'host' {$script:refuseCapabilityHost=$true} + 'global-before' {$script:refuseGlobalCheck=1} + 'shell-before' {$script:refuseShellCheck=1} + 'global-after' {$script:refuseGlobalCheck=2} + 'shell-after' {$script:refuseShellCheck=2} + 'install' {$script:failCapabilityInstall=$true} + } + $rejected=$false + try {Install-WindowsInboxSshCapability 'x64' @{} {param($name) $script:capabilityStages.Add($name)}} catch {$rejected=$true} + if(-not $rejected -or $script:capabilityStages.Contains('inbox-capability-complete')){throw "Capability fault did not fail closed: $fault"} + if($fault -in @('host','global-before','shell-before') -and $script:capabilityAdds){throw 'Capability mutation preceded its host guards'} +} +$capabilityRoot=Join-Path ([IO.Path]::GetTempPath()) ([Guid]::NewGuid().ToString('N')) +try { + New-Item -ItemType Directory -Path $capabilityRoot | Out-Null + $capabilityReceipt=Join-Path $capabilityRoot 'capability.json' + Reset-CapabilityControl 'Installed' + Initialize-WindowsInboxSshCapability 'x64' $capabilityReceipt + $completed=Get-Content -LiteralPath $capabilityReceipt -Raw | ConvertFrom-Json + if($completed.status -ne 'passed' -or $completed.inboxCapabilityInitialState -ne 'Installed'){throw 'Capability success receipt missing its observed initial state'} + Reset-CapabilityControl 'NotPresent';$script:failCapabilityInstall=$true + $rejected=$false + try {Initialize-WindowsInboxSshCapability 'x64' $capabilityReceipt} catch {$rejected=$true} + $failed=Get-Content -LiteralPath $capabilityReceipt -Raw | ConvertFrom-Json + if(-not $rejected -or $failed.status -ne 'failed' -or $failed.inboxCapabilityInitialState -ne 'NotPresent' -or $failed.error -ne 'Injected capability install failure'){throw 'Failed capability preparation masqueraded as a passing receipt'} +} finally {Remove-Item -LiteralPath $capabilityRoot -Recurse -Force -ErrorAction SilentlyContinue} +& { + param($ProofAst) + $cleanup=@($ProofAst.FindAll({param($node) $node -is [Management.Automation.Language.TryStatementAst] -and $node.Body.Extent.Text.Contains("Write-Stage 'cleanup-start'")},$true)) + if($cleanup.Count -ne 1 -or -not $cleanup[0].Extent.Text.Contains('[Diagnostics.Stopwatch]::StartNew()')){throw 'Cleanup clock boundary missing'} + # Run the real cleanup gates with virtual clocks; no Windows machine operation may escape these mocks. + $cleanupBlock=[scriptblock]::Create($cleanup[0].Extent.Text.Replace('[Diagnostics.Stopwatch]::StartNew()','(New-ProfileControlStopwatch)').Replace('[DateTime]::UtcNow','(Get-ProfileControlUtcNow)')) + $ownedSids=@('S-1-5-21-100-200-300-1001','S-1-5-21-100-200-300-1002','S-1-5-21-100-200-300-1003') + $foreignSid='S-1-5-21-100-200-300-9000';$control=@{} + function New-ProfileControlStopwatch { + $watch=[pscustomobject]@{StartedMilliseconds=$control.clockMs;Control=$control} + $watch | Add-Member ScriptProperty ElapsedMilliseconds {$this.Control.clockMs-$this.StartedMilliseconds} + return $watch + } + function Get-ProfileControlUtcNow {[DateTime]::new(2026,10,2,0,0,0,[DateTimeKind]::Utc).AddMilliseconds($control.clockMs)} + function Start-Sleep([int]$Milliseconds,[int]$Seconds){$control.clockMs+=$Milliseconds+1000*$Seconds} + function Write-Stage([string]$Stage){$control.stages.Add($Stage)} + function Record-PrivateServiceDiagnostics([switch]$AfterStop){} + function Test-Path([string]$LiteralPath){if($LiteralPath -ne 'HKLM:\SOFTWARE\OpenSSH'){throw 'Unexpected filesystem query'};return $false} + function Get-ItemProperty([string]$LiteralPath,[object]$ErrorAction){if($LiteralPath -ne 'HKLM:\SOFTWARE\OpenSSH'){throw 'Unexpected registry query'};return $null} + function Remove-ItemProperty {throw 'Unexpected registry mutation'} + function Stop-Service([string]$Name,[switch]$Force,[object]$ErrorAction){if($Name -ne 'orca-sshd-control'){throw 'Foreign service stop'};if($control.case.Fault -ne 'server-exit'){$control.serverLive=$false}} + function Invoke-Bounded([string]$Program,[string[]]$Arguments){if($Program -ne 'sc.exe' -or ($Arguments -join '|') -ne 'delete|orca-sshd-control'){throw 'Unexpected native command'};if($control.case.Fault -ne 'service-absence'){$control.servicePresent=$false}} + function Get-Process([int]$Id,[object]$ErrorAction){if($Id -ne 100){throw 'Foreign process query'};if($control.serverLive){@{Id=100}}} + function Get-Service([string]$Name,[object]$ErrorAction){if($Name -ne 'orca-sshd-control'){throw 'Foreign service query'};if($control.servicePresent){@{Name=$Name}}} + function Get-ProfileControlLoaded([string]$Sid){ + $state=$control.profiles[$Sid] + if($state.Mode -eq 'late'){return $control.clockMs -lt $state.UnloadAtMs} + if($state.Mode -in @('reload','reload-at-deadline')){return $state.CurrentLoaded} + return $state.Mode -eq 'loaded' + } + function Get-CimInstance([Parameter(Position=0)][string]$ClassName,[string]$Filter){ + if($ClassName -eq 'Win32_Service'){ + if($Filter -ne "Name='orca-sshd-control'"){throw 'Foreign service query'} + if($control.servicePresent){@{PathName=$(if($control.case.Fault -eq 'service-identity'){'C:\foreign\sshd.exe'}else{'C:\fake\ossh-control\sshd.exe'});ProcessId=$(if($control.case.Fault -eq 'service-pid'){200}else{100})}};return + } + if($ClassName -eq 'Win32_Process'){ + if($control.case.Fault -eq 'child-exit'){@{ExecutablePath='C:\fake\OpenSSH\session.exe';ProcessId=101;ParentProcessId=100;CreationDate=(Get-ProfileControlUtcNow)}};return + } + if($ClassName -ne 'Win32_UserProfile' -or $Filter -notmatch "^SID='(S-1-5-21-100-200-300-\d+)'$" -or $Matches[1] -notin $ownedSids){throw 'Profile query must target an owned SID'} + $sid=$Matches[1];$control.clockMs+=$control.case.QueryCostMs;$control.profileQueries[$sid]++ + if($control.case.Fault -eq 'query' -and $sid -eq $ownedSids[1]){throw 'Injected profile query failure'} + if($control.case.Fault -eq 'foreign-result' -and $sid -eq $ownedSids[1]){[pscustomobject]@{SID=$foreignSid;Loaded=$false;Status=0};return} + $state=$control.profiles[$sid] + if($state.Mode -eq 'absent' -or $state.Deleted){return} + $loaded=Get-ProfileControlLoaded $sid + if($state.Mode -eq 'reload' -and $control.profileQueries[$sid] -eq 1){$loaded=$false;$state.CurrentLoaded=$true} + if($state.Mode -eq 'reload-at-deadline' -and $control.clockMs -ge 30000 -and -not $state.Reinjected){$loaded=$false;$state.CurrentLoaded=$true;$state.Reinjected=$true} + [pscustomobject]@{SID=$sid;Loaded=$loaded;Status=0} + } + function Remove-CimInstance { + param([Parameter(ValueFromPipeline=$true)][object]$InputObject) + process { + if($InputObject.SID -notin $ownedSids -or $InputObject.Loaded -or (Get-ProfileControlLoaded $InputObject.SID)){throw 'Unsafe profile deletion attempted'} + if($control.case.Fault -eq 'delete'){throw 'Injected profile deletion failure'} + $control.profiles[$InputObject.SID].Deleted=$true;$control.removed.Add($InputObject.SID) + } + } + function Get-LocalUser([string]$Name,[object]$ErrorAction){if(-not $control.users.ContainsKey($Name)){throw 'Foreign account query'};if($control.users[$Name]){@{Name=$Name}}} + function Remove-LocalUser([string]$Name){if(-not $control.users.ContainsKey($Name)){throw 'Foreign account deletion'};if($control.case.Fault -ne 'account'){$control.users[$Name]=$false}} + function Remove-Item([string]$LiteralPath,[switch]$Recurse,[switch]$Force){if($LiteralPath -ne 'C:\fake\ossh-control'){throw 'Foreign filesystem deletion'};if($control.case.Fault -eq 'keys'){throw 'Injected private key deletion failure'};$control.keysRemoved=$true} + function Assert-ProfileCleanup([hashtable]$Case){ + $control.Clear();$control.case=$Case;$control.clockMs=0;$control.serverLive=$true;$control.servicePresent=$true;$control.keysRemoved=$false + $control.stages=[Collections.Generic.List[string]]::new();$control.removed=[Collections.Generic.List[string]]::new() + $control.profiles=@{};$control.users=@{};$control.profileQueries=@{} + $ownedAccounts=@(for($index=0;$index -lt $ownedSids.Count;$index++){ + $sid=$ownedSids[$index];$control.profileQueries[$sid]=0 + $control.profiles[$sid]=@{Mode=$Case.Modes[$index];UnloadAtMs=$Case.UnloadAtMs[$index];CurrentLoaded=$true;Deleted=$false} + $name="orca-control-$index";$control.users[$name]=$true;@{name=$name;sid=$(if($Case.MissingSid -and $index -eq 2){$null}else{$sid})} + }) + $report=@{status='proof-passed-cleanup-pending'};$openSshKey='HKLM:\SOFTWARE\OpenSSH';$serviceName='orca-sshd-control' + $root='C:\fake\ossh-control';$createdService=$true;$ownedServerPid=100;$sshDir='C:\fake\OpenSSH';$preexisting=@();$deniedToolDirs=@() + & $cleanupBlock + if($Case.Fault){ + if($report.status -ne 'failed' -or $report.cleanupError -ne $Case.Error -or $control.keysRemoved -or $control.stages.Contains('cleanup-private-files-complete')){throw "Cleanup fault did not fail at its gate: $($Case.Name)"} + if($Case.Fault -notin @('account','keys') -and @($control.users.Values | Where-Object {-not $_}).Count){throw "Failure bypassed account cleanup gate: $($Case.Name)"} + return + } + if($report.status -ne 'passed' -or @($control.users.Values | Where-Object {$_}).Count -or -not $control.keysRemoved){throw "Cleanup omitted account/key gates: $($Case.Name)"} + if(($control.removed.ToArray() | Sort-Object) -join ',' -ne (($Case.Removed | Sort-Object) -join ',')){throw "Wrong removed SIDs: $($Case.Name)"} + if(@($report.profileCleanup | Where-Object {$_ -eq 'Loaded profile retained for disposable CI VM destruction'}).Count -ne $Case.Retained){throw "Wrong retained disposition: $($Case.Name)"} + foreach($sid in $Case.FullWindow){ + $observed=@($report.privateProfiles | Where-Object sid -eq $sid) + if($observed.Count -ne 1 -or $observed[0].waitMs -lt 30000){throw "Short profile observation window: $($Case.Name)"} + } + if($control.clockMs -gt $Case.MaxClockMs){throw "Profile windows were serialized: $($Case.Name)"} + if($Case.MissingSid -and $control.profileQueries[$ownedSids[2]]){throw 'Missing SID gained profile deletion authority'} + if($report.privateProfiles.Count -and ($report.profileUnloadWaitMs -ne $report.privateProfiles[-1].waitMs -or ($report.privateProfile | ConvertTo-Json -Compress) -ne ($report.privateProfiles[-1].profiles | ConvertTo-Json -Compress))){throw 'Legacy scalar observation lost owned-account order'} + } + $cases=@( + @{Name='three loaded full windows';Modes=@('loaded','loaded','loaded');Retained=3;Removed=@();FullWindow=$ownedSids}, + @{Name='unload at 29999ms';Modes=@('late','unloaded','absent');UnloadAtMs=@(29999,0,0);Retained=0;Removed=$ownedSids[0..1];FullWindow=@($ownedSids[0])}, + @{Name='reload before deletion';Modes=@('reload','unloaded','absent');Retained=1;Removed=@($ownedSids[1]);FullWindow=@($ownedSids[0])}, + @{Name='independent unloads';Modes=@('late','loaded','late');UnloadAtMs=@(5000,0,20000);Retained=1;Removed=@($ownedSids[0],$ownedSids[2]);FullWindow=@($ownedSids[1])}, + @{Name='slow provider queries';Modes=@('loaded','loaded','loaded');QueryCostMs=200;Retained=3;Removed=@();FullWindow=$ownedSids;MaxClockMs=40000}, + @{Name='reload at expired window';Modes=@('reload-at-deadline','unloaded','absent');Retained=1;Removed=@($ownedSids[1]);FullWindow=@($ownedSids[0])}, + @{Name='late unload honestly retained';Modes=@('loaded','loaded','late');UnloadAtMs=@(0,0,45000);Retained=3;Removed=@();FullWindow=$ownedSids}, + @{Name='missing SID is skipped';Modes=@('unloaded','absent','unloaded');Retained=0;Removed=@($ownedSids[0]);MissingSid=$true} + ) + foreach($case in $cases){ + if(-not $case.UnloadAtMs){$case.UnloadAtMs=@(0,0,0)} + if(-not $case.MaxClockMs){$case.MaxClockMs=33000} + Assert-ProfileCleanup $case + } + $failures=@{ + query='Injected profile query failure';delete='Injected profile deletion failure';'foreign-result'='Private profile query returned an unrelated SID' + 'service-identity'='Private service identity changed; refuse stop';'service-pid'='Private service identity changed; refuse stop' + 'server-exit'='Private sshd process still live; no PID-only kill attempted' + 'service-absence'='Private service still registered';'child-exit'='Private SSH child processes remain; preserve files and discard ephemeral runner' + account='Private account still exists';keys='Injected private key deletion failure' + } + foreach($failure in $failures.GetEnumerator()){Assert-ProfileCleanup @{Name=$failure.Key;Fault=$failure.Key;Error=$failure.Value;Modes=@('unloaded','unloaded','unloaded');UnloadAtMs=@(0,0,0)}} +} $ast # Extract functions through the AST: never provision the fixture while testing diagnostics. -'PASS: fixture parse, five numeric-diagnostic cases and the toolchain PATH split' +'PASS: fixture parse, diagnostics, PATH split, capability boundaries, profile windows and cleanup failure gates' diff --git a/config/ci/windows-ssh-provider/preview-ssh/windows-ssh-capability.ps1 b/config/ci/windows-ssh-provider/preview-ssh/windows-ssh-capability.ps1 new file mode 100644 index 00000000000..d2bdc9096a3 --- /dev/null +++ b/config/ci/windows-ssh-provider/preview-ssh/windows-ssh-capability.ps1 @@ -0,0 +1,61 @@ +function Assert-IsolatedWindowsSshCi([ValidateSet('arm64','x64')][string]$Arch) { + $os=@{arm64='Arm64';x64='X64'}[$Arch] + if($env:GITHUB_ACTIONS -ne 'true' -or $env:ORCA_ISOLATED_SSH_CI -ne '1' -or [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() -ne $os){throw "Requires isolated native $Arch GitHub runner"} + $admin=([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + if(-not $admin){throw 'Administrative private service/account setup required'} +} + +function Assert-WindowsSshGlobalServerDormant([ValidateSet('preview','inbox')][string]$Server) { + $global=Get-CimInstance Win32_Service -Filter "Name='sshd'" + if(-not $global){return} + $inboxDir=Join-Path $env:WINDIR 'System32\OpenSSH' + # Inbox installation may register the global service; it must never be started here. + if($Server -ne 'inbox' -or $global.State -ne 'Stopped' -or $global.PathName.Trim('"') -ne (Join-Path $inboxDir 'sshd.exe')){throw 'Refuse an existing global SSH server'} +} + +function Assert-WindowsSshStockShell { + $registry=Get-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\OpenSSH' -ErrorAction SilentlyContinue + if($registry.DefaultShell -or $registry.DefaultShellCommandOption){throw 'Requires stock cmd.exe OpenSSH shell at start'} +} + +function Write-WindowsSshReceiptStage([hashtable]$Report,[string]$Receipt,[string]$Stage) { + $timestamp=[DateTime]::UtcNow.ToString('o') + $Report.stages+=@{stage=$Stage;utc=$timestamp} + try { + $bytes=[Text.UTF8Encoding]::new($false).GetBytes(($Report | ConvertTo-Json -Depth 6)) + $temporary="$Receipt.pending" + $stream=[IO.FileStream]::new($temporary,[IO.FileMode]::Create,[IO.FileAccess]::Write,[IO.FileShare]::Read) + try {$stream.Write($bytes,0,$bytes.Length);$stream.Flush($true)} finally {$stream.Dispose()} + [IO.File]::Move($temporary,$Receipt,$true) + $written=$true + } catch {$written=$false;Write-Warning 'Progress receipt could not be updated; cleanup must still run'} + Write-Host "Native SSH stage: $Stage ($timestamp)" + return $written +} + +function Install-WindowsInboxSshCapability([ValidateSet('arm64','x64')][string]$Arch,[hashtable]$Report,[scriptblock]$Stage) { + Assert-IsolatedWindowsSshCi $Arch + Assert-WindowsSshGlobalServerDormant 'inbox' + Assert-WindowsSshStockShell + & $Stage 'inbox-capability-start' + $capability=Get-WindowsCapability -Online -Name 'OpenSSH.Server~~~~0.0.1.0' + $Report.inboxCapabilityInitialState=[string]$capability.State + if($capability.State -ne 'Installed'){Add-WindowsCapability -Online -Name 'OpenSSH.Server~~~~0.0.1.0' | Out-Null} + Assert-WindowsSshGlobalServerDormant 'inbox' + Assert-WindowsSshStockShell + & $Stage 'inbox-capability-complete' +} + +function Initialize-WindowsInboxSshCapability([ValidateSet('arm64','x64')][string]$Arch,[string]$Receipt) { + $report=@{scope='Windows inbox OpenSSH.Server capability preparation only';arch=$Arch;sourceSha=$env:GITHUB_SHA;runId=$env:GITHUB_RUN_ID;runAttempt=$env:GITHUB_RUN_ATTEMPT;runnerName=$env:RUNNER_NAME;imageVersion=$env:ImageVersion;status='running';stages=@();globalBootstrapCleanup='Disposable CI VM destruction is the boundary; no global sshd is started'} + try { + if(-not (Write-WindowsSshReceiptStage $report $Receipt 'preparation-start')){throw 'Initial progress receipt unavailable; refuse provisioning'} + Install-WindowsInboxSshCapability $Arch $report {param($name) + if(-not (Write-WindowsSshReceiptStage $report $Receipt $name)){throw 'Capability preparation progress receipt unavailable'} + } + $report.status='passed' + } catch {$report.status='failed';$report.error=$_.Exception.Message;throw} + finally { + if(-not (Write-WindowsSshReceiptStage $report $Receipt 'preparation-finished')){throw 'Final capability preparation receipt unavailable'} + } +} diff --git a/config/scripts/ssh-windows-hosts-workflow.test.mjs b/config/scripts/ssh-windows-hosts-workflow.test.mjs index c137b268c0d..3daef7cd4e2 100644 --- a/config/scripts/ssh-windows-hosts-workflow.test.mjs +++ b/config/scripts/ssh-windows-hosts-workflow.test.mjs @@ -13,6 +13,14 @@ const workflow = parse( ) const job = workflow.jobs.hosts const runStep = job.steps.find((step) => step.name?.startsWith('Run the Windows host cells')) +const provisioning = readFileSync( + join(projectDir, 'config/ci/windows-ssh-provider/preview-ssh/prove-preview-openssh.ps1'), + 'utf8' +) +const capability = readFileSync( + join(projectDir, 'config/ci/windows-ssh-provider/preview-ssh/windows-ssh-capability.ps1'), + 'utf8' +) const manifest = (arch) => JSON.parse( readFileSync( @@ -47,6 +55,99 @@ describe('SSH Windows-host workflow', () => { 'x64/windows-2022/preview' ]) expect(job.env).toMatchObject({ ORCA_BACKGROUND_LAUNCH: '1', ORCA_ISOLATED_SSH_CI: '1' }) + expect(job.strategy['fail-fast']).toBe(false) + expect(job['timeout-minutes']).toBe(75) + expect(runStep['timeout-minutes']).toBe(50) + }) + + it('overlaps only guarded ARM inbox capability preparation with the existing builds', () => { + const selfTestIndex = job.steps.findIndex((step) => step.name?.startsWith('Self-test')) + const prepareIndex = job.steps.findIndex((step) => step.id === 'inbox-capability') + const installIndex = job.steps.findIndex( + (step) => step.uses === './.github/actions/install-node-dependencies' + ) + const buildIndex = job.steps.findIndex((step) => step.name?.startsWith('Build this runner')) + const prebuildIndex = job.steps.findIndex( + (step) => step.uses === './.github/actions/prepare-orcad-prebuilds' + ) + const templateIndex = job.steps.findIndex((step) => step.name?.startsWith('Build the win32')) + const waitIndex = job.steps.findIndex((step) => step.wait === 'inbox-capability') + const runIndex = job.steps.indexOf(runStep) + expect([ + selfTestIndex, + prepareIndex, + installIndex, + buildIndex, + prebuildIndex, + templateIndex, + waitIndex, + runIndex + ]).toEqual([1, 2, 3, 4, 5, 6, 7, 8]) + expect(job.steps[prepareIndex]).toMatchObject({ + background: true, + shell: 'pwsh' + }) + expect(job.steps[prepareIndex].if).toBeUndefined() + expect(job.steps[waitIndex].if).toBeUndefined() + expect(job.steps[prepareIndex].run.trim()).toMatch( + /^if\('\$\{\{ matrix\.server }}' -eq 'inbox' -and '\$\{\{ matrix\.arch }}' -eq 'arm64'\)\{[\s\S]+\}$/ + ) + expect(job.steps[prepareIndex].run).toContain('Initialize-WindowsInboxSshCapability') + expect(job.steps[prepareIndex].run).toContain('inbox-capability-preparation.json') + expect(runStep.run).toContain('-InboxPreparationReceipt $preparation') + expect(runStep.run).toContain( + "$preparation=Join-Path $receipts 'inbox-capability-preparation.json'" + ) + expect(runStep.run).toContain( + "if('${{ matrix.server }}' -eq 'inbox' -and '${{ matrix.arch }}' -eq 'arm64'){$preparation=" + ) + expect(runStep.background).toBeUndefined() + expect(job.steps.at(-1)).toMatchObject({ if: 'always()', uses: 'actions/upload-artifact@v7' }) + }) + + it('shares one capability installer without bypassing native verification or private cleanup', () => { + expect(capability.match(/Add-WindowsCapability -Online/g)).toHaveLength(1) + expect(provisioning).not.toContain('Add-WindowsCapability') + expect(provisioning).toContain(". (Join-Path $PSScriptRoot 'windows-ssh-capability.ps1')") + expect(provisioning).toContain('Install-WindowsInboxSshCapability $Arch $report') + const install = capability.slice( + capability.indexOf('function Install-WindowsInboxSshCapability'), + capability.indexOf('function Initialize-WindowsInboxSshCapability') + ) + const mutation = install.indexOf('Add-WindowsCapability') + expect(install.indexOf('Assert-IsolatedWindowsSshCi')).toBeLessThan(mutation) + expect(install.indexOf('Assert-WindowsSshGlobalServerDormant')).toBeLessThan(mutation) + expect(install.lastIndexOf('Assert-WindowsSshGlobalServerDormant')).toBeGreaterThan(mutation) + expect(install.indexOf('Assert-WindowsSshStockShell')).toBeLessThan(mutation) + expect(install.lastIndexOf('Assert-WindowsSshStockShell')).toBeGreaterThan(mutation) + for (const check of [ + '(Machine $path) -ne $target.machine', + 'Get-AuthenticodeSignature -LiteralPath $path', + 'Inbox native input Microsoft signature invalid', + "Write-Stage 'host-cell-probe-start'", + "Write-Stage 'cleanup-default-shell-start'", + "Write-Stage 'cleanup-service-stop-delete-start'" + ]) { + expect(provisioning).toContain(check) + } + }) + + it('keeps preparation provenance separate from the oracle current capability state', () => { + for (const [field, environment] of [ + ['sourceSha', 'GITHUB_SHA'], + ['runId', 'GITHUB_RUN_ID'], + ['runAttempt', 'GITHUB_RUN_ATTEMPT'], + ['runnerName', 'RUNNER_NAME'], + ['imageVersion', 'ImageVersion'] + ]) { + expect(capability).toContain(`${field}=$env:${environment}`) + expect(provisioning).toContain(`$preparation.${field} -ne $env:${environment}`) + } + expect(provisioning).toContain("$preparation.status -ne 'passed'") + expect(provisioning).toContain('$preparation.arch -ne $Arch') + expect(provisioning).toContain('$report.inboxCapabilityPreparation=$preparation') + expect(capability).toContain('$Report.inboxCapabilityInitialState=[string]$capability.State') + expect(capability).toContain("$report.status='failed';$report.error=$_.Exception.Message;throw") }) it('fetches the preview release its hash manifests pin', () => { diff --git a/docs/reference/ci-runner-efficiency.md b/docs/reference/ci-runner-efficiency.md index 6c7f0d059cf..f2c8e649b35 100644 --- a/docs/reference/ci-runner-efficiency.md +++ b/docs/reference/ci-runner-efficiency.md @@ -1392,3 +1392,94 @@ one CI failure does not establish a failure-rate reduction. The bounded 50-head main sample ending at 8ff6296 contained no root package metadata changes. Removing app-version metadata from the Windows server cache key would not improve reuse in that sample, so the key remains unchanged. + +## Windows ARM SSH: prepare the inbox capability during independent builds + +The ARM inbox lane starts guarded Windows capability preparation after the pure +provisioning self-test and waits for it before any private SSH server or host cell +runs. Dependency installation and the unchanged native artifacts can run during +that preparation. Preview and x64 lanes keep their existing serial provisioning; +the registered background step completes without mutation in those lanes. + +The preparation and the foreground provider use the same installer and isolation +guards. The receipt must match the source, run, attempt, runner, image and native +architecture. The foreground provider still reads the installed capability and +verifies every native binary and Microsoft signature. Account ownership, ACLs, +DefaultShell, private service identity, host cells and cleanup remain independent +checks. A background failure propagates through the unconditional native wait. + +Two full four-lane pairs used frozen source refs and the same dependency and +native-install policy. The [first baseline](https://github.com/stablyai/orca/actions/runs/36986929163) +ran before the [first candidate](https://github.com/stablyai/orca/actions/runs/36986970976); +the [second candidate](https://github.com/stablyai/orca/actions/runs/36991232037) +was dispatched before the [second baseline](https://github.com/stablyai/orca/actions/runs/36991234729). +Runner image versions matched within each platform in both pairs. + +| Active job, seconds | First baseline | First candidate | Second baseline | Second candidate | +| ------------------- | -------------: | --------------: | --------------: | ---------------: | +| ARM inbox | 2,403 | 1,644 | 2,353 | 1,667 | +| ARM preview | 1,002 | 935 | 886 | 872 | +| x64 inbox | 636 | 732 | 616 | 620 | +| x64 preview | 562 | 561 | 623 | 566 | + +The ARM inbox observations improved by 759 and 686 seconds. Baseline dependency +installation and artifact builds consumed 501 and 498 seconds before capability +installation could start. Candidate capability installation ran during that +work, but also took about 261 and 232 seconds less than the baseline. Candidate +dependency installation was slower, particularly in the second pair. These +observations support overlap on ARM; they do not establish a guaranteed 11–13 +minute saving, a reduction in queue time, or the cause of installer variability. +The x64 lane showed no repeatable gain, so it keeps serial preparation. + +All 16 actual Windows providers and 48 host-cell verdicts passed across the two +pairs. Receipts verify native machine identity, private service absence, owned +process exit, account removal and key removal. Loaded profile disposition remains +separate from those required cleanup checks. Hosted execution also verified the +native background/wait syntax; older actionlint versions do not recognize it. + +### Overlap the private profile observation budgets + +After service deletion and owned process exit, profile cleanup polls each owned +SID with its own full 30-second monotonic budget. Independent budgets now run +together. Every deletion follows a fresh targeted read; loaded profiles remain +for disposable VM destruction. Service identity, PID ownership, process exit, +account removal and key removal still fail the complete provider on error. + +The maintained diagnostics self-test executes the actual cleanup try/catch with +scoped Windows API and clock controls. Eight positive cases cover full windows, +late unload, reload, query overhead, mixed states and missing SIDs; ten specific +failure cases cover foreign profiles and the required cleanup gates. Disposable +shortened-deadline and stale-snapshot mutations fail those controls. A separate +mocked real-clock observation took 30.179 seconds for three loaded profiles, +compared with about 90 seconds for serial full budgets. This measures polling, +not an actual Windows provider or the entire job. + +The third profile no longer gains incidental extra time while earlier profiles +consume their budgets. A profile unloading at 45 seconds may therefore remain +where serial cleanup removed it. This uses the existing disposable-VM fallback; +it does not remove a loaded profile or relax mandatory account/key cleanup. +Hosted qualification of the combined workflow remains pending. + +## Coordinator mail tests: advance observation windows without removing them + +Six cases advance their original six 1,500 ms and ten 100 ms observation windows +with a scoped clock. Real filesystem, SQLite, journal, RPC and runtime work still +finishes asynchronously. The original journal-read gate and all counter and +operation assertions remain. Cancellation during delayed startup and the +Date-only age case retain real timers. Teardown stops the host and closes the +database before advancing the known 2,000 ms orphan repair, then asserts no fake +timers remain and restores the clock in `finally`. + +Two opposite-order local pairs passed the same 23 cases and unchanged source +hashes. Selected-case totals fell from 13.674 to 3.318 seconds and from 13.276 to +6.323 seconds. Whole-file test totals fell from 24.845 to 10.829 seconds and from +21.500 to 19.410 seconds. Process wall times were 41.488/37.810 seconds and +42.140/78.450 seconds; the reverse candidate spent 56.31 seconds importing under +unrelated local load. Overall wall-time savings remain inconclusive. + +Injected extra deliveries at 1,499 ms and 99 ms still fail the original assertions +in both clock modes. The latter candidate fails the unchanged journal-read gate +with the same extra provider start. A separate control confirms the orphan repair +actually executes against the closed database and leaves no fake timers. The +change retains all 121 original expectation sites and adds one teardown check; +it does not shorten the runtime's observation interval or claim a whole-PR gain. diff --git a/src/main/runtime/structured-chat-coordinator-mail.test.ts b/src/main/runtime/structured-chat-coordinator-mail.test.ts index c9c3b8448ad..49c694b5adb 100644 --- a/src/main/runtime/structured-chat-coordinator-mail.test.ts +++ b/src/main/runtime/structured-chat-coordinator-mail.test.ts @@ -34,6 +34,7 @@ import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { createCoordinatorMailObservationClock } from './structured-chat-coordinator-observation-clock.test-fixture' import { attachParams, fakeCodex, @@ -56,6 +57,7 @@ let db: OrchestrationDb let host: StructuredAgentSessionHost let dispatcher: RpcDispatcher let requests = 0 +const observationClock = createCoordinatorMailObservationClock(() => host, COORDINATOR) function request( method: string, @@ -284,10 +286,15 @@ function startRuntime(): OrcaRuntimeService { } afterEach(async () => { - await stopStructuredAgentSessionRuntime() - db.close() - vi.restoreAllMocks() - await rm(root, { recursive: true, force: true }) + try { + await stopStructuredAgentSessionRuntime() + db.close() + await observationClock.drainClosedDatabaseRepair() + vi.restoreAllMocks() + await rm(root, { recursive: true, force: true }) + } finally { + observationClock.restore() + } }) // Pointers are sent on asynchronous edges; the default 1s wait is too tight under a loaded parallel run. @@ -350,15 +357,7 @@ describe('a worker result reaches the structured chat that coordinates it', () = }) /** Fires both edges and waits until every gate read they started has answered. */ - async function edgesAnswered(): Promise { - const reads = vi.spyOn(host, 'journalSnapshot') - runtime.onStructuredSessionStatusForMail({ sessionId: COORDINATOR, status: null }) - runtime.onStructuredSessionStatusForMail({ sessionId: COORDINATOR, status: 'idle' }) - await vi.waitFor(() => expect(reads).toHaveBeenCalled(), WAIT) - await Promise.all(reads.mock.results.map((read) => read.value)) - await new Promise((resolve) => setImmediate(resolve)) - reads.mockRestore() - } + const edgesAnswered = (): Promise => observationClock.edgesAnswered(runtime, WAIT) /** The operation ids the coordinator's journal recorded for its pointer turns. */ async function pointerSends(): Promise { @@ -407,6 +406,7 @@ describe('a worker result reaches the structured chat that coordinates it', () = }) it('does not restart a provider that dies before every echo, however many edges follow', async () => { + observationClock.start() // What this pins: each death's own status edge used to re-point the mail, and that send started // the provider again, about once a second for as long as the mail was unread. await openChat(COORDINATOR) @@ -416,7 +416,7 @@ describe('a worker result reaches the structured chat that coordinates it', () = await finishWorker(taskId) await vi.waitFor(() => expect(providerFaults.turnStarts).toBe(1), WAIT) // A fixed window, not a poll: a respawn loop would restart it several times in it. - await new Promise((resolve) => setTimeout(resolve, 1_500)) + await observationClock.observe(1_500) await edgesAnswered() expect(codex.connections.length - before).toBe(0) expect(providerFaults.turnStarts).toBe(1) @@ -426,6 +426,7 @@ describe('a worker result reaches the structured chat that coordinates it', () = it.each(['exit-then-throw', 'throw-then-exit'] as const)( 'does not restart a provider that crashed while taking the pointer turn (%s)', async (crash) => { + observationClock.start() // The crash settles the send `unknown` with the connection's own error, not as a provider // exit; every status edge after it re-pointed the mail and started the provider again. await openChat(COORDINATOR) @@ -434,7 +435,7 @@ describe('a worker result reaches the structured chat that coordinates it', () = const before = providerFaults.starts await finishWorker(taskId) await vi.waitFor(() => expect(providerFaults.turnStarts).toBe(1), WAIT) - await new Promise((resolve) => setTimeout(resolve, 1_500)) + await observationClock.observe(1_500) await edgesAnswered() expect(providerFaults.starts - before).toBe(0) expect(providerFaults.turnStarts).toBe(1) @@ -501,7 +502,7 @@ describe('a worker result reaches the structured chat that coordinates it', () = expect(cancelled).toMatchObject({ ok: true }) providerFaults.startDelayMs = 0 // A fixed window, not a poll: a re-point would start the agent again in it. - await new Promise((resolve) => setTimeout(resolve, 1_500)) + await observationClock.observe(1_500) expect(providerFaults.starts - before).toBe(1) expect(await pointerSends()).toHaveLength(1) await edgesAnswered() @@ -511,6 +512,7 @@ describe('a worker result reaches the structured chat that coordinates it', () = }) it('points a held pointer once more after Orca restarts, under a new id', async () => { + observationClock.start() await openChat(COORDINATOR) const { runId, taskId } = await coordinatorRunAndTask() providerFaults.dieBeforeEveryEcho = true @@ -525,7 +527,7 @@ describe('a worker result reaches the structured chat that coordinates it', () = dispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }) const before = providerFaults.starts await vi.waitFor(() => expect(providerFaults.turnStarts).toBe(2), WAIT) - await new Promise((resolve) => setTimeout(resolve, 1_500)) + await observationClock.observe(1_500) await edgesAnswered() expect(providerFaults.starts - before).toBe(1) expect(providerFaults.turnStarts).toBe(2) @@ -539,6 +541,7 @@ describe('a worker result reaches the structured chat that coordinates it', () = async function refusedStartsFor( refusal: () => Error ): Promise<{ runId: string; starts: number }> { + observationClock.start() await openChat(COORDINATOR) const { runId, taskId } = await coordinatorRunAndTask() await host.close(COORDINATOR, 'evict') @@ -551,7 +554,7 @@ describe('a worker result reaches the structured chat that coordinates it', () = WAIT ) // A fixed window, not a poll: a retry loop would start it several times in it. - await new Promise((resolve) => setTimeout(resolve, 1_500)) + await observationClock.observe(1_500) return { runId, starts: providerFaults.starts - before } } @@ -576,7 +579,7 @@ describe('a worker result reaches the structured chat that coordinates it', () = const before = providerFaults.starts for (let edge = 0; edge < 5; edge += 1) { runtime.onStructuredSessionStatusForMail({ sessionId: COORDINATOR, status: 'idle' }) - await new Promise((resolve) => setTimeout(resolve, 100)) + await observationClock.observe(100) } await edgesAnswered() expect(providerFaults.starts).toBe(before) diff --git a/src/main/runtime/structured-chat-coordinator-observation-clock.test-fixture.ts b/src/main/runtime/structured-chat-coordinator-observation-clock.test-fixture.ts new file mode 100644 index 00000000000..cced136ce8b --- /dev/null +++ b/src/main/runtime/structured-chat-coordinator-observation-clock.test-fixture.ts @@ -0,0 +1,55 @@ +import { expect, vi } from 'vitest' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import type { OrcaRuntimeService } from './orca-runtime' +import { waitForStructuredAgentSessionRecovery } from './structured-agent-session-runtime' + +export function createCoordinatorMailObservationClock( + getHost: () => StructuredAgentSessionHost, + sessionId: string +) { + let active = false + return { + start(): void { + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + active = true + }, + async observe(ms: number): Promise { + if (!active) { + await new Promise((resolve) => setTimeout(resolve, ms)) + return + } + await vi.advanceTimersByTimeAsync(ms) + await waitForStructuredAgentSessionRecovery() + await getHost().flushStreamedEvents(sessionId) + await new Promise((resolve) => setImmediate(resolve)) + }, + async edgesAnswered( + runtime: Pick, + wait: { timeout: number } + ): Promise { + const reads = vi.spyOn(getHost(), 'journalSnapshot') + runtime.onStructuredSessionStatusForMail({ sessionId, status: null }) + runtime.onStructuredSessionStatusForMail({ sessionId, status: 'idle' }) + await vi.waitFor(() => expect(reads).toHaveBeenCalled(), wait) + await Promise.all(reads.mock.results.map((read) => read.value)) + await new Promise((resolve) => setImmediate(resolve)) + reads.mockRestore() + }, + async drainClosedDatabaseRepair(): Promise { + if (!active) { + return + } + // The runtime's orphan mailbox repair must still run against the closed database. + await vi.advanceTimersByTimeAsync(2_000) + await waitForStructuredAgentSessionRecovery() + await new Promise((resolve) => setImmediate(resolve)) + expect(vi.getTimerCount()).toBe(0) + }, + restore(): void { + if (active) { + vi.useRealTimers() + } + active = false + } + } +} From b4ff7fe6d4180c9fa70cf99ecc9c913699c20ae6 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:23:50 -0700 Subject: [PATCH 19/21] fix(tests): run watcher crash harness against current code (#24705) * fix(tests): resolve watcher crash harness from repository root * fix(tests): rebuild watcher crash harness from current sources * fix(tests): register watcher interruption callback as an owner hook --- .../tools/repro-watcher-crash-7547/child.cjs | 2 +- .../repro-watcher-crash-7547/fixed-child.cjs | 26 +++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/tests/tools/repro-watcher-crash-7547/child.cjs b/tests/tools/repro-watcher-crash-7547/child.cjs index d5a522c0b40..57657efac04 100644 --- a/tests/tools/repro-watcher-crash-7547/child.cjs +++ b/tests/tools/repro-watcher-crash-7547/child.cjs @@ -32,7 +32,7 @@ const os = require('node:os') const path = require('node:path') const { Worker, isMainThread, workerData } = require('node:worker_threads') -const REPO_ROOT = path.resolve(__dirname, '..', '..') +const REPO_ROOT = path.resolve(__dirname, '..', '..', '..') // The exact bundled native module (@parcel/watcher 2.5.6 + watcher-win32-x64). const watcherPath = path.join(REPO_ROOT, 'node_modules', '@parcel', 'watcher') diff --git a/tests/tools/repro-watcher-crash-7547/fixed-child.cjs b/tests/tools/repro-watcher-crash-7547/fixed-child.cjs index 99a5d782cc3..47b9749e48e 100644 --- a/tests/tools/repro-watcher-crash-7547/fixed-child.cjs +++ b/tests/tools/repro-watcher-crash-7547/fixed-child.cjs @@ -17,23 +17,21 @@ const os = require('node:os') const path = require('node:path') const { Worker, isMainThread, workerData } = require('node:worker_threads') -const REPO_ROOT = path.resolve(__dirname, '..', '..') +const REPO_ROOT = path.resolve(__dirname, '..', '..', '..') // The real host client, bundled on demand so the harness always exercises the // current src/main/ipc/parcel-watcher-process.ts. const bundlePath = path.join(__dirname, 'parcel-watcher-process.bundle.cjs') if (isMainThread) { const clientSrc = path.join(REPO_ROOT, 'src', 'main', 'ipc', 'parcel-watcher-process.ts') - if ( - !fs.existsSync(bundlePath) || - fs.statSync(bundlePath).mtimeMs < fs.statSync(clientSrc).mtimeMs - ) { - require('node:child_process').execSync( - `npx esbuild ${JSON.stringify(clientSrc)} --bundle --platform=node --format=cjs ` + - `--external:electron --external:@parcel/watcher --outfile=${JSON.stringify(bundlePath)}`, - { cwd: REPO_ROOT, stdio: 'inherit' } - ) - } + require('esbuild').buildSync({ + entryPoints: [clientSrc], + bundle: true, + platform: 'node', + format: 'cjs', + external: ['electron', '@parcel/watcher'], + outfile: bundlePath + }) const entry = path.join(process.cwd(), 'out', 'main', 'parcel-watcher-process-entry.js') if (!fs.existsSync(entry)) { process.stderr.write( @@ -156,8 +154,10 @@ function subscribeLikeOrca(dir) { stats.events += events.length }, OPTS, - () => { - stats.interruptions++ + { + onInterruption: () => { + stats.interruptions++ + } } ) .then((sub) => { From d3a406fcbea3b6f6fc51b09c15993e1319764cea Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:32:33 -0400 Subject: [PATCH 20/21] ci(release): publish after a skipped orcad template (#24882) * ci(release): publish after a skipped orcad template #24872 skips orcad-template for tags that predate it, but a skipped ancestor skips every job that keeps the implicit success(), so publish-release and the post-release jobs never ran for v1.4.219. * test: brace-free filter in the orcad downstream contract --- .github/workflows/release-cut.yml | 18 +++++++++++++--- .../orcad-template-release-workflow.test.mjs | 21 +++++++++++++++++++ .../release-e2e-dispatch-contract.test.mjs | 4 +++- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index f1334df6a9b..3073d160d11 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -2375,6 +2375,15 @@ jobs: - skill-sharing-linux-floor-release-gate - skill-sharing-release-gate - terminal-rendering-golden + # Why explicit: a skipped orcad-template (tags that predate it) would skip every job after it. + if: >- + !cancelled() && + needs.cut.result == 'success' && + needs.build.result == 'success' && + needs.build-mac.result == 'success' && + needs.skill-sharing-linux-floor-release-gate.result == 'success' && + needs.skill-sharing-release-gate.result == 'success' && + needs.terminal-rendering-golden.result == 'success' runs-on: ubuntu-latest permissions: contents: write @@ -2443,7 +2452,8 @@ jobs: needs: - cut - publish-release - if: ${{ needs.cut.outputs.tag != '' }} + # Why explicit: a skipped orcad-template (tags that predate it) would skip every job after it. + if: ${{ !cancelled() && needs.publish-release.result == 'success' && needs.cut.outputs.tag != '' }} runs-on: ubuntu-latest permissions: actions: write @@ -2472,7 +2482,8 @@ jobs: # A release created with GITHUB_TOKEN does not reliably emit a release # event to other workflows. Dispatch the trusted default-branch workflow; # it validates and checks out the released tag before deploying. - if: ${{ needs.cut.outputs.tag != '' }} + # Why explicit: a skipped orcad-template (tags that predate it) would skip every job after it. + if: ${{ !cancelled() && needs.publish-release.result == 'success' && needs.cut.outputs.tag != '' }} runs-on: ubuntu-latest permissions: actions: write @@ -2516,7 +2527,8 @@ jobs: needs: - cut - publish-release - if: ${{ needs.cut.outputs.tag != '' && startsWith(needs.cut.outputs.tag, 'v') }} + # Why explicit: a skipped orcad-template (tags that predate it) would skip every job after it. + if: ${{ !cancelled() && needs.publish-release.result == 'success' && needs.cut.outputs.tag != '' && startsWith(needs.cut.outputs.tag, 'v') }} uses: ./.github/workflows/homebrew-bump.yml with: tag: ${{ needs.cut.outputs.tag }} diff --git a/config/scripts/orcad-template-release-workflow.test.mjs b/config/scripts/orcad-template-release-workflow.test.mjs index 4ac9e3e27d7..311231ea4bf 100644 --- a/config/scripts/orcad-template-release-workflow.test.mjs +++ b/config/scripts/orcad-template-release-workflow.test.mjs @@ -161,6 +161,27 @@ describe('orcad template release wiring (design D2)', () => { expect(macDownload.if).toBe("hashFiles('config/scripts/packaged-orcad-template.cjs') != ''") }) + it('keeps every job downstream of the template from inheriting its skip', () => { + const needsOf = (name) => [releaseCut.jobs[name].needs ?? []].flat() + const dependsOnTemplate = (name) => + needsOf(name).some((need) => need === 'orcad-template' || dependsOnTemplate(need)) + const downstream = Object.keys(releaseCut.jobs).filter(dependsOnTemplate) + expect(downstream).toEqual( + expect.arrayContaining(['build', 'build-mac', 'publish-release', 'homebrew-bump']) + ) + for (const name of downstream) { + // A skipped ancestor skips the job under the implicit success() that any `if` without a + // status function gets, so each one must override it and check its own needs instead. + const condition = releaseCut.jobs[name].if + expect(condition, name).toContain('!cancelled()') + for (const need of needsOf(name).filter( + (need) => need !== 'orcad-template' && need !== 'cut' + )) { + expect(condition, `${name} -> ${need}`).toContain(`needs.${need}.result == 'success'`) + } + } + }) + it('signs only Windows template binaries and reseals the manifest before the installer rebuild', () => { const steps = releaseCut.jobs.build.steps const stage = steps.find((step) => step.id === 'stage-inner') diff --git a/config/scripts/release-e2e-dispatch-contract.test.mjs b/config/scripts/release-e2e-dispatch-contract.test.mjs index 2277cd399a4..d939a6231ef 100644 --- a/config/scripts/release-e2e-dispatch-contract.test.mjs +++ b/config/scripts/release-e2e-dispatch-contract.test.mjs @@ -28,7 +28,9 @@ describe('release E2E dispatch contract', () => { expect(releaseWorkflow.jobs.e2e).toBeUndefined() expect(dispatchJob.needs).toEqual(['cut', 'publish-release']) - expect(dispatchJob.if).toBe("${{ needs.cut.outputs.tag != '' }}") + expect(dispatchJob.if).toBe( + "${{ !cancelled() && needs.publish-release.result == 'success' && needs.cut.outputs.tag != '' }}" + ) expect(dispatchJob.permissions.actions).toBe('write') expect(dispatchStep.env.TAG).toBe('${{ needs.cut.outputs.tag }}') expect(dispatchStep.run).toContain('gh workflow run e2e.yml') From 61836f602661f2004764c8ec85494e5184a8ac53 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:35:06 -0700 Subject: [PATCH 21/21] Reduce scheduled CI cache warming to every six hours (#24881) * Reduce scheduled CI cache warming to every six hours * Document cache warmer recovery interval and measured tradeoff --- .github/workflows/ci-cache-warmup.yml | 5 ++-- .../scripts/ci-cache-warmup-workflow.test.mjs | 4 +-- docs/reference/ci-runner-efficiency.md | 29 ++++++++++++++++--- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci-cache-warmup.yml b/.github/workflows/ci-cache-warmup.yml index daad00afb60..1c8c248da89 100644 --- a/.github/workflows/ci-cache-warmup.yml +++ b/.github/workflows/ci-cache-warmup.yml @@ -1,8 +1,9 @@ name: Warm shared CI caches on: + # Cache-input pushes seed immediately; this schedule repairs eviction and image changes. schedule: - - cron: '41 * * * *' + - cron: '41 */6 * * *' workflow_dispatch: push: branches: [main] @@ -52,7 +53,7 @@ permissions: concurrency: group: ci-cache-warmup-${{ github.event.pull_request.number || github.ref }} - # Hourly retries must let an active warmer finish publishing its caches. + # Scheduled retries must let an active warmer finish publishing its caches. cancel-in-progress: ${{ github.event_name != 'schedule' }} jobs: diff --git a/config/scripts/ci-cache-warmup-workflow.test.mjs b/config/scripts/ci-cache-warmup-workflow.test.mjs index 74b15e9b267..fd027a8434f 100644 --- a/config/scripts/ci-cache-warmup-workflow.test.mjs +++ b/config/scripts/ci-cache-warmup-workflow.test.mjs @@ -72,12 +72,12 @@ it('bounds warming to the required platforms and validates changes without grant expect(workflow.permissions).toEqual({ contents: 'read' }) expect(workflow.on.push.branches).toEqual(['main']) expect(workflow.on.push.paths).toContain('.github/actions/prepare-native-runtime/**') - expect(workflow.on.schedule).toEqual([{ cron: '41 * * * *' }]) + expect(workflow.on.schedule).toEqual([{ cron: '41 */6 * * *' }]) expect(workflow.on.pull_request.paths).toContain('.github/workflows/ci-cache-warmup.yml') expect(steps[0].with['persist-credentials']).toBe(false) }) -it('lets hourly warmers wait while pushes, PR updates, and manual runs can replace active work', () => { +it('lets scheduled warmers wait while pushes, PR updates, and manual runs can replace active work', () => { expect(workflow.concurrency).toEqual({ group: 'ci-cache-warmup-${{ github.event.pull_request.number || github.ref }}', 'cancel-in-progress': "${{ github.event_name != 'schedule' }}" diff --git a/docs/reference/ci-runner-efficiency.md b/docs/reference/ci-runner-efficiency.md index f2c8e649b35..77b6479804b 100644 --- a/docs/reference/ci-runner-efficiency.md +++ b/docs/reference/ci-runner-efficiency.md @@ -679,8 +679,8 @@ coverage, and release behavior: Shard 4 spent 535 worker-seconds importing and 357 executing tests; a uniform per-file import estimate misses that cost. See [timing refresh](../../config/scripts/ci-shard-timings.md). - Seed Node 24 native modules, the pinned Git compatibility binary, and TypeScript - state on the default branch, hourly - and when dependency/toolchain inputs change. One ten-minute-bounded hosted job + state on the default branch when dependency/toolchain inputs change, with + scheduled recovery (originally hourly; now every six hours). One ten-minute-bounded hosted job reuses existing cache keys and skips typechecking an already-cached commit. New PRs can restore default-branch caches, while caches saved by another PR are inaccessible. The audit found 80 entries totaling 10.67 GiB, including @@ -1216,7 +1216,7 @@ These are single cold/warm observations, not paired medians or a measured whole-workflow saving. They demonstrate usable exact-key reuse after publication; future savings depend on cache availability and unchanged native inputs. The trial seeds belong to this PR's merge ref. Other PRs require a main-branch seed -after merging this new namespace; the existing main push and hourly warming +after merging this new namespace; the existing main-push and scheduled warming jobs provide that seed. ## Separate mobile install verification: retain the current policy @@ -1269,7 +1269,7 @@ This measures the three-file oracle cohort. Whole-shard timings include other test bodies, imports and transforms, so a whole-suite saving needs separate measurement. -## Cache warming: let hourly ticks wait for active work +## Cache warming: let scheduled ticks wait for active work The hourly warmer previously cancelled an active warmer, even when both used the same source. On October 2, the [merge-triggered run](https://github.com/stablyai/orca/actions/runs/36965832780) @@ -1289,6 +1289,27 @@ scheduled run or its repeated successful lanes, and pending replacement still applies regardless of the cancellation expression. The bounded 20-run sample contains this collision; it does not establish a recurring or whole-CI saving. +## Cache warming: six-hour recovery interval + +Scheduled warming now runs at 00:41, 06:41, 12:41 and 18:41 UTC instead of hourly. +Main pushes that change cache inputs still seed immediately, and manual dispatch +remains available. All five jobs, probes, keys and publication rules remain. +This removes 20 scheduled workflows and 100 scheduled job starts per day (83%). + +Four consecutive October 2 scheduled runs used the same source. The +[18:50 UTC run](https://github.com/stablyai/orca/actions/runs/37050194510) used 474 +aggregate runner-seconds across five jobs, including 242 seconds on Windows ARM. +That job restored exact package, verification and native caches; package-store +restore alone took about 70 seconds. Repeating that observed duration twenty +fewer times would avoid about 158 runner-minutes daily, but this one-run estimate +is not a billing forecast or measured post-rollout saving. + +The longer interval can delay background repair after eviction or runner-image +changes. Existing consumers retain cold-cache installation/build fallback, and +normal cache reads update last access. Storage was near the repository limit +when audited, so retention and unchanged hit rates are not guaranteed. Observe +misses before reducing the recovery frequency further. + ## Daemon shutdown fixture: remove build tools after compilation The fixture now removes compiler and Python build dependencies, plus npm and