leetcode-capture-helper 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/cli.js +92 -0
  2. package/config.js +25 -0
  3. package/framing.js +57 -0
  4. package/host.js +346 -0
  5. package/install.js +290 -0
  6. package/package.json +31 -4
  7. package/runtime/bundle.json +42 -0
  8. package/runtime/companion/capture-log.js +83 -0
  9. package/runtime/companion/codex-backend.js +337 -0
  10. package/runtime/companion/companion.js +2193 -0
  11. package/runtime/companion/mouse-input.js +154 -0
  12. package/runtime/companion/package.json +4 -0
  13. package/runtime/companion/panel-bridge.js +273 -0
  14. package/runtime/companion/settings-rpc.js +228 -0
  15. package/runtime/companion/settings.js +464 -0
  16. package/runtime/companion/terminal-format.js +330 -0
  17. package/runtime/companion/vault-summary.js +169 -0
  18. package/runtime/companion/virtual-terminal.js +138 -0
  19. package/runtime/extension/shared/anthropic-backend.js +193 -0
  20. package/runtime/extension/shared/catalog.js +415 -0
  21. package/runtime/extension/shared/code-ledger.js +333 -0
  22. package/runtime/extension/shared/context-budget.js +259 -0
  23. package/runtime/extension/shared/context-summary.js +271 -0
  24. package/runtime/extension/shared/conversation.js +81 -0
  25. package/runtime/extension/shared/event-hub.js +126 -0
  26. package/runtime/extension/shared/extension-settings.js +445 -0
  27. package/runtime/extension/shared/helper-launcher.js +248 -0
  28. package/runtime/extension/shared/local-backend.js +706 -0
  29. package/runtime/extension/shared/local-server.js +149 -0
  30. package/runtime/extension/shared/package.json +4 -0
  31. package/runtime/extension/shared/practice.js +228 -0
  32. package/runtime/extension/shared/problem-index.js +4128 -0
  33. package/runtime/extension/shared/profiles.js +106 -0
  34. package/runtime/extension/shared/providers.js +58 -0
  35. package/runtime/extension/shared/reply-guard.js +864 -0
  36. package/runtime/extension/shared/roadmap.js +1344 -0
  37. package/runtime/extension/shared/sanitize.js +68 -0
  38. package/runtime/extension/shared/solved.js +277 -0
  39. package/runtime/extension/shared/tutor-engine.js +369 -0
  40. package/runtime/extension/shared/tutor-prompt.js +108 -0
  41. package/runtime/extension/shared/tutor-service.js +520 -0
  42. package/runtime/relay-server/package.json +4 -0
  43. package/runtime/relay-server/panel-broker.js +797 -0
  44. package/runtime/relay-server/server.js +241 -0
  45. package/runtime/vault-tool/log-session.js +230 -0
  46. package/runtime/vault-tool/package.json +4 -0
  47. package/runtime/vault-tool/vault-notes.js +206 -0
  48. package/README.md +0 -3
@@ -0,0 +1,2193 @@
1
+ #!/usr/bin/env node
2
+ // companion/companion.js - single-process companion that owns a live chat
3
+ // conversation directly (no tmux, no CLI-pane scraping).
4
+ //
5
+ // Replaces the old start.sh + watch.js pair. This program:
6
+ // - Tails relay-server/data/captures.jsonl for new Run/Submit captures,
7
+ // the same way watch.js used to (byte-offset tracking, tail-not-backfill
8
+ // on first run).
9
+ // - Runs a normal readline chat loop on stdin/stdout so Thomson can type
10
+ // into it directly at any time.
11
+ // - Sends a changed Submit (never a Run) and every typed message into the
12
+ // same ongoing conversation through a swappable backend, the code as a
13
+ // diff against what the tutor already holds (see code-ledger.js), then
14
+ // prints the real response - no pane, nothing to scrape.
15
+ //
16
+ // Providers (chosen on the extension's options page, saved by settings.js, or
17
+ // with COMPANION_BACKEND, which wins):
18
+ // - "claude" (default): the Claude Agent SDK (@anthropic-ai/claude-agent-sdk)
19
+ // on the Claude Code sign-in, a real programmatic session - not the
20
+ // `claude` CLI driven by keystrokes. "anthropic" is the same backend with
21
+ // an API key.
22
+ // - "local": plain HTTP against an OpenAI-compatible chat-completions
23
+ // endpoint (e.g. a local Ollama install). No extra dependency - Node's
24
+ // built-in fetch. "openai" is the same backend against OpenAI's API.
25
+ // - "chatgpt": the Codex CLI on a ChatGPT sign-in (codex-backend.js).
26
+ //
27
+ // See the "Companion" section of the top-level README.md for setup,
28
+ // environment variables, and exactly what credentials each backend needs.
29
+
30
+ import fs from 'node:fs';
31
+ import os from 'node:os';
32
+ import path from 'node:path';
33
+ import readline from 'node:readline';
34
+ import { PassThrough } from 'node:stream';
35
+ import { spawn, spawnSync } from 'node:child_process';
36
+ import { fileURLToPath } from 'node:url';
37
+ import {
38
+ prepareVaultContext,
39
+ extractVaultBlock,
40
+ writeVaultAutoSummary,
41
+ resolveVaultConfig,
42
+ validateVaultPath,
43
+ } from './vault-summary.js';
44
+ import {
45
+ stylingEnabled,
46
+ dim,
47
+ promptString,
48
+ turnMarker,
49
+ boxRule,
50
+ renderMarkdown,
51
+ spinnerFrame,
52
+ createMarkdownStreamer,
53
+ refreshWidth,
54
+ indentContinuation,
55
+ } from './terminal-format.js';
56
+ import { ENABLE_MOUSE_TRACKING, DISABLE_MOUSE_TRACKING, createMouseFilter } from './mouse-input.js';
57
+ import { LocalBackend } from '../extension/shared/local-backend.js';
58
+ import {
59
+ TUTOR_SYSTEM_MESSAGE,
60
+ SUBMIT_ADDENDUM,
61
+ triggerLabel,
62
+ captureHeaderLine,
63
+ captureProblemId,
64
+ captureProblemContext,
65
+ } from '../extension/shared/tutor-prompt.js';
66
+ import { createPanelBridge } from './panel-bridge.js';
67
+ import {
68
+ loadSettings,
69
+ resolveEffective,
70
+ settingsPath,
71
+ statusSummary,
72
+ claudeBackendEnv,
73
+ OLLAMA_BASE_URL,
74
+ PROVIDERS,
75
+ } from './settings.js';
76
+ import { createSettingsRpc } from './settings-rpc.js';
77
+ import { CodexBackend } from './codex-backend.js';
78
+ import { transferConversation } from '../extension/shared/conversation.js';
79
+ import { createReplyGuard, guardedSend } from '../extension/shared/reply-guard.js';
80
+ import { requestCaptureTrim, pendingTrimFor, pendingTrimHappened, isPendingTrim } from './capture-log.js';
81
+ import { stripControlChars, oneLine, sanitizeCapture } from '../extension/shared/sanitize.js';
82
+ import {
83
+ createCodeLedger,
84
+ sendsToTutor,
85
+ chatPreamble,
86
+ prepareChatTurn,
87
+ prepareSubmitTurn,
88
+ DEFAULT_MAX_PROBLEMS,
89
+ } from '../extension/shared/code-ledger.js';
90
+
91
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
92
+ const REPO_ROOT = path.join(__dirname, '..');
93
+
94
+ // --- alternate screen buffer -------------------------------------------
95
+ //
96
+ // The same mechanism vim/less/htop use: swap to a separate screen buffer
97
+ // for the whole session, so nothing companion.js prints ever mixes into
98
+ // the terminal's normal scrollback, and exiting - any path: /exit, Ctrl+C,
99
+ // a crash - restores the terminal to exactly what was on screen before
100
+ // companion.js ever ran. TTY-only: makes no sense (and would corrupt
101
+ // output) when piped. Entered and the matching cleanup registered
102
+ // together, synchronously, so there's never a window where the terminal
103
+ // could be left stuck in the alternate buffer with nothing to restore it.
104
+ //
105
+ // Most terminals (xterm and its many descendants, including kitty)
106
+ // automatically turn on "alternate scroll mode" (DECSET 1007) the moment
107
+ // the alternate screen buffer is active: with no native scrollback to show
108
+ // while in the alternate buffer, a mouse wheel notch gets translated into
109
+ // an Up/Down arrow keypress instead, on the theory that a full-screen
110
+ // alt-screen app (a pager, an editor) will handle its own scrolling with
111
+ // the arrow keys it already reads. Readline has no idea any of this is
112
+ // happening - it just sees an arrow key, which is *also* its own built-in
113
+ // binding for cycling through previously-typed input lines. The result:
114
+ // scrolling the wheel to read back through the conversation instead
115
+ // silently replaces whatever's in the prompt with an old typed message.
116
+ // Explicitly disabling 1007 stops the wheel-to-arrow-key translation, so
117
+ // scrolling no longer collides with readline's input history - though
118
+ // what the wheel does *instead* (the terminal's own alt-screen scrollback,
119
+ // if it has one, or nothing at all) is then up to the terminal, not
120
+ // something this program can fully control from here. Not restored on
121
+ // exit: 1007 only has any effect while also inside the alternate screen
122
+ // buffer (1049), so once that's left, this can't affect normal terminal
123
+ // use afterward either way.
124
+ // Mouse-wheel scrolling *inside* the alternate screen (see mouse-input.js
125
+ // and the "scrollback viewport" section further down) needs the terminal's
126
+ // own SGR mouse-reporting mode (1000+1006) turned on for the duration too -
127
+ // entered/exited in the same breath as the alternate screen itself, for
128
+ // the same reason: never leave the shell in a mode it didn't ask for once
129
+ // this program exits. process.stdin.setRawMode(true) is required for
130
+ // this - normally readline manages that on its own for a TTY input, but
131
+ // stdin here gets wired through a filtering PassThrough instead (see
132
+ // "raw input / mouse-wheel scrollback" below), which isn't a TTY, so readline
133
+ // would never touch stdin's raw mode at all left to its own devices.
134
+ if (process.stdout.isTTY) {
135
+ process.stdout.write('\x1b[?1049h');
136
+ process.stdout.write('\x1b[?1007l');
137
+ process.stdout.write(ENABLE_MOUSE_TRACKING);
138
+ if (process.stdin.isTTY) process.stdin.setRawMode(true);
139
+ process.on('exit', () => {
140
+ process.stdout.write(DISABLE_MOUSE_TRACKING);
141
+ process.stdout.write('\x1b[?1049l');
142
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
143
+ });
144
+ }
145
+
146
+ // --- config ---------------------------------------------------------------
147
+
148
+ // Which LLM the tutor talks to and how comes from settings.js: per field, an
149
+ // environment variable wins over the saved settings file (written by the
150
+ // extension's options page through settings-rpc.js), which wins over the
151
+ // default. A set environment variable locks its field in the options page.
152
+ // `effective` is replaced as a whole after each save from the options page
153
+ // (applySettings, below), so everything that reads it sees the new values
154
+ // from the next turn on. It carries the API key, so it is never published.
155
+ const SETTINGS_FILE = settingsPath(process.env);
156
+ const loadedSettings = loadSettings(SETTINGS_FILE);
157
+ let storedSettings = loadedSettings.settings;
158
+ let effective = resolveEffective(storedSettings, process.env);
159
+
160
+ // Ollama's own default OpenAI-compat address, used when nothing else is set.
161
+ // Any OpenAI-compatible server works (LM Studio, llama.cpp server, vLLM,
162
+ // text-generation-webui, LocalAI, ...).
163
+ const DEFAULT_BASE_URL = OLLAMA_BASE_URL;
164
+
165
+ // Which HTTP API LocalBackend actually speaks: 'openai' (the standard
166
+ // /v1/chat/completions shape every OpenAI-compatible server understands) or
167
+ // 'native' (Ollama's own /api/chat, which, unlike any OpenAI-compatible
168
+ // endpoint, honors a requested context window, `num_ctx`; see LOCAL_NUM_CTX
169
+ // and LocalBackend's class comment). 'auto' (the default, null here) probes
170
+ // the server once (local-server.js: Ollama answers GET /api/version) and uses
171
+ // 'native' only for a server that answers like Ollama, whatever its address
172
+ // or port; anything else, or a failed probe, is 'openai'. Explicit
173
+ // "openai"/"native" (case-insensitive) always wins.
174
+ const LOCAL_API_RAW = (process.env.COMPANION_LOCAL_API || 'auto').toLowerCase();
175
+ const LOCAL_API = LOCAL_API_RAW === 'openai' || LOCAL_API_RAW === 'native' ? LOCAL_API_RAW : null;
176
+
177
+ // The configured context window in tokens. It is what Ollama's native API is
178
+ // asked for (`options.num_ctx`), and the most LocalBackend ever budgets for
179
+ // on any server: the window actually used is the smallest of this, what the
180
+ // server reports when probed, and what a reply shows the server really kept
181
+ // (see resolveContextWindow in context-budget.js). 8192 is double the 4096
182
+ // this project's configured Ollama model (gemma4:26b) runs with by default,
183
+ // and was confirmed live to turn a real single-capture failure (a normal
184
+ // ~150-line solution plus its problem description, no prior history) into a
185
+ // normal completed reply with the exact same input.
186
+ // From settings.js: the "Context window (tokens)" field of the local
187
+ // provider, or COMPANION_LOCAL_NUM_CTX, which wins over it.
188
+ const DEFAULT_LOCAL_NUM_CTX = 8192;
189
+
190
+ // How many tokens of LOCAL_NUM_CTX to hold back for the model's own
191
+ // thinking + answer when pre-flight checking a request's estimated size
192
+ // (see estimateTokens/LocalBackend.sendMessage below) - the request itself
193
+ // only gets the remainder as budget. Chosen from live investigation data
194
+ // (data/companion-context-capacity-test in the firstmate repo): eval_count
195
+ // (thinking+answer combined) ranged 1,141-2,607 tokens across a wide sweep
196
+ // of real prompt sizes against this project's configured model, largely
197
+ // independent of prompt length. 2600 sits just above that observed max, so
198
+ // an unusually long "thinking" pass still has room without needing a
199
+ // per-request guess.
200
+ const LOCAL_RESERVE_TOKENS = Number(process.env.COMPANION_LOCAL_RESERVE_TOKENS || 2600);
201
+
202
+ // The other options also come from settings.js (`effective.options`):
203
+ // - vaultAutoSummary (VAULT_AUTO_SUMMARY), off by default: a portable
204
+ // checkout won't have this vault's Study/Algorithms structure, or even
205
+ // use Obsidian. See "Vault auto-summary" below and README.md.
206
+ // - autoClearContext (COMPANION_AUTO_CLEAR_CONTEXT), on by default: see the
207
+ // "automatic context reset on problem switch" section below for why
208
+ // leaving it on is the safer default (a stale problem's code and
209
+ // discussion would otherwise keep being resent long after the
210
+ // conversation moved on).
211
+ // - localMaxHistoryTurns (COMPANION_LOCAL_MAX_HISTORY_TURNS), 6 by default:
212
+ // how many user+assistant pairs LocalBackend and CodexBackend keep in
213
+ // full; older ones are condensed into one short summary
214
+ // (context-summary.js). Only a cap on the count: every local request is
215
+ // also fitted to the token budget before it is sent (fitWithSummary).
216
+ // 0 turns the count cap off.
217
+ // In-page tutor panel (the extension's "Tutor" tab on leetcode.com): mirrors
218
+ // this conversation to the browser through the relay server and accepts
219
+ // messages typed there. On by default; COMPANION_PANEL=0 turns the bridge off
220
+ // and leaves the terminal chat exactly as it was. See panel-bridge.js.
221
+ const PANEL_ENABLED = !/^(0|false|no)$/i.test(process.env.COMPANION_PANEL || '');
222
+ // Run by the Native Messaging helper (helper/host.js) instead of from a
223
+ // terminal: stdin is the helper's pipe (it writes commands such as /reset,
224
+ // and closing it is the signal to quit), output goes to the helper's log
225
+ // file, and the helper owns the relay. A closed terminal's SIGHUP does not
226
+ // apply; the helper ends this process with SIGTERM or by closing stdin.
227
+ const HEADLESS = /^(1|true|yes)$/i.test(process.env.COMPANION_HEADLESS || '');
228
+ // With nothing heard from the relay (so no panel and no extension behind
229
+ // it) for this long, quit on our own, so a browser or helper that vanished
230
+ // cannot leave this process behind. Headless only by default; 0 turns it off.
231
+ const IDLE_EXIT_MS = Number(process.env.COMPANION_IDLE_EXIT_MS ?? (HEADLESS ? 3 * 60 * 1000 : 0)) || 0;
232
+
233
+ const { vaultPath: VAULT_PATH, algorithmsSubfolder: VAULT_ALGORITHMS_SUBFOLDER } = resolveVaultConfig();
234
+ // Fail loudly at startup, not on the first Submit - a wrong or unset vault
235
+ // path should never silently fabricate a new folder tree (see
236
+ // vault-notes.js's validateVaultPath). Only checked when the feature is
237
+ // actually on, so a portable checkout with it off never needs a real vault.
238
+ // A save from the options page is checked the same way (settings-rpc.js's
239
+ // validateVault) and refused instead.
240
+ if (effective.options.vaultAutoSummary) validateVaultPath(VAULT_PATH);
241
+
242
+ const SCRATCH_DIR =
243
+ process.env.LEETCODE_COMPANION_SCRATCH ||
244
+ path.join(os.homedir(), '.local', 'state', 'leetcode-companion', 'scratch');
245
+ const CAPTURES_PATH =
246
+ process.env.LEETCODE_CAPTURES_FILE ||
247
+ path.join(REPO_ROOT, 'relay-server', 'data', 'captures.jsonl');
248
+ const STATE_PATH =
249
+ process.env.LEETCODE_COMPANION_STATE_FILE || path.join(__dirname, '.companion-state.json');
250
+ const POLL_MS = Number(process.env.LEETCODE_COMPANION_POLL_MS || 1000);
251
+ // The size backstop for clearing the capture log (see "capture tailing"
252
+ // below): once the handled part passes this, it is dropped even without a
253
+ // problem switch.
254
+ const CAPTURE_LOG_TRIM_BYTES = Number(process.env.LEETCODE_CAPTURES_TRIM_BYTES || 4 * 1024 * 1024);
255
+
256
+ // Mirrors server.js's own HOST/PORT resolution exactly (127.0.0.1, and
257
+ // CAPTURE_PORT if set) so the health check below asks the same address the
258
+ // relay server actually binds to.
259
+ const RELAY_HOST = '127.0.0.1';
260
+ const RELAY_PORT = process.env.CAPTURE_PORT ? Number(process.env.CAPTURE_PORT) : 8135;
261
+ const RELAY_SERVER_DIR = path.join(REPO_ROOT, 'relay-server');
262
+
263
+ // --- backend adapters -------------------------------------------------------
264
+ //
265
+ // Shared shape: async sendMessage(text) -> Promise<string>. Each backend
266
+ // manages its own conversation continuity however is natural for it (the
267
+ // Claude backend resumes its own SDK session by id; the local backend keeps
268
+ // an explicit messages array and resends it in full, OpenAI-chat style).
269
+
270
+ class ClaudeBackend {
271
+ constructor({ model, apiKey = null }) {
272
+ this.model = model;
273
+ this.apiKey = apiKey;
274
+ this.sessionId = undefined;
275
+ this.queryFn = null;
276
+ }
277
+
278
+ async ensureLoaded() {
279
+ if (this.queryFn) return;
280
+ let mod;
281
+ try {
282
+ mod = await import('@anthropic-ai/claude-agent-sdk');
283
+ } catch (err) {
284
+ throw new Error(
285
+ `could not load @anthropic-ai/claude-agent-sdk (run "npm install" in companion/ first): ${err.message}`
286
+ );
287
+ }
288
+ this.queryFn = mod.query;
289
+ }
290
+
291
+ // `problemContext` (see companion.js's captureProblemContext) is supplied
292
+ // whenever a problem is known - only actually used below on a fresh SDK
293
+ // session's very first message (see promptText). `currentCode`/`compactText`
294
+ // (the stateless backends' code pinning) are not needed here: a resumed
295
+ // session keeps the first full code and every later diff, and a reset starts a
296
+ // new session. See docs/context-measurement.md for the measured case for
297
+ // keeping the session and what it still lacks.
298
+ async sendMessage(text, { onChunk, problemContext } = {}) {
299
+ await this.ensureLoaded();
300
+ fs.mkdirSync(SCRATCH_DIR, { recursive: true });
301
+
302
+ // A plain string here fully replaces Claude Code's own default system
303
+ // prompt rather than appending to it - the SDK only preserves the
304
+ // default when systemPrompt is `{ type: 'preset', preset: 'claude_code' }`
305
+ // (see node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts). That's the
306
+ // right call here: this companion has no tools configured and isn't
307
+ // acting as a coding agent over this repo, so the Claude Code framing
308
+ // (tool-use conventions, CLI-oriented tone) would only get in the way of
309
+ // the tutor persona.
310
+ //
311
+ // systemPrompt only controls the system-prompt *text* though - it says
312
+ // nothing about which tools get defined in the model's context or which
313
+ // filesystem settings (project/user/local CLAUDE.md, .mcp.json, plugins)
314
+ // get loaded, both of which default to "everything available" when left
315
+ // unset (see sdk.d.ts's `tools`/`mcpServers`/`settingSources`). Measured
316
+ // live via a direct SDK call matching this shape (custom cwd, this same
317
+ // plain-string systemPrompt, nothing else set): the resulting turn
318
+ // carried ~12,500 tokens of context that had nothing to do with the
319
+ // tutor conversation at all - the SDK's ~25 built-in tools (Task, Bash,
320
+ // Edit, Write, Read, and the rest) plus, in an environment with any MCP
321
+ // connectors configured (this one had Gmail/Calendar/Drive), every one
322
+ // of *their* tool definitions too - none of which this companion ever
323
+ // calls. `tools: []` is the specific fix for the built-in set - note
324
+ // `allowedTools: []` looked like the obvious option but does NOT do
325
+ // this: per sdk.d.ts it only skips the permission prompt for listed
326
+ // tools, it doesn't remove anything from context, confirmed live (still
327
+ // ~10,800 tokens with `allowedTools: []` alone, tool list unchanged).
328
+ // `mcpServers: {}` plus `strictMcpConfig: true` is the equivalent fix for
329
+ // MCP-server tool definitions - `strictMcpConfig` is required, not just
330
+ // `mcpServers: {}` alone, since without it the SDK still merges in
331
+ // whatever a user's own project/user config declares. `settingSources:
332
+ // []` additionally opts out of loading any CLAUDE.md/AGENTS.md at all
333
+ // (filesystem settings default to "load everything" too, independent of
334
+ // the systemPrompt override above) - defense in depth on top of `cwd`
335
+ // already pointing at SCRATCH_DIR, a dedicated non-project directory, so
336
+ // this repo's own 53KB CLAUDE.md was never actually reachable from here
337
+ // in practice (confirmed: no CLAUDE.md exists anywhere in SCRATCH_DIR's
338
+ // own directory-ancestor chain either) - but making the isolation
339
+ // explicit here means that stays true regardless of where SCRATCH_DIR
340
+ // ever points. With all four set, the same measured call dropped to 197
341
+ // tokens total, zero cache overhead - a ~98% reduction - confirming the
342
+ // ~12,500 tokens above really was unused tool/settings scaffolding, not
343
+ // anything the tutor persona needed.
344
+ const options = {
345
+ cwd: SCRATCH_DIR,
346
+ systemPrompt: TUTOR_SYSTEM_MESSAGE,
347
+ tools: [],
348
+ mcpServers: {},
349
+ strictMcpConfig: true,
350
+ settingSources: [],
351
+ env: claudeBackendEnv(this.apiKey),
352
+ };
353
+ if (this.model) options.model = this.model;
354
+ if (this.sessionId) options.resume = this.sessionId;
355
+ // Only opt into partial-message events when a caller actually wants
356
+ // them - onReply's vault-block-stripping path (see sendAndPrint) calls
357
+ // this with no onChunk at all, and this stays a plain, unchanged
358
+ // request in that case.
359
+ if (onChunk) options.includePartialMessages = true;
360
+
361
+ // A fresh SDK session (no resumed session id yet) needs the problem
362
+ // description inline, once - unlike LocalBackend, this backend's own
363
+ // continuity is a resumed server-side session with no trimming (see
364
+ // resetContext below), so the description only needs supplying once per
365
+ // session, right at its start, and stays available for the rest of this
366
+ // problem's captures until AUTO_CLEAR_CONTEXT resets sessionId on a
367
+ // genuine problem switch - no separate pinning/reconstruction logic
368
+ // needed the way LocalBackend.buildMessages requires.
369
+ let promptText = text;
370
+ if (!this.sessionId && problemContext?.description) {
371
+ promptText =
372
+ `Problem currently being discussed: ${problemContext.title} (${problemContext.slug})\n\n` +
373
+ `${problemContext.description}\n\n${text}`;
374
+ }
375
+
376
+ let resultText = null;
377
+ let errorNote = null;
378
+ for await (const message of this.queryFn({ prompt: promptText, options })) {
379
+ if (message.session_id) this.sessionId = message.session_id;
380
+ // A `stream_event` message wraps a raw Anthropic API stream event
381
+ // (see SDKPartialAssistantMessage in sdk.d.ts); only its text deltas
382
+ // are relevant here - tool-use/thinking/citation deltas never appear
383
+ // for this backend, since it's configured with no tools.
384
+ if (onChunk && message.type === 'stream_event') {
385
+ const event = message.event;
386
+ if (event?.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
387
+ onChunk(event.delta.text);
388
+ }
389
+ }
390
+ if (message.type === 'result') {
391
+ if (message.subtype === 'success') {
392
+ resultText = message.result;
393
+ } else {
394
+ errorNote = `query ended with ${message.subtype}${
395
+ message.errors && message.errors.length ? `: ${message.errors.join('; ')}` : ''
396
+ }`;
397
+ }
398
+ }
399
+ }
400
+ if (resultText !== null && resultText.trim()) return resultText;
401
+ if (resultText !== null) {
402
+ throw new Error('Agent SDK query succeeded but returned an empty result');
403
+ }
404
+ throw new Error(errorNote || 'no result message received from the Agent SDK');
405
+ }
406
+
407
+ // Drops the resumed session id so the next sendMessage starts a fresh SDK
408
+ // session (options.resume is only set at all when this.sessionId is
409
+ // truthy - see sendMessage above) rather than continuing the old one.
410
+ resetContext() {
411
+ this.sessionId = undefined;
412
+ }
413
+ }
414
+
415
+ // LocalBackend (any OpenAI-compatible server, or Ollama's native API) lives
416
+ // in extension/shared/local-backend.js, shared with the extension's own
417
+ // engine.
418
+
419
+ // Builds the backend for an effective configuration (settings.js's
420
+ // resolveEffective). Throws, with a message fit for the options page, when
421
+ // one cannot be built from it. Used at startup and by every options-page
422
+ // save or connection test (settings-rpc.js), so all of them agree.
423
+ function makeBackend(config) {
424
+ const { provider } = config;
425
+ if (provider === 'claude') return new ClaudeBackend({ model: config.model });
426
+ if (provider === 'anthropic') {
427
+ if (!config.apiKey) throw new Error('Anthropic API key needs an API key (or ANTHROPIC_API_KEY)');
428
+ return new ClaudeBackend({ model: config.model, apiKey: config.apiKey });
429
+ }
430
+ if (provider === 'local' || provider === 'openai') {
431
+ return new LocalBackend({
432
+ baseUrl: config.baseUrl || DEFAULT_BASE_URL,
433
+ model: config.model,
434
+ apiKey: config.apiKey,
435
+ maxHistoryTurns: config.options.localMaxHistoryTurns,
436
+ // A hosted OpenAI API always speaks the OpenAI shape; never probe it
437
+ // for Ollama's native API.
438
+ apiStyle: provider === 'openai' ? 'openai' : LOCAL_API,
439
+ numCtx: config.contextTokens || DEFAULT_LOCAL_NUM_CTX,
440
+ reserveTokens: LOCAL_RESERVE_TOKENS,
441
+ });
442
+ }
443
+ if (provider === 'chatgpt') {
444
+ return new CodexBackend({
445
+ model: config.model,
446
+ cwd: path.join(SCRATCH_DIR, 'codex'),
447
+ systemPrompt: TUTOR_SYSTEM_MESSAGE,
448
+ maxHistoryTurns: config.options.localMaxHistoryTurns,
449
+ });
450
+ }
451
+ throw new Error(`unknown provider "${provider}" (expected one of ${Object.keys(PROVIDERS).join(', ')})`);
452
+ }
453
+
454
+ // Swappable: an options-page save replaces it through the turn queue (see
455
+ // applySettings), so a swap never lands in the middle of a turn.
456
+ let backend = makeBackend(effective);
457
+
458
+ // The label used in "error talking to backend (...)" lines.
459
+ function backendLabel() {
460
+ return effective.provider;
461
+ }
462
+
463
+ // --- relay server lifecycle -------------------------------------------------
464
+ //
465
+ // companion.js now owns the relay server's lifecycle: it starts one on
466
+ // launch if nothing is already listening, and stops the one it started when
467
+ // it exits - one command instead of two. If a server was already running
468
+ // (started separately, or left over from another companion instance), this
469
+ // instance leaves it alone on both ends: it doesn't spawn a second one, and
470
+ // it doesn't stop it on exit. See the "Companion" section of the top-level
471
+ // README.md for the durability tradeoff this implies (captures during a gap
472
+ // where companion.js isn't running are no longer durably logged).
473
+
474
+ // Set only if *this* process spawned the relay server; stays null if an
475
+ // already-running server was found instead, so the exit handler below knows
476
+ // whether it's allowed to stop it.
477
+ let relayServerChild = null;
478
+
479
+ async function isRelayServerUp() {
480
+ const controller = new AbortController();
481
+ const timeout = setTimeout(() => controller.abort(), 500);
482
+ try {
483
+ // OPTIONS /capture is answered unconditionally by server.js (see its
484
+ // CORS_HEADERS comment) regardless of body or other headers, making it
485
+ // the cheapest real request to probe with - a plain TCP connect would
486
+ // also tell us *something* is listening on the port, but not that it's
487
+ // actually this relay server.
488
+ await fetch(`http://${RELAY_HOST}:${RELAY_PORT}/capture`, {
489
+ method: 'OPTIONS',
490
+ signal: controller.signal,
491
+ });
492
+ return true;
493
+ } catch {
494
+ return false;
495
+ } finally {
496
+ clearTimeout(timeout);
497
+ }
498
+ }
499
+
500
+ async function ensureRelayServer() {
501
+ if (await isRelayServerUp()) return;
502
+ // The helper starts the relay before this process and stops it after, so a
503
+ // relay started from here would outlive both.
504
+ if (HEADLESS) {
505
+ printBanner(dim(`companion: warning: the relay is not answering on http://${RELAY_HOST}:${RELAY_PORT}`), console.error);
506
+ return;
507
+ }
508
+
509
+ // Pass the environment through unchanged: if Thomson set CAPTURE_PORT or
510
+ // CAPTURE_LOG_PATH for this companion invocation, server.js reads those
511
+ // same variables itself, so the spawned server naturally stays in sync
512
+ // with whatever this companion process is configured for.
513
+ const child = spawn(process.execPath, ['server.js'], {
514
+ cwd: RELAY_SERVER_DIR,
515
+ env: process.env,
516
+ detached: true,
517
+ stdio: 'ignore',
518
+ });
519
+ child.unref();
520
+ relayServerChild = child;
521
+ printBanner(dim('companion: relay server not detected, starting it now'));
522
+
523
+ // Wait (bounded) for it to actually come up, so the very first capture
524
+ // isn't lost to a race between spawning it and its listen() callback.
525
+ const deadline = Date.now() + 5000;
526
+ while (Date.now() < deadline) {
527
+ // eslint-disable-next-line no-await-in-loop
528
+ if (await isRelayServerUp()) return;
529
+ // eslint-disable-next-line no-await-in-loop
530
+ await new Promise((resolve) => setTimeout(resolve, 150));
531
+ }
532
+ printBanner(
533
+ dim(
534
+ `companion: warning: started the relay server but it hasn't answered on http://${RELAY_HOST}:${RELAY_PORT} yet - continuing anyway`
535
+ ),
536
+ console.error
537
+ );
538
+ }
539
+
540
+ function stopRelayServerIfOwned() {
541
+ if (!relayServerChild) return;
542
+ try {
543
+ process.kill(relayServerChild.pid, 'SIGTERM');
544
+ } catch {
545
+ // Already gone (e.g. it crashed on its own) - nothing to do.
546
+ }
547
+ relayServerChild = null;
548
+ }
549
+
550
+ // A synchronous 'exit' handler is the one place guaranteed to run
551
+ // regardless of which path led there - rl.close() -> process.exit(0) after
552
+ // /exit, /quit, or Ctrl+C; the pollLoop fatal-error process.exit(1); or a
553
+ // plain fall-through exit - so it's the single point of truth for "did this
554
+ // instance start the server, and if so, stop it."
555
+ process.on('exit', stopRelayServerIfOwned);
556
+
557
+ // --- in-page panel bridge ----------------------------------------------------
558
+ //
559
+ // Everything printed to the terminal for the tutor conversation is also
560
+ // published as small structured events (user message, capture, turn
561
+ // start/chunk/end, notice) so the panel can render the same conversation in
562
+ // the browser. The terminal path stays the source of truth: every publish is
563
+ // fire-and-forget through panelPublish, and a missing/slow relay can never
564
+ // block or alter what the terminal does.
565
+ let panelBridge = null;
566
+ // The problem the conversation is currently about, as the panel's own
567
+ // per-problem transcript key. Updated by captures and by panel messages.
568
+ let panelProblemSlug = null;
569
+ let panelTurnCounter = 0;
570
+ // What code the tutor has been shown, the newest code per problem, and which
571
+ // problem is active (see code-ledger.js). In memory only: a restart starts
572
+ // the backends from nothing too.
573
+ const ledger = createCodeLedger({ maxProblems: DEFAULT_MAX_PROBLEMS });
574
+ // Hint count and what the student named or saw, for the post-check guard
575
+ // (extension/shared/reply-guard.js); reset with the tutor context.
576
+ const replyGuard = createReplyGuard();
577
+
578
+ // The panel's "Processing" light: how many tutor turns are running or
579
+ // queued. A typed message counts from the moment it is queued, a Submit from
580
+ // when it turns out to need the tutor (a Run, or an unchanged Submit, never
581
+ // does). Going idle is told a moment late, so the gap between two queued
582
+ // turns does not flash "Connected" in between.
583
+ let busyTurns = 0;
584
+ let busyIdleTimer = null;
585
+ const BUSY_IDLE_DELAY_MS = 100;
586
+ function holdBusy() {
587
+ busyTurns += 1;
588
+ if (busyIdleTimer) {
589
+ clearTimeout(busyIdleTimer);
590
+ busyIdleTimer = null;
591
+ }
592
+ if (panelBridge) panelBridge.setBusy(true);
593
+ let released = false;
594
+ return function releaseBusy() {
595
+ if (released) return;
596
+ released = true;
597
+ busyTurns -= 1;
598
+ if (busyTurns > 0 || busyIdleTimer) return;
599
+ busyIdleTimer = setTimeout(() => {
600
+ busyIdleTimer = null;
601
+ if (busyTurns === 0 && panelBridge) panelBridge.setBusy(false);
602
+ }, BUSY_IDLE_DELAY_MS);
603
+ busyIdleTimer.unref();
604
+ };
605
+ }
606
+
607
+ function panelPublish(event) {
608
+ if (!panelBridge) return;
609
+ // The panel keeps one transcript per problem and only shows the one for the
610
+ // page it is on, so an event that belongs to no problem (a message typed in
611
+ // the terminal before any capture, say) would be stored under an empty key
612
+ // that no page ever displays: dead weight in the relay's replay buffer and
613
+ // in every panel. Not sending it is the honest outcome. Chunks carry no slug
614
+ // of their own; sendAndPrintTurn skips those for such a turn.
615
+ if ('problemSlug' in event && !event.problemSlug) return;
616
+ panelBridge.publish(event);
617
+ }
618
+
619
+ function newPanelTurnId() {
620
+ panelTurnCounter += 1;
621
+ return `t${Date.now().toString(36)}-${panelTurnCounter}`;
622
+ }
623
+
624
+ // Capture formatting (triggerLabel, captureHeaderLine, SUBMIT_ADDENDUM,
625
+ // captureProblemId, captureProblemContext) lives in
626
+ // extension/shared/tutor-prompt.js, shared with the extension's own engine.
627
+
628
+ // --- automatic context reset on problem switch (AUTO_CLEAR_CONTEXT) --------
629
+ //
630
+ // The active problem is whichever one the student last sent a message, Run
631
+ // or Submit from. Activity on a different problem hard-resets the backend's
632
+ // own continuity (see each backend's resetContext, above) instead of leaving
633
+ // it to the model to notice the switch, drops what the ledger knew about the
634
+ // old problem (code-ledger.js's activate), and asks the relay to drop the old
635
+ // problem's buffered chat. The part of captures.jsonl already handled is
636
+ // dropped too (see "capture tailing" below); nothing about an old problem is
637
+ // kept.
638
+ //
639
+ // Identifier: capture.problemSlug, falling back to capture.problemTitle when
640
+ // slug is absent. The very first problem this process sees resets nothing
641
+ // (there is no prior context to clear), so a restart needs no persisted
642
+ // state either.
643
+
644
+ // Makes problemId the active problem, resetting everything about the old one
645
+ // when it changed (and AUTO_CLEAR_CONTEXT is on). Runs inside the turn queue,
646
+ // so a reset never lands in the middle of a backend call.
647
+ function activateProblem(problemId, slug) {
648
+ const result = ledger.activate(problemId, { slug, resetOnSwitch: effective.options.autoClearContext });
649
+ if (!result.reset) return result;
650
+ backend.resetContext();
651
+ replyGuard.reset();
652
+ if (result.dropped && result.dropped.slug && panelBridge) panelBridge.dropProblem(result.dropped.slug);
653
+ // The old problem's captures are no longer needed either: the handled
654
+ // part of the log goes at the end of the current poll (see pollLoop).
655
+ captureTrimWanted = true;
656
+ printAboveInput(
657
+ consumeTurnBoundary() + dim(`companion: new problem detected (${oneLine(problemId) || 'unknown'}) - clearing tutor context`)
658
+ );
659
+ panelPublish({ type: 'notice', problemSlug: slug || null, text: 'New problem detected. Started a fresh tutor session.', switch: true });
660
+ return result;
661
+ }
662
+
663
+ // `/reset` (typed, or written by the helper when the active problem's tab
664
+ // stayed closed for its grace period): the tutor forgets the conversation and
665
+ // the code it held, so the next message starts fresh with the full code. The
666
+ // relay, the panel feed and the process itself stay as they are.
667
+ function resetTutorContext() {
668
+ backend.resetContext();
669
+ replyGuard.reset();
670
+ ledger.forgetHeld();
671
+ printAboveInput(consumeTurnBoundary() + dim('companion: tutor context cleared'));
672
+ }
673
+
674
+ // --- vault auto-summary (VAULT_AUTO_SUMMARY) --------------------------------
675
+ //
676
+ // Turns the companion's own tutoring response to a Submit into a durable,
677
+ // structured note in Thomson's Obsidian vault, updated in place as the
678
+ // session progresses. The actual logic (building the addendum, parsing the
679
+ // model's reply, writing the notes) lives in vault-summary.js, split out so
680
+ // it can be unit-tested without booting this file's readline loop / relay
681
+ // server lifecycle - see that file's header for the full design, and
682
+ // README.md → "Vault auto-summary" for user-facing docs.
683
+
684
+ // --- terminal chat loop ------------------------------------------------------
685
+ //
686
+ // A capture can arrive from the file tailer at any moment, including while
687
+ // Thomson is mid-way through typing a line. The input area is a small
688
+ // pinned box - a thin rule, then the "> " prompt - that stays the last
689
+ // thing on screen: printAboveInput clears the box, prints the new text
690
+ // above where it was, then redraws the box fresh (see drawBox - readline's
691
+ // own rl.prompt(true) already renders the current line/cursor correctly on
692
+ // its own, so there's nothing to save and reinsert). Both captures and
693
+ // normal replies to typed input go through this same path, including each
694
+ // individually-streamed piece of a reply, so the box stays visible (pinned
695
+ // at the bottom) throughout a reply rather than only reappearing once it's
696
+ // fully done.
697
+
698
+ // --- raw input / mouse-wheel scrollback -------------------------------
699
+ //
700
+ // In a real terminal, readline reads from `readlineInput` - a filtered
701
+ // copy of stdin - rather than process.stdin directly. Mouse-wheel SGR
702
+ // reports (enabled above, alongside the alternate screen) arrive on stdin
703
+ // interleaved with ordinary keystrokes, and readline's own keypress
704
+ // decoder has no understanding of that protocol at all: fed one directly,
705
+ // it doesn't ignore it, it shreds it into a run of spurious single-
706
+ // character keypresses that get inserted straight into whatever's being
707
+ // typed (confirmed live - see mouse-input.js's header). createMouseFilter
708
+ // sits in front of readline instead: every byte that isn't part of a
709
+ // wheel report passes through to `filteredStdin` unchanged and in order,
710
+ // so readline's own line editing, arrow-key history, Ctrl+C handling, and
711
+ // Page Up detection all keep working exactly as before against a stream
712
+ // that's identical to stdin minus wheel reports. handleWheel (defined
713
+ // further down, alongside the scrollback viewport it drives) is only ever
714
+ // invoked once a real wheel event arrives, well after the rest of this
715
+ // file's top-level code has finished running, so referencing it here
716
+ // ahead of its own definition is safe. Non-TTY mode is untouched -
717
+ // readline reads process.stdin directly there, same as before this
718
+ // feature existed, and none of this wiring runs.
719
+ const filteredStdin = process.stdout.isTTY ? new PassThrough() : null;
720
+ if (filteredStdin) {
721
+ const mouseFilter = createMouseFilter({ output: filteredStdin, onWheel: handleWheel });
722
+ process.stdin.on('data', (chunk) => mouseFilter.push(chunk));
723
+ process.stdin.on('end', () => filteredStdin.end());
724
+ }
725
+
726
+ const rl = readline.createInterface({
727
+ input: filteredStdin || process.stdin,
728
+ output: process.stdout,
729
+ prompt: promptString(),
730
+ });
731
+ let stopping = false;
732
+
733
+ // How many terminal rows, ending at the cursor's current row and extending
734
+ // upward, are occupied by whatever's reserved at the bottom right now that
735
+ // *isn't* the box itself - i.e. the 1-row spinner, or 0 when nothing's
736
+ // reserved (mid content-write, or right after readline's own native Enter
737
+ // handling already claimed the row). 0 until anything is first drawn. See
738
+ // boxShown below for why the box's own row count is never stored here.
739
+ // Only meaningful in styled/TTY mode; non-TTY output never reserves any
740
+ // rows at all.
741
+ let bottomRowsShown = 0;
742
+
743
+ // True exactly when the box (the blank breathing-room line, the rule, and
744
+ // the prompt - see drawBoxRaw) is currently the thing occupying the bottom
745
+ // reserved rows, as opposed to the spinner or nothing. Unlike
746
+ // bottomRowsShown, the box's own row count is never cached in a variable -
747
+ // it's always computed fresh, on demand, via promptRowCount() (see
748
+ // reservedBottomRows below). That distinction matters: readline redraws its
749
+ // own prompt - correctly reflecting how many physical rows a long typed/
750
+ // pasted line currently wraps to - on every keystroke, entirely on its own,
751
+ // with no call into any of this file's own drawing code. A row count cached
752
+ // at the box's last *drawBoxRaw* call goes stale the instant the user types
753
+ // past (or back under) a wrap boundary afterward; confirmed live: typing a
754
+ // line long enough to wrap to a second terminal row, then triggering an
755
+ // ordinary redraw (a capture arriving), cleared only the row count cached
756
+ // from before typing started, leaving part of the actual on-screen wrapped
757
+ // prompt behind as leftover garbage that the new content got written on top
758
+ // of - corrupting everything drawn afterward, including the box's own next
759
+ // redraw. Computing promptRowCount() fresh every time avoids this entirely,
760
+ // since rl.line (and the terminal's actual on-screen rendering of it) is
761
+ // always already current by the time anything here runs.
762
+ let boxShown = false;
763
+
764
+ // The number of rows clearBottomRows needs to clear right now: the box's
765
+ // own current height (2 fixed rows - the blank line and the rule - plus
766
+ // however many rows its prompt currently wraps to, computed fresh; see
767
+ // boxShown above) when the box is what's on screen, or bottomRowsShown
768
+ // (the spinner's fixed 1 row, or 0) otherwise. Deliberately does NOT count
769
+ // the extra blank breathing-room row currentVisibleRows() reserves below
770
+ // the prompt (see that function) - this is "rows at and above the cursor's
771
+ // own row," and that reserved row sits below the cursor, never touched by
772
+ // clearBottomRows's own upward-clearing loop.
773
+ function reservedBottomRows() {
774
+ return boxShown ? 2 + promptRowCount() : bottomRowsShown;
775
+ }
776
+
777
+ // True for the duration of one sendAndPrint call (a typed message's reply,
778
+ // or a capture's whole exchange) - two independent things check this before
779
+ // touching the screen outside the normal print flow: the resize handler
780
+ // (near the bottom of this file), and Page Up/showHistory (below). Mid-turn,
781
+ // bottomRowsShown might mean "a 1-row spinner" rather than "a 2-row box" at
782
+ // any given moment, so forcing a redraw or spawning a pager there would
783
+ // corrupt whatever's actually on screen - the resize handler falls back to
784
+ // only updating the width for future draws, and Page Up is simply ignored,
785
+ // until the turn's own writes catch up naturally. Idle (turnActive false),
786
+ // the box's state is simple and known, so both are safe to act on
787
+ // immediately.
788
+ let turnActive = false;
789
+
790
+ // Every line of actual conversation content (captures, replies, warnings,
791
+ // errors - not the box's own rule/prompt, not the transient spinner) is
792
+ // also recorded here, verbatim, ANSI codes included, in addition to being
793
+ // printed live - this is the only durable record of anything that's
794
+ // scrolled off screen, since the alternate screen buffer this program runs
795
+ // in (see the top of this file) has no native scrollback of its own in
796
+ // most terminals once content exceeds one screen's height. Page Up opens
797
+ // it in a real pager (`less`) rather than a hand-rolled scroll view -
798
+ // less already gets wrapping, search, and its own Page Up/Down exactly
799
+ // right. Capped by character count, not line count, so one enormous
800
+ // unbroken piece of content can't make the trim loop below do
801
+ // disproportionate work for how much it actually frees.
802
+ const HISTORY_MAX_CHARS = 2_000_000;
803
+ let historyChars = 0;
804
+ const historyBuffer = [];
805
+ function recordHistory(text) {
806
+ if (!text) return;
807
+ historyBuffer.push(text);
808
+ historyChars += text.length;
809
+ while (historyChars > HISTORY_MAX_CHARS && historyBuffer.length > 1) {
810
+ historyChars -= historyBuffer.shift().length;
811
+ }
812
+ }
813
+
814
+ // Startup/relay-server-lifecycle notices (below and near ensureRelayServer)
815
+ // print via this instead of a bare console.log/console.error - they're real
816
+ // on-screen rows just like anything else, so they need to be recorded (for
817
+ // Page Up, and so redrawViewport's own padding math below counts them) the
818
+ // same as every other line this program ever prints. logFn defaults to
819
+ // console.log;
820
+ // ensureRelayServer's own warning passes console.error to keep that line's
821
+ // existing stream, since both still land on the same physical terminal
822
+ // either way.
823
+ function printBanner(text, logFn = console.log) {
824
+ logFn(text);
825
+ if (process.stdout.isTTY) recordHistory(`${text}\n`);
826
+ }
827
+
828
+ // historyBuffer's own chunks are always newline-terminated (every caller of
829
+ // recordHistory ends its text with exactly one trailing '\n' - see each call
830
+ // site) - so joining and naively splitting on '\n' leaves one trailing empty
831
+ // string that isn't a real displayed row. Dropping it keeps this an exact
832
+ // count of on-screen lines, not off by one - which matters below, since an
833
+ // off-by-one here would just reproduce a smaller version of the bug this is
834
+ // fixing.
835
+ function historyLines() {
836
+ const joined = historyBuffer.join('');
837
+ if (!joined) return [];
838
+ const lines = joined.split('\n');
839
+ if (lines[lines.length - 1] === '') lines.pop();
840
+ return lines;
841
+ }
842
+
843
+ // True once the box has been pinned flush to the terminal's actual last row
844
+ // with the screen genuinely full of real content behind it (contentRows >=
845
+ // visibleRows) - see drawBox/redrawViewport, both of which set this only
846
+ // under that real condition now, not merely "a draw happened."
847
+ let screenFilledToBox = false;
848
+
849
+ // clearBottomRows clears exactly whatever's currently reserved at the
850
+ // bottom right now (reservedBottomRows - the box, freshly measured, or the
851
+ // spinner) and nothing more - it does not know or care whether the screen
852
+ // has genuinely filled with real content yet. That distinction belongs
853
+ // entirely to drawBox, right below.
854
+ function clearBottomRows() {
855
+ if (!stylingEnabled()) return;
856
+ const total = reservedBottomRows();
857
+ for (let i = 0; i < total; i += 1) {
858
+ readline.clearLine(process.stdout, 0);
859
+ readline.cursorTo(process.stdout, 0);
860
+ if (i < total - 1) readline.moveCursor(process.stdout, 0, -1);
861
+ }
862
+ bottomRowsShown = 0;
863
+ boxShown = false;
864
+ }
865
+
866
+ // How many physical terminal rows readline's own current prompt line
867
+ // (promptString() plus whatever's typed so far) actually occupies once the
868
+ // terminal's own soft-wrap kicks in - normally 1, but more once a
869
+ // typed/pasted line is long enough to wrap on its own. Every piece of this
870
+ // file's box-pinning math (bottomRowsShown, clearBottomRows, drawBoxRaw,
871
+ // currentVisibleRows) used to assume this was always exactly 1 - confirmed
872
+ // live: pasting a line longer than one terminal row, then triggering a
873
+ // redraw (a capture arriving, or the reply to a normal typed message),
874
+ // cleared only the single row the old code assumed the prompt occupied,
875
+ // leaving the wrapped-over remainder of the old prompt line sitting as
876
+ // leftover garbage above the freshly redrawn box. Node's readline has no
877
+ // public API for this, so it's derived the same way readline derives it
878
+ // internally: total character count (the plain prompt marker - ANSI codes
879
+ // in it don't occupy a column, so they're stripped first - plus the raw
880
+ // typed text) divided by the terminal's current column count. An
881
+ // approximation, not a byte-exact match for every terminal's own wrapping
882
+ // quirks (e.g. deferred/"pending" wrap at an exact multiple of the column
883
+ // count), but Math.ceil errs on the side of clearing one row too many
884
+ // rather than too few, which self-corrects on the very next redraw rather
885
+ // than leaving stray content behind.
886
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
887
+ function promptRowCount() {
888
+ const cols = process.stdout.columns || 80;
889
+ const plainPromptLength = promptString().replace(ANSI_RE, '').length;
890
+ const totalLength = plainPromptLength + rl.line.length;
891
+ return Math.max(1, Math.ceil(totalLength / cols));
892
+ }
893
+
894
+ // Draws just the box itself (a blank breathing-room line, the rule, then the
895
+ // prompt - see promptRowCount above for why the prompt can be more than one
896
+ // physical row) at the cursor's current position, with no
897
+ // padding logic of its own - callers are responsible for the cursor already
898
+ // being at the correct row. Both of drawBox's two branches below end here:
899
+ // the fast incremental path (cursor left wherever clearBottomRows +
900
+ // newly-written content put it) and redrawViewport's full repaint (cursor at
901
+ // the end of its freshly-painted history slice). rl.prompt(true) alone
902
+ // already renders readline's own current line and cursor position correctly
903
+ // on its own (verified live: it reflects whatever was typed, including
904
+ // mid-line edits and a wrapped multi-row line, with no help needed) - no
905
+ // manual re-insertion of a "saved" line, which matters here specifically
906
+ // because a turn no longer pauses readline for its duration (see
907
+ // sendAndPrint): a keystroke can land between two box redraws, and manually
908
+ // re-inserting a previously-saved line back into a buffer readline's own
909
+ // keypress handling has since updated would duplicate it. A no-op in
910
+ // non-styled mode: the plain '> ' prompt there is just readline's own,
911
+ // undecorated, and never needs a rule or an explicit redraw of its own.
912
+ function drawBoxRaw() {
913
+ if (!stylingEnabled()) return;
914
+ process.stdout.write(`\n${boxRule()}\n`);
915
+ // Reserve the blank breathing-room row below the prompt (symmetric with
916
+ // the blank line above the rule, just written) *before* rendering the
917
+ // prompt itself, while the cursor sits at a known, unambiguous position:
918
+ // column 0 of the prompt's own first row. Writing promptRowCount() plain
919
+ // newlines forces the terminal to scroll if it's already at its bottom
920
+ // margin - the only way to genuinely create a new row there; a relative
921
+ // cursor-down move (readline.moveCursor with a positive dy, i.e. CSI
922
+ // "...B") does *not* scroll, it just clamps uselessly at the last row,
923
+ // which would silently skip the reservation exactly when it's needed
924
+ // most (the steady-state, screen-already-full case this function mostly
925
+ // runs in). clearLine then guards against stale content left over from a
926
+ // taller previous draw landing at this same spot (e.g. right after a
927
+ // resize). moveCursor back up the same count is a pure relative vertical
928
+ // move (unlike another plain '\n', which most terminals' own output
929
+ // processing also treats as an implicit carriage return) - paired with
930
+ // the explicit cursorTo(0) after it, this returns the cursor to exactly
931
+ // where it started, so rl.prompt(true) below renders into unchanged,
932
+ // correctly-positioned space, unaware any of this happened. Doing this
933
+ // *before* rl.prompt(true) - rather than after, relative to wherever it
934
+ // leaves the cursor - sidesteps having to know precisely where within a
935
+ // multi-row wrapped prompt the cursor ends up (e.g. mid-line editing
936
+ // after arrowing left): that position is genuinely ambiguous from here,
937
+ // while "column 0 of the prompt's first row" is not.
938
+ const promptRows = promptRowCount();
939
+ process.stdout.write('\n'.repeat(promptRows));
940
+ readline.clearLine(process.stdout, 0);
941
+ readline.moveCursor(process.stdout, 0, -promptRows);
942
+ readline.cursorTo(process.stdout, 0);
943
+ // Reset readline's own internal "how many rows did my last render take"
944
+ // bookkeeping (rl.prevRows - an undocumented but stable, directly-
945
+ // readable property; see node's lib/internal/readline/interface.js
946
+ // _refreshLine) before letting it redraw. rl.prompt(true) doesn't just
947
+ // paint at the cursor's current position - internally it moves the
948
+ // cursor *up* by rl.prevRows first (to reach what it believes was the
949
+ // start of its own previous multi-row render), then clearScreenDown()s
950
+ // from there before writing the new prompt. That's fine as long as
951
+ // readline is the only thing that's touched the screen since its last
952
+ // render - but it isn't: clearBottomRows just repositioned the cursor
953
+ // using this file's own row math, to the correct spot for a *fresh*
954
+ // render. If rl.prevRows is left stale from readline's last render of
955
+ // this same long/wrapped line (e.g. 2, for a cursor sitting on the third
956
+ // row of a 3-row wrapped prompt), rl.prompt(true) moves up 2 *more* rows
957
+ // than it should - straight into content clearBottomRows never touched
958
+ // (the ack/label lines this turn already printed above the box) - and
959
+ // then clearScreenDown() erases all of it before redrawing the box 2 rows
960
+ // too high. Confirmed live: this is the actual mechanism behind "a
961
+ // capture arriving while a long typed line sits in the prompt corrupts
962
+ // everything above the box," not a row-*counting* bug (promptRowCount
963
+ // above was already correct throughout) - forcing rl.prevRows to 0 here
964
+ // makes _refreshLine's own upward move a no-op, so it just clears
965
+ // (harmlessly - the box's own rows are already blank) from the cursor's
966
+ // actual current position downward and draws fresh there, matching what
967
+ // clearBottomRows already prepared.
968
+ rl.prevRows = 0;
969
+ rl.prompt(true);
970
+ boxShown = true;
971
+ }
972
+
973
+ // The box is pinned to the terminal's actual last row(s) - not wherever the
974
+ // cursor happens to be after whatever's been printed so far. Two phases:
975
+ //
976
+ // While the screen hasn't genuinely filled with real content yet
977
+ // (screenFilledToBox false), every redraw goes through a full repaint
978
+ // (redrawViewport) instead of the incremental clear/write path below. This
979
+ // is the real fix for "chat ends up clustered at the bottom with the box
980
+ // instead of starting from the top and growing down, with padding sitting
981
+ // between it and the box": the incremental path only ever cleared the box's
982
+ // own rows (clearBottomRows) and then wrote new content immediately adjacent
983
+ // to wherever the box already was - which, since the box is pinned to the
984
+ // terminal's actual last row, always means writing right at that last row.
985
+ // Doing that forces the *terminal itself* to scroll on every single
986
+ // redraw (there's nowhere left to print a trailing newline without
987
+ // overflowing the last row) - which silently drops exactly one row off the
988
+ // *top* of the screen each time, even while genuine blank padding still sits
989
+ // untouched in the middle, never being consumed by it. The old
990
+ // padToBottomIfNeeded compounded this by only ever padding once (an
991
+ // unconditional `screenFilledToBox = true` after its very first call,
992
+ // regardless of whether the screen had actually filled) - confirmed live:
993
+ // a short conversation in a tall window showed new content clustered
994
+ // directly above the box while the padding above stayed frozen at whatever
995
+ // it was after the very first startup draw, and the window's original
996
+ // startup banner silently scrolled off the top within the first couple of
997
+ // capture exchanges. redrawViewport, in contrast, was already correct - it
998
+ // repaints the whole visible window fresh from historyBuffer (the true,
999
+ // never-lost record of everything printed) on every call, which is exactly
1000
+ // why Thomson's own live testing found that a single wheel-scroll tick
1001
+ // "snapped" a broken-looking screen to the correct layout: it was the first
1002
+ // full repaint since the bug's incremental drawBox calls had been silently
1003
+ // scrolling real content away. Using that same full-repaint path for every
1004
+ // redraw during the fill phase costs a full-screen clear per redraw, but
1005
+ // that phase is inherently bounded (at most visibleRows lines of content),
1006
+ // unlike the conversation itself - once the screen has genuinely filled
1007
+ // (contentRows >= visibleRows), natural terminal scrolling is exactly the
1008
+ // desired behavior forever after, so the cheap incremental path (clear only
1009
+ // the box's own rows, write, redraw in place) takes back over, unchanged
1010
+ // from before this fix and already verified correct for that case.
1011
+ function drawBox() {
1012
+ if (!stylingEnabled()) return;
1013
+ if (!screenFilledToBox) {
1014
+ redrawViewport();
1015
+ return;
1016
+ }
1017
+ drawBoxRaw();
1018
+ }
1019
+
1020
+ function printAboveInput(text) {
1021
+ const body = text.endsWith('\n') ? text : `${text}\n`;
1022
+ if (!process.stdout.isTTY) {
1023
+ process.stdout.write(body);
1024
+ return;
1025
+ }
1026
+ recordHistory(body);
1027
+ clearBottomRows();
1028
+ process.stdout.write(body);
1029
+ drawBox();
1030
+ }
1031
+
1032
+ // Marks the start of a new turn (a capture's whole exchange, or a typed
1033
+ // message's reply) so exactly one blank line separates it from whatever
1034
+ // came before - never more, never none. Set at the end of every turn;
1035
+ // consumed (and cleared) by whichever print happens first for the next one,
1036
+ // wherever that is - a capture's local ack, or a typed reply's own first
1037
+ // output - so it's correct regardless of which of those runs first, without
1038
+ // each needing to know about the other. Styled/TTY only, like every other
1039
+ // decoration in this file - piped output stays exactly the raw text stream
1040
+ // it's always been, with no separators inserted.
1041
+ let turnBoundaryPending = false;
1042
+ function consumeTurnBoundary() {
1043
+ if (!stylingEnabled() || !turnBoundaryPending) return '';
1044
+ turnBoundaryPending = false;
1045
+ return '\n';
1046
+ }
1047
+
1048
+ // Serializes every backend call (typed messages and injected captures alike)
1049
+ // through one queue, so a capture landing mid-response to a typed message -
1050
+ // or vice versa - never sends two overlapping requests to a backend that
1051
+ // keeps its own session/history state.
1052
+ let queueTail = Promise.resolve();
1053
+ function enqueue(task) {
1054
+ const result = queueTail.then(() => task());
1055
+ queueTail = result.then(
1056
+ () => undefined,
1057
+ () => undefined
1058
+ );
1059
+ return result;
1060
+ }
1061
+
1062
+ // The "waiting for the backend" indicator: temporarily replaces the box
1063
+ // with a single animated line until the first content is ready, redrawn in
1064
+ // place on that same line each tick. A plain no-op whenever styling is off,
1065
+ // so a piped/non-TTY invocation is never touched by it - see
1066
+ // terminal-format.js's stylingEnabled.
1067
+ function startSpinner() {
1068
+ if (!stylingEnabled()) return () => {};
1069
+ clearBottomRows();
1070
+ let i = 0;
1071
+ let stopped = false;
1072
+ process.stdout.write(spinnerFrame(i));
1073
+ bottomRowsShown = 1;
1074
+ const timer = setInterval(() => {
1075
+ i += 1;
1076
+ readline.cursorTo(process.stdout, 0);
1077
+ readline.clearLine(process.stdout, 0);
1078
+ process.stdout.write(spinnerFrame(i));
1079
+ }, 120);
1080
+ return function stopSpinner() {
1081
+ if (stopped) return;
1082
+ stopped = true;
1083
+ clearInterval(timer);
1084
+ // Leaves an empty, still-reserved row - clearBottomRows (bottomRowsShown
1085
+ // stays 1) picks it up cleanly on whatever prints next, same as if a
1086
+ // one-line box were there.
1087
+ readline.cursorTo(process.stdout, 0);
1088
+ readline.clearLine(process.stdout, 0);
1089
+ };
1090
+ }
1091
+
1092
+ // Runs one queued task as a turn: the view snaps back to the live bottom
1093
+ // first (new activity, even if the user was scrolled up reading history),
1094
+ // and turnActive is set while it runs. Every capture, panel message and typed
1095
+ // message goes through here, so the ledger's reads and writes, context
1096
+ // resets and backend calls all happen in arrival order, one at a time.
1097
+ function queueTurn(task) {
1098
+ resetScrollIfNeeded();
1099
+ return enqueue(async () => {
1100
+ turnActive = true;
1101
+ try {
1102
+ return await task();
1103
+ } finally {
1104
+ turnActive = false;
1105
+ }
1106
+ });
1107
+ }
1108
+
1109
+ // Sends one message and prints (and mirrors to the panel) the reply.
1110
+ // Resolves true once the backend answered with a real reply, false on an
1111
+ // error or an empty reply, so the caller only records the code as held by
1112
+ // the tutor when it really got it.
1113
+ //
1114
+ // Every turn goes through the post-check guard (extension/shared/
1115
+ // reply-guard.js, the same one the extension runtime uses): `guardText` is
1116
+ // the student's own words (null for a Submit) and `trigger` 'submit' or
1117
+ // null. What streams to the terminal and the panel is only what the guard
1118
+ // releases, and the turn-end text is exactly that.
1119
+ //
1120
+ // onReply, when given, runs on the raw reply before it's printed and may
1121
+ // return a transformed string - used by handleCaptureLine to peel the vault
1122
+ // auto-summary's JSON block back off a Submit reply (see extractVaultBlock)
1123
+ // before it ever reaches the terminal. That stripping can only happen once
1124
+ // the reply is complete, so a turn with onReply set never streams - see the
1125
+ // non-streaming branch below.
1126
+ async function sendAndPrintTurn(label, text, options = {}) {
1127
+ try {
1128
+ return await sendAndPrintTurnBody(label, text, options);
1129
+ } finally {
1130
+ // A backend's own warnings from this turn (LocalBackend's silent-
1131
+ // truncation guard), printed below the reply or error they are about.
1132
+ for (const warning of backend.takeWarnings?.() || []) {
1133
+ printAboveInput(dim(`companion: warning: ${warning}`));
1134
+ }
1135
+ }
1136
+ }
1137
+
1138
+ async function sendAndPrintTurnBody(label, text, { onReply, problemContext, currentCode, compactText, panelMeta, guardText = null, trigger = null } = {}) {
1139
+ const guardTurn = replyGuard.begin({ text: guardText, trigger, code: currentCode && typeof currentCode.code === 'string' ? currentCode.code : null });
1140
+ const guardLog = (message) => printAboveInput(dim(`companion: ${message}`));
1141
+ const cutOffNote = 'The reply was cut off because the model ran out of room.';
1142
+ // Mirrors this turn to the in-page panel (see "in-page panel bridge"
1143
+ // above): a start marker, each streamed chunk, then one end event carrying
1144
+ // the authoritative full text (or the error), so a panel that connects
1145
+ // mid-turn or reloads still ends up with the right transcript.
1146
+ const panelTurnId = newPanelTurnId();
1147
+ const panelSlug = (panelMeta && panelMeta.problemSlug) || panelProblemSlug;
1148
+ // No problem, no panel transcript to put this turn in (see panelPublish).
1149
+ const mirrorChunks = Boolean(panelSlug);
1150
+ panelPublish({ type: 'turn-start', turnId: panelTurnId, problemSlug: panelSlug });
1151
+ const panelEnd = (fields) => panelPublish({ type: 'turn-end', turnId: panelTurnId, problemSlug: panelSlug, ...fields });
1152
+ const separator = consumeTurnBoundary();
1153
+ if (label) printAboveInput(separator + label);
1154
+ else if (separator) printAboveInput(''); // separator alone - printAboveInput('') is exactly one blank line
1155
+
1156
+ const streaming = !onReply;
1157
+ const stopSpinner = startSpinner();
1158
+
1159
+ if (streaming) {
1160
+ let wroteMarker = false;
1161
+ let anyChunk = false;
1162
+ const streamer = createMarkdownStreamer();
1163
+
1164
+ // Writes one flushed piece of the reply above the box, then redraws
1165
+ // the box fresh below it - each call is its own printAboveInput-style
1166
+ // clear/print/redraw cycle, throttled by the streamer to real
1167
+ // markdown block boundaries (not raw network chunks), so the box
1168
+ // stays visibly pinned through a whole reply without flickering on
1169
+ // every tiny piece. The turn marker (•) opens the very first piece;
1170
+ // consecutive blocks are separated by a blank line, since each is
1171
+ // rendered independently and trimmed at its own boundary - without
1172
+ // this the paragraph spacing between them would be lost. In
1173
+ // non-styled mode a piece is raw, possibly mid-word text - no marker,
1174
+ // no separator, straight pass-through.
1175
+ function writeReplyPiece(piece) {
1176
+ if (!piece) return;
1177
+ if (!stylingEnabled()) {
1178
+ // Raw, possibly mid-word pass-through - no marker, no separator,
1179
+ // and critically no newline added: a chunk boundary can fall
1180
+ // anywhere, and injecting one here would corrupt piped output.
1181
+ process.stdout.write(piece);
1182
+ return;
1183
+ }
1184
+ stopSpinner();
1185
+ clearBottomRows();
1186
+ // Each piece always ends with its own trailing newline (added
1187
+ // below, so the box's rule has a fresh line to start on) - that
1188
+ // newline already accounts for the first half of a blank-line
1189
+ // separator, so only one more newline (not two) is needed here to
1190
+ // leave exactly one blank row before a second-or-later piece.
1191
+ const separatorOrMarker = wroteMarker ? '\n' : turnMarker();
1192
+ // The very first piece's own first line sits right after turnMarker()
1193
+ // itself, so only that one line stays unindented; every later piece
1194
+ // starts on a fresh line of its own (following the blank-line
1195
+ // separator above) and needs the same hanging indent as any other
1196
+ // continuation line - see indentContinuation.
1197
+ const indentedPiece = indentContinuation(piece, { indentFirstLine: wroteMarker });
1198
+ const fullPiece = indentedPiece.endsWith('\n') ? indentedPiece : `${indentedPiece}\n`;
1199
+ recordHistory(separatorOrMarker + fullPiece);
1200
+ process.stdout.write(separatorOrMarker);
1201
+ wroteMarker = true;
1202
+ process.stdout.write(fullPiece);
1203
+ drawBox();
1204
+ }
1205
+
1206
+ let reply;
1207
+ try {
1208
+ reply = await guardedSend({
1209
+ backend,
1210
+ text,
1211
+ options: { problemContext, currentCode, compactText },
1212
+ turn: guardTurn,
1213
+ onText: (chunk) => {
1214
+ anyChunk = true;
1215
+ if (mirrorChunks) panelPublish({ type: 'chunk', turnId: panelTurnId, text: chunk });
1216
+ writeReplyPiece(streamer.push(chunk));
1217
+ },
1218
+ log: guardLog,
1219
+ });
1220
+ } catch (err) {
1221
+ if (err.cutOff) {
1222
+ if (!anyChunk && err.partialReply) writeReplyPiece(streamer.push(err.partialReply));
1223
+ writeReplyPiece(streamer.finish());
1224
+ }
1225
+ stopSpinner();
1226
+ clearBottomRows();
1227
+ const errorLine = `${err.cutOff && !stylingEnabled() && anyChunk ? '\n' : ''}` +
1228
+ `${dim(`companion: error talking to backend (${backendLabel()}): ${err.message}`)}\n` +
1229
+ (err.cutOff ? `${dim(cutOffNote)}\n` : '');
1230
+ recordHistory(errorLine);
1231
+ process.stdout.write(errorLine);
1232
+ drawBox();
1233
+ turnBoundaryPending = true;
1234
+ panelEnd(err.cutOff
1235
+ ? { text: err.partialReply || undefined, error: cutOffNote + ' Open Settings and check Context window (tokens).' }
1236
+ : { error: `Error talking to the backend (${backendLabel()}): ${err.message}` });
1237
+ return false;
1238
+ }
1239
+
1240
+ // Defense in depth: even if the backend resolves with an
1241
+ // empty/whitespace-only reply without throwing, never let that render
1242
+ // as total silence - indistinguishable from "the reply never arrived"
1243
+ // (see LocalBackend.sendMessage's own empty-reply guard).
1244
+ const isEmpty = !(typeof reply === 'string' && reply.trim());
1245
+ // A backend/model that ignored the streaming request entirely (no
1246
+ // onChunk call ever fired) still has a real reply to show - feed it
1247
+ // to the streamer as one final push, exactly like a real chunk would
1248
+ // be, so it renders instead of being lost.
1249
+ if (!anyChunk && !isEmpty) writeReplyPiece(streamer.push(reply));
1250
+ writeReplyPiece(streamer.finish());
1251
+ if (isEmpty) {
1252
+ stopSpinner();
1253
+ clearBottomRows();
1254
+ const warningLine = `${dim('companion: warning: backend returned an empty reply')}\n`;
1255
+ recordHistory(warningLine);
1256
+ process.stdout.write(warningLine);
1257
+ drawBox();
1258
+ panelEnd({ error: 'The backend returned an empty reply.' });
1259
+ } else {
1260
+ panelEnd({ text: reply });
1261
+ }
1262
+ turnBoundaryPending = true;
1263
+ return !isEmpty;
1264
+ }
1265
+
1266
+ // Non-streaming path (vault auto-summary's Submit turns): the whole
1267
+ // transformed reply prints as one block once it's fully ready, same
1268
+ // shape as before streaming existed.
1269
+ // The guard sees the reply with the vault block already peeled off (by
1270
+ // onReply, called exactly once); it does not stream here, and a Submit
1271
+ // review is never re-asked, so a reply with nothing left shows the
1272
+ // fallback.
1273
+ let reply;
1274
+ let displayText;
1275
+ try {
1276
+ displayText = await guardedSend({
1277
+ backend: {
1278
+ // Not streamed: only the turn's own options, no onChunk.
1279
+ sendMessage: async (message) => {
1280
+ reply = await backend.sendMessage(message, { problemContext, currentCode, compactText });
1281
+ return onReply(reply);
1282
+ },
1283
+ replaceLastReply: (shown) => backend.replaceLastReply?.(shown),
1284
+ },
1285
+ text,
1286
+ options: { problemContext, currentCode, compactText },
1287
+ turn: guardTurn,
1288
+ log: guardLog,
1289
+ });
1290
+ } catch (err) {
1291
+ stopSpinner();
1292
+ clearBottomRows();
1293
+ const partial = err.cutOff ? (err.partialReply || '').split('<<<VAULT_JSON>>>')[0].trim() : '';
1294
+ if (partial) {
1295
+ const body = indentContinuation(renderMarkdown(partial));
1296
+ const shown = `${stylingEnabled() ? turnMarker() : ''}${body.endsWith('\n') ? body : `${body}\n`}`;
1297
+ recordHistory(shown);
1298
+ process.stdout.write(shown);
1299
+ }
1300
+ const errorLine = `${dim(`companion: error talking to backend (${backendLabel()}): ${err.message}`)}\n` +
1301
+ (err.cutOff ? `${dim(cutOffNote)}\n` : '');
1302
+ recordHistory(errorLine);
1303
+ process.stdout.write(errorLine);
1304
+ drawBox();
1305
+ turnBoundaryPending = true;
1306
+ panelEnd(err.cutOff
1307
+ ? { text: partial || undefined, error: cutOffNote + ' Open Settings and check Context window (tokens).' }
1308
+ : { error: `Error talking to the backend (${backendLabel()}): ${err.message}` });
1309
+ return false;
1310
+ }
1311
+ stopSpinner();
1312
+ clearBottomRows();
1313
+ const isEmpty = !(typeof displayText === 'string' && displayText.trim());
1314
+ const marker = stylingEnabled() && !isEmpty ? turnMarker() : '';
1315
+ // Same hanging-indent treatment as the streaming path's first piece
1316
+ // (writeReplyPiece) - the whole reply prints as one block here, so its
1317
+ // own first line sits right after the marker and only that one line
1318
+ // stays unindented; see indentContinuation.
1319
+ const body = isEmpty
1320
+ ? dim('companion: warning: backend returned an empty reply')
1321
+ : indentContinuation(renderMarkdown(displayText));
1322
+ const fullBody = `${marker}${body.endsWith('\n') ? body : `${body}\n`}`;
1323
+ recordHistory(fullBody);
1324
+ process.stdout.write(fullBody);
1325
+ drawBox();
1326
+ turnBoundaryPending = true;
1327
+ if (isEmpty) panelEnd({ error: 'The backend returned an empty reply.' });
1328
+ else panelEnd({ text: displayText });
1329
+ // The tutor got the code even if the vault block was all it wrote back.
1330
+ return typeof reply === 'string' && reply.trim() !== '';
1331
+ }
1332
+
1333
+ rl.on('line', (line) => {
1334
+ const text = line.trim();
1335
+ if (!text) {
1336
+ // Ignore an empty submission entirely. Pressing Enter is readline's
1337
+ // own native handling, not ours - it already committed the (empty)
1338
+ // prompt row as permanent scrollback and moved the cursor to a fresh
1339
+ // row before this listener ever ran. A naive drawBox() here would draw
1340
+ // a whole new rule+prompt under that leftover row instead of reusing
1341
+ // it - meaning every empty Enter would permanently grow the screen by
1342
+ // two lines, and the rule (meant to be the single, unique break
1343
+ // between chat history and the live input) would duplicate itself
1344
+ // once per press. Reclaim that row instead (move up, clear) and redraw
1345
+ // only the prompt, still empty - the rule and blank-breathing-room
1346
+ // rows above it were never touched by readline's own handling and are
1347
+ // already correct, so the box ends up looking exactly as it did before
1348
+ // Enter was pressed, not just similar to it.
1349
+ //
1350
+ // While the screen hasn't genuinely filled yet, though, that reclaim
1351
+ // isn't enough on its own: readline's own commit-and-move-to-a-fresh-row
1352
+ // handling for Enter still emits a raw newline at whatever the terminal's
1353
+ // current last row is (the prompt's own row, since the box is always
1354
+ // pinned there) - the exact same last-row-overflow mechanism drawBox's
1355
+ // own fill-phase branch above exists to work around, so it forces a real
1356
+ // terminal scroll here too, silently dropping a row off the top, even
1357
+ // though an empty submission adds nothing to historyBuffer to justify
1358
+ // it. A full repaint recovers correctly, the same as drawBox's own
1359
+ // fill-phase branch - there's no typed content to preserve either way,
1360
+ // since the submission was empty.
1361
+ if (stylingEnabled()) {
1362
+ if (!screenFilledToBox) {
1363
+ redrawViewport();
1364
+ } else {
1365
+ readline.moveCursor(process.stdout, 0, -1);
1366
+ readline.clearLine(process.stdout, 0);
1367
+ readline.cursorTo(process.stdout, 0);
1368
+ rl.prompt(true);
1369
+ }
1370
+ } else {
1371
+ rl.prompt();
1372
+ }
1373
+ return;
1374
+ }
1375
+ // A non-empty submission, in contrast, must keep the row(s) readline just
1376
+ // committed - that's Thomson's own message, and it's meant to become part
1377
+ // of permanent chat history. Whatever the box was (boxShown/
1378
+ // reservedBottomRows) is now stale: readline's own native Enter handling
1379
+ // already committed and cleared all of it on its own. Reset both to
1380
+ // "nothing reserved" before the reply's own printAboveInput/box calls run.
1381
+ bottomRowsShown = 0;
1382
+ boxShown = false;
1383
+ if (text === '/exit' || text === '/quit') {
1384
+ rl.close();
1385
+ return;
1386
+ }
1387
+ if (text === '/reset') {
1388
+ queueTurn(async () => resetTutorContext()).catch(() => {});
1389
+ return;
1390
+ }
1391
+ // Readline's own echo already put this line on screen (that's what was
1392
+ // just committed to permanent scrollback, above) - this is purely for
1393
+ // history/showHistory's benefit, since readline's echo never goes
1394
+ // through printAboveInput or anything else that records it.
1395
+ if (process.stdout.isTTY) recordHistory(`${promptString()}${text}\n`);
1396
+ panelPublish({ type: 'user', text, problemSlug: panelProblemSlug });
1397
+ // The terminal cannot read the live editor, so the code reference is the
1398
+ // newest Run or Submit recorded for the active problem.
1399
+ const releaseBusy = holdBusy();
1400
+ queueTurn(() => sendChatTurn({ label: null, text, problemId: ledger.activeProblemId })).finally(releaseBusy);
1401
+ });
1402
+
1403
+ // A typed message (terminal or panel), inside the turn queue: prefixed with
1404
+ // the code update when the code changed since the tutor last saw it (see
1405
+ // code-ledger.js), and the code counted as held once the reply came back.
1406
+ // codeUnavailable: a panel message whose editor code could not be read. It
1407
+ // goes with no code update at all (the newest Run or Submit does not stand
1408
+ // in for the live code), and the tutor is told why.
1409
+ async function sendChatTurn({ label, text, problemId, preamble = null, codeUnavailable = false }) {
1410
+ const turn = prepareChatTurn(ledger, { text, problemId, preamble, codeUnavailable });
1411
+ const ok = await sendAndPrintTurn(label, turn.message.text, {
1412
+ guardText: text,
1413
+ problemContext: turn.problemContext,
1414
+ currentCode: turn.currentCode,
1415
+ compactText: turn.message.compactText,
1416
+ panelMeta: { problemSlug: panelProblemSlug },
1417
+ });
1418
+ if (ok && (turn.plan.kind === 'full' || turn.plan.kind === 'diff')) ledger.commit(problemId, turn.plan);
1419
+ }
1420
+
1421
+ rl.on('close', () => {
1422
+ stopping = true;
1423
+ // No "goodbye" line here - the alternate-screen restore (see the top of
1424
+ // this file) fires immediately after process.exit() below and erases
1425
+ // whatever's on screen anyway, so printing one would only ever flash
1426
+ // briefly, if it rendered at all, working against "exiting restores
1427
+ // exactly what was there before" rather than serving any purpose.
1428
+ process.exit(0);
1429
+ });
1430
+
1431
+ // --- capture tailing (offset tracking adapted from the old watch.js) -------
1432
+ //
1433
+ // The log is a short-lived delivery queue from the relay, and it is cleared
1434
+ // as it is consumed (see capture-log.js): the handled prefix is dropped at
1435
+ // start, after a problem switch, and once it passes CAPTURE_LOG_TRIM_BYTES.
1436
+ // Always between polls (pollLoop), so `offset` then marks exactly the lines
1437
+ // already handled, and never while a poll is reading the file.
1438
+
1439
+ // The log's bytes at [position, position + length), fewer at its end.
1440
+ function readCaptureBytes(position, length) {
1441
+ let fd;
1442
+ try {
1443
+ fd = fs.openSync(CAPTURES_PATH, 'r');
1444
+ } catch {
1445
+ return Buffer.alloc(0);
1446
+ }
1447
+ try {
1448
+ const buf = Buffer.alloc(length);
1449
+ const read = fs.readSync(fd, buf, 0, length, Math.max(0, position));
1450
+ return buf.subarray(0, read);
1451
+ } finally {
1452
+ fs.closeSync(fd);
1453
+ }
1454
+ }
1455
+
1456
+ function loadOffset() {
1457
+ try {
1458
+ const raw = fs.readFileSync(STATE_PATH, 'utf8');
1459
+ const parsed = JSON.parse(raw);
1460
+ if (typeof parsed.offset === 'number' && parsed.offset >= 0) {
1461
+ // A trim this companion asked for before it last stopped, with no
1462
+ // record of how it ended: the bytes before the cut tell.
1463
+ if (isPendingTrim(parsed.pendingTrim)) {
1464
+ const happened = pendingTrimHappened(parsed.pendingTrim, readCaptureBytes);
1465
+ const resolved = happened ? Math.max(0, parsed.offset - parsed.pendingTrim.before) : parsed.offset;
1466
+ saveOffset(resolved);
1467
+ return resolved;
1468
+ }
1469
+ return parsed.offset;
1470
+ }
1471
+ } catch (err) {
1472
+ if (err.code !== 'ENOENT') {
1473
+ console.error(dim(`companion: warning: could not read state file (${err.message}); starting fresh`));
1474
+ }
1475
+ }
1476
+ // No usable state: this is effectively a first run. Start at the current
1477
+ // end of the log (if it exists) so we tail forward, not replay history.
1478
+ try {
1479
+ return fs.statSync(CAPTURES_PATH).size;
1480
+ } catch {
1481
+ return 0;
1482
+ }
1483
+ }
1484
+
1485
+ function saveOffset(offset, pendingTrim) {
1486
+ const tmp = `${STATE_PATH}.tmp`;
1487
+ fs.writeFileSync(tmp, JSON.stringify(pendingTrim ? { offset, pendingTrim } : { offset }), 'utf8');
1488
+ fs.renameSync(tmp, STATE_PATH);
1489
+ }
1490
+
1491
+ let offset = loadOffset();
1492
+ let captureTrimWanted = false;
1493
+ // Set once the relay turns out not to support trimming (or refuses), so the
1494
+ // warning is printed once and the request not repeated every poll.
1495
+ let captureTrimDisabled = false;
1496
+
1497
+ // Asks the relay to drop the handled prefix of the log (everything before
1498
+ // `offset`), and moves `offset` back by what it removed once it confirms.
1499
+ // Only called from pollLoop, between polls.
1500
+ async function trimHandledCaptures() {
1501
+ captureTrimWanted = false;
1502
+ if (captureTrimDisabled || offset === 0) return;
1503
+ const before = offset;
1504
+ const pending = pendingTrimFor(before, readCaptureBytes);
1505
+ saveOffset(offset, pending);
1506
+ const result = await requestCaptureTrim({
1507
+ baseUrl: `http://${RELAY_HOST}:${RELAY_PORT}`,
1508
+ before,
1509
+ logPath: path.resolve(CAPTURES_PATH),
1510
+ });
1511
+ if (result.outcome === 'trimmed') {
1512
+ offset -= result.removed;
1513
+ } else if (result.outcome === 'unknown') {
1514
+ // No answer (a timeout, the relay restarting): whether it cut the file
1515
+ // is read off the file itself, the same way a restart would.
1516
+ if (pendingTrimHappened(pending, readCaptureBytes)) offset = Math.max(0, offset - before);
1517
+ } else {
1518
+ captureTrimDisabled = true;
1519
+ printAboveInput(
1520
+ dim(
1521
+ result.outcome === 'unsupported'
1522
+ ? 'companion: warning: the running relay server cannot clear the capture log; restart it to enable that'
1523
+ : `companion: warning: the relay would not clear the capture log (${oneLine(result.error)}); is CAPTURE_LOG_PATH the same file as LEETCODE_CAPTURES_FILE?`
1524
+ )
1525
+ );
1526
+ }
1527
+ saveOffset(offset);
1528
+ }
1529
+
1530
+ async function handleCaptureLine(line) {
1531
+ // A capture is new activity, not something the user asked for - if
1532
+ // they're mid-scroll reading back through history (see the scrollback
1533
+ // viewport section below), snap back to the live view first so every
1534
+ // write this function and the turn it triggers make below lands where
1535
+ // the existing rendering code has always assumed it would: at the live
1536
+ // bottom. Keeps that rendering code (printAboveInput, the streaming
1537
+ // writer, the spinner) entirely unaware scrolling exists at all.
1538
+ resetScrollIfNeeded();
1539
+ const trimmed = line.trim();
1540
+ if (!trimmed) return;
1541
+ let capture;
1542
+ try {
1543
+ // Every capture field that gets printed, published to the panel or put
1544
+ // in the model's prompt label is attacker-controlled: /capture is CORS
1545
+ // `*`, so any website can post one. Sanitized once, here (see
1546
+ // sanitize.js).
1547
+ capture = sanitizeCapture(JSON.parse(trimmed));
1548
+ } catch (err) {
1549
+ // V8's JSON.parse message quotes part of the input, escapes included.
1550
+ printAboveInput(dim(`companion: warning: skipping malformed capture JSON: ${oneLine(err.message)}`));
1551
+ return;
1552
+ }
1553
+ const label = dim(
1554
+ `[capture] ${triggerLabel(capture.trigger)} - ${capture.problemTitle || capture.problemSlug || 'unknown'} (attempt ${capture.attemptSeq ?? '?'})`
1555
+ );
1556
+ const problemId = captureProblemId(capture);
1557
+ const sends = sendsToTutor(capture.trigger);
1558
+ if (capture.problemSlug) panelProblemSlug = capture.problemSlug;
1559
+ const shownTitle = capture.problemTitle || capture.problemSlug || 'this problem';
1560
+
1561
+ // Everything else happens in the turn queue, in arrival order: the reset
1562
+ // (if this capture is for another problem) first, then recording the code,
1563
+ // then (a changed Submit only) the backend call.
1564
+ await queueTurn(async () => {
1565
+ if (problemId) {
1566
+ activateProblem(problemId, capture.problemSlug || null);
1567
+ ledger.record(problemId, {
1568
+ code: typeof capture.code === 'string' ? capture.code : undefined,
1569
+ language: capture.language || null,
1570
+ slug: capture.problemSlug || null,
1571
+ context: captureProblemContext(capture),
1572
+ });
1573
+ }
1574
+
1575
+ // A Run (or anything else that is not a Submit) is only recorded: the
1576
+ // tutor sees what changed with the student's next message.
1577
+ if (!sends) {
1578
+ printAboveInput(
1579
+ consumeTurnBoundary() +
1580
+ dim(
1581
+ `companion: recorded your ${triggerLabel(capture.trigger)} for ${shownTitle} (attempt ${capture.attemptSeq ?? '?'}) - not sent to the tutor; it sees the changes with your next message`
1582
+ )
1583
+ );
1584
+ turnBoundaryPending = true;
1585
+ return;
1586
+ }
1587
+
1588
+ const plan = problemId ? ledger.planUpdate(problemId) : { kind: 'none' };
1589
+ if (plan.kind === 'unchanged' || plan.kind === 'none') {
1590
+ const why = plan.kind === 'unchanged' ? 'is unchanged since the tutor last saw it' : 'carried no code';
1591
+ printAboveInput(
1592
+ consumeTurnBoundary() +
1593
+ dim(`companion: your Submit for ${shownTitle} (attempt ${capture.attemptSeq ?? '?'}) ${why} - not sent`)
1594
+ );
1595
+ turnBoundaryPending = true;
1596
+ return;
1597
+ }
1598
+
1599
+ // Only a capture the tutor is about to see shows its chip in the panel:
1600
+ // a Run or an unchanged Submit shows nothing there.
1601
+ panelPublish({
1602
+ type: 'capture',
1603
+ problemSlug: capture.problemSlug || null,
1604
+ problemTitle: capture.problemTitle || null,
1605
+ trigger: capture.trigger,
1606
+ attemptSeq: Number.isInteger(capture.attemptSeq) ? capture.attemptSeq : null,
1607
+ });
1608
+
1609
+ // From here on the tutor is working on this Submit (the panel's
1610
+ // "Processing" light, see holdBusy).
1611
+ const releaseBusy = holdBusy();
1612
+ try {
1613
+ // An instant, local, non-LLM acknowledgement before the backend call
1614
+ // starts: the tutor's own acknowledgement is baked into its reply and can
1615
+ // lag several seconds behind.
1616
+ printAboveInput(consumeTurnBoundary() + dim(`companion: got your Submit for ${shownTitle} - taking a look...`));
1617
+
1618
+ // Only Submits drive the vault auto-summary, and only when the toggle is
1619
+ // on - see the "vault auto-summary" section above. One extra instruction
1620
+ // rides on the same message; no second LLM call.
1621
+ let vaultContext = null;
1622
+ if (effective.options.vaultAutoSummary) {
1623
+ try {
1624
+ vaultContext = prepareVaultContext({
1625
+ capture,
1626
+ vaultPath: VAULT_PATH,
1627
+ algorithmsSubfolder: VAULT_ALGORITHMS_SUBFOLDER,
1628
+ });
1629
+ } catch (err) {
1630
+ printAboveInput(dim(`companion: warning: could not prepare vault auto-summary context (${err.message})`));
1631
+ }
1632
+ }
1633
+ const turn = prepareSubmitTurn(ledger, {
1634
+ problemId,
1635
+ plan,
1636
+ headerLine: captureHeaderLine(capture),
1637
+ addendum: SUBMIT_ADDENDUM + (vaultContext ? vaultContext.addendum : ''),
1638
+ fallbackContext: captureProblemContext(capture),
1639
+ });
1640
+ const ok = await sendAndPrintTurn(label, turn.message.text, {
1641
+ onReply: vaultContext
1642
+ ? (reply) => {
1643
+ const { displayText, vaultData } = extractVaultBlock(reply, {
1644
+ warn: (msg) => printAboveInput(dim(`companion: warning: ${msg}`)),
1645
+ });
1646
+ if (vaultData) {
1647
+ try {
1648
+ writeVaultAutoSummary({ capture, vaultContext, vaultData });
1649
+ } catch (err) {
1650
+ printAboveInput(dim(`companion: warning: vault auto-summary write failed (${err.message})`));
1651
+ }
1652
+ } else {
1653
+ printAboveInput(
1654
+ dim('companion: warning: vault auto-summary was on but the reply had no vault data block; skipping this write')
1655
+ );
1656
+ }
1657
+ return displayText;
1658
+ }
1659
+ : undefined,
1660
+ problemContext: turn.problemContext,
1661
+ currentCode: turn.currentCode,
1662
+ compactText: turn.message.compactText,
1663
+ panelMeta: { problemSlug: capture.problemSlug || null },
1664
+ trigger: 'submit',
1665
+ });
1666
+ if (ok) ledger.commit(problemId, plan);
1667
+ } finally {
1668
+ releaseBusy();
1669
+ }
1670
+ });
1671
+ }
1672
+
1673
+ // The live editor code the panel read at send time (see extension/panel.js
1674
+ // and content.js), or null. Only the fields the tutor needs, made safe the
1675
+ // same way a capture is; the code itself is left exactly as typed.
1676
+ function panelSnapshot(raw) {
1677
+ if (!raw || typeof raw !== 'object' || typeof raw.code !== 'string') return null;
1678
+ const clean = sanitizeCapture({
1679
+ code: raw.code,
1680
+ language: typeof raw.language === 'string' ? raw.language : null,
1681
+ problemTitle: typeof raw.problemTitle === 'string' ? raw.problemTitle : null,
1682
+ problemDescription: typeof raw.problemDescription === 'string' ? raw.problemDescription : null,
1683
+ });
1684
+ return clean;
1685
+ }
1686
+
1687
+ // A message typed into the in-page panel (delivered by panel-bridge.js). It
1688
+ // joins the same queue as terminal-typed messages and captures, and is echoed
1689
+ // in the terminal under a [panel] label so the two views never diverge. The
1690
+ // panel sends the live editor code with it when it could read it; without
1691
+ // that the message goes with no code at all (a terminal message, which has no
1692
+ // editor to read, uses the newest Run or Submit instead).
1693
+ function handlePanelMessage(message) {
1694
+ resetScrollIfNeeded();
1695
+ // The relay already strips control characters from panel messages, but
1696
+ // this process is the one writing to the terminal, so it does not rely on
1697
+ // that (see sanitize.js).
1698
+ const text = stripControlChars(message.text).trim();
1699
+ const problemSlug = oneLine(message.problemSlug) || null;
1700
+ if (!text) return;
1701
+ const snapshot = panelSnapshot(message.snapshot);
1702
+ if (problemSlug) panelProblemSlug = problemSlug;
1703
+ const messageProblemSlug = panelProblemSlug;
1704
+ const label = dim(`[panel] ${text}`);
1705
+ const releaseBusy = holdBusy();
1706
+ queueTurn(async () => {
1707
+ if (!problemSlug) {
1708
+ panelPublish({ type: 'user', text, problemSlug: messageProblemSlug });
1709
+ await sendChatTurn({ label, text, problemId: ledger.activeProblemId });
1710
+ return;
1711
+ }
1712
+ // A message from another problem than the active one resets the tutor
1713
+ // (see activateProblem), and the tutor is told which problem is on screen.
1714
+ const { previousId } = activateProblem(problemSlug, problemSlug);
1715
+ panelPublish({ type: 'user', text, problemSlug });
1716
+ if (snapshot) {
1717
+ ledger.record(problemSlug, {
1718
+ code: snapshot.code,
1719
+ language: snapshot.language,
1720
+ slug: problemSlug,
1721
+ context: {
1722
+ problemId: problemSlug,
1723
+ title: snapshot.problemTitle || problemSlug,
1724
+ slug: problemSlug,
1725
+ description:
1726
+ snapshot.problemDescription && snapshot.problemDescription.trim() ? snapshot.problemDescription : null,
1727
+ },
1728
+ });
1729
+ }
1730
+ const codeUnavailable = !snapshot;
1731
+ const preamble = ({ hasHeld }) => chatPreamble({ previousId, problemId: problemSlug, codeUnavailable, hasHeld });
1732
+ await sendChatTurn({ label, text, problemId: problemSlug, preamble, codeUnavailable });
1733
+ }).finally(releaseBusy);
1734
+ }
1735
+
1736
+ async function poll() {
1737
+ let stats;
1738
+ try {
1739
+ stats = fs.statSync(CAPTURES_PATH);
1740
+ } catch {
1741
+ return; // log file doesn't exist yet
1742
+ }
1743
+
1744
+ if (stats.size < offset) {
1745
+ printAboveInput(dim('companion: capture log shrank; resetting offset to 0'));
1746
+ offset = 0;
1747
+ }
1748
+ if (stats.size <= offset) return;
1749
+
1750
+ const fd = fs.openSync(CAPTURES_PATH, 'r');
1751
+ let buf;
1752
+ try {
1753
+ const length = stats.size - offset;
1754
+ buf = Buffer.alloc(length);
1755
+ fs.readSync(fd, buf, 0, length, offset);
1756
+ } finally {
1757
+ fs.closeSync(fd);
1758
+ }
1759
+
1760
+ const text = buf.toString('utf8');
1761
+ const lastNewline = text.lastIndexOf('\n');
1762
+ if (lastNewline === -1) return; // no complete line yet
1763
+
1764
+ const complete = text.slice(0, lastNewline);
1765
+ const consumedBytes = Buffer.byteLength(complete, 'utf8') + 1;
1766
+ const lines = complete.split('\n');
1767
+
1768
+ for (const line of lines) {
1769
+ // eslint-disable-next-line no-await-in-loop
1770
+ await handleCaptureLine(line);
1771
+ }
1772
+
1773
+ offset += consumedBytes;
1774
+ saveOffset(offset);
1775
+ }
1776
+
1777
+ async function pollLoop() {
1778
+ // Captures from before this start are not needed (an unread tail after the
1779
+ // saved offset still is, and stays).
1780
+ captureTrimWanted = true;
1781
+ while (!stopping) {
1782
+ try {
1783
+ if (captureTrimWanted || offset > CAPTURE_LOG_TRIM_BYTES) {
1784
+ // eslint-disable-next-line no-await-in-loop
1785
+ await trimHandledCaptures();
1786
+ }
1787
+ // eslint-disable-next-line no-await-in-loop
1788
+ await poll();
1789
+ } catch (err) {
1790
+ printAboveInput(dim(`companion: error during capture poll: ${err.stack || err.message}`));
1791
+ }
1792
+ if (stopping) break;
1793
+ // eslint-disable-next-line no-await-in-loop
1794
+ await new Promise((resolve) => setTimeout(resolve, POLL_MS));
1795
+ }
1796
+ }
1797
+
1798
+ // SIGHUP is what a terminal sends when its window is closed. Node's default
1799
+ // for it ends the process without running 'exit' handlers, which left the
1800
+ // relay server this companion started running (and holding its port) after
1801
+ // the window was gone.
1802
+ // Run by the helper, there is no terminal to close: SIGHUP is ignored, and
1803
+ // the helper's SIGTERM or closed stdin ends this process.
1804
+ for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
1805
+ process.on(sig, () => {
1806
+ if (sig === 'SIGHUP' && HEADLESS) return;
1807
+ rl.close();
1808
+ });
1809
+ }
1810
+
1811
+ // --- mouse-wheel scrollback viewport ------------------------------------
1812
+ //
1813
+ // The alternate screen buffer has no native scrollback (see the top of
1814
+ // this file), so this program keeps its own: historyBuffer already records
1815
+ // every line of real conversation content verbatim (for Page Up/less
1816
+ // below); scrollOffset is how many lines up from the live bottom the
1817
+ // visible window currently sits, and redrawViewport paints exactly that
1818
+ // window - a real, application-level scrollback, not reliance on anything
1819
+ // the terminal provides. 0 means "the live tail" - the normal, default
1820
+ // view, following new content exactly like before this feature existed.
1821
+ // Only ever changed while idle (turnActive false) - see handleWheel and
1822
+ // resetScrollIfNeeded below for why a turn in progress and a nonzero
1823
+ // scrollOffset can never coincide, which is what lets the rest of this
1824
+ // file's rendering code (printAboveInput, the streaming writer, the
1825
+ // spinner) stay entirely unaware scrolling exists at all.
1826
+ let scrollOffset = 0;
1827
+
1828
+ // How many lines a single wheel notch moves the viewport - most terminals
1829
+ // send one SGR wheel report per physical notch (not per pixel), so this is
1830
+ // "lines per notch," matching the common OS-level default of a few lines
1831
+ // per click rather than a full screen.
1832
+ const WHEEL_SCROLL_LINES = 3;
1833
+
1834
+ // Leaves room for the box's own rows: a blank breathing-room line, the
1835
+ // rule, however many physical rows the prompt itself currently needs (see
1836
+ // promptRowCount - normally 1, more once a long typed/pasted line has
1837
+ // wrapped), and one more blank breathing-room row below the prompt,
1838
+ // symmetric with the one above the rule (see drawBoxRaw's own reservation
1839
+ // of that below-row, right before it renders the prompt). Reserving it here
1840
+ // too, so redrawViewport's own padding math leaves the box's bottom pinned
1841
+ // one row higher than the terminal's actual last row, is what makes the
1842
+ // *fill-phase* draws (before the screen has genuinely filled with real
1843
+ // content) agree with drawBoxRaw's own steady-state reservation, rather
1844
+ // than the two disagreeing about where the box's bottom belongs.
1845
+ // clearBottomRows/reservedBottomRows deliberately keep counting only "the
1846
+ // rule plus the prompt" rows, since those are the rows at-and-above the
1847
+ // cursor's own row - the reserved row below the cursor is never part of
1848
+ // that upward count. Recomputed on every call rather than assumed fixed, so
1849
+ // a long typed line correctly shrinks the space left for chat content
1850
+ // instead of the box's extra wrapped row silently overlapping it.
1851
+ function currentVisibleRows() {
1852
+ const rows = process.stdout.rows || 24;
1853
+ return Math.max(1, rows - 3 - promptRowCount());
1854
+ }
1855
+
1856
+ // Repaints the screen from historyBuffer at the current scrollOffset, sized
1857
+ // to the terminal's current height, then the box - the one place that
1858
+ // actually turns "recorded history + an offset" into pixels. Used to
1859
+ // return to the live view after the pager (see showHistory) closes, to
1860
+ // respond to a wheel event, to reflow the current window after a resize,
1861
+ // and (see drawBox above) as the fill-phase repaint for every ordinary
1862
+ // redraw until the screen has genuinely filled with real content. Because
1863
+ // it always repaints the *entire* visible window fresh from historyBuffer -
1864
+ // the true, never-lost record of everything ever printed - rather than
1865
+ // incrementally editing whatever the physical terminal screen currently
1866
+ // shows, it can't inherit whatever an unrelated stray scroll (this
1867
+ // program's own, or readline's own newline-at-the-last-row on a plain
1868
+ // Enter) already did to the physical screen; it always recomputes the
1869
+ // correct layout from scratch. scrollOffset is clamped here (not at the
1870
+ // call sites) so it can never point past either end of the buffer
1871
+ // regardless of how it got there - a shrunk terminal window, or history
1872
+ // that's grown shorter than a previously-valid offset (e.g. after the
1873
+ // capture log's own trim).
1874
+ function redrawViewport() {
1875
+ const visibleRows = currentVisibleRows();
1876
+ const allLines = historyLines();
1877
+ const total = allLines.length;
1878
+ scrollOffset = Math.max(0, Math.min(scrollOffset, Math.max(0, total - visibleRows)));
1879
+ const end = total - scrollOffset;
1880
+ const start = Math.max(0, end - visibleRows);
1881
+ const slice = allLines.slice(start, end);
1882
+ process.stdout.write('\x1b[2J\x1b[H'); // clear screen, cursor to top-left
1883
+ if (slice.length) process.stdout.write(`${slice.join('\n')}\n`);
1884
+ if (slice.length < visibleRows) {
1885
+ process.stdout.write('\n'.repeat(visibleRows - slice.length));
1886
+ }
1887
+ // Only latch "the screen has genuinely filled" when the *real* content
1888
+ // (not padding) actually reaches visibleRows - unlike the old code, which
1889
+ // set this unconditionally after any repaint. Latching unconditionally is
1890
+ // exactly what made drawBox's old incremental path silently corrupt a
1891
+ // short conversation: after the very first draw, drawBox would never pad
1892
+ // again regardless of how little real content there actually was, so
1893
+ // every later redraw's own newline-at-the-last-row forced a real terminal
1894
+ // scroll that dropped a row off the top instead. total >= visibleRows is
1895
+ // the one condition under which that natural scrolling is actually
1896
+ // correct and desired (see drawBox's own comment) - so that's the only
1897
+ // condition allowed to turn the incremental path back on.
1898
+ screenFilledToBox = total >= visibleRows;
1899
+ bottomRowsShown = 0;
1900
+ // drawBoxRaw directly, not drawBox - this *is* the fill-phase repaint
1901
+ // drawBox would otherwise delegate back to, so calling drawBox here would
1902
+ // either recurse into this same function again (screenFilledToBox still
1903
+ // false) or silently skip a row of padding this call already accounted
1904
+ // for (screenFilledToBox just turned true) by drawing the box a second,
1905
+ // redundant time.
1906
+ drawBoxRaw();
1907
+ }
1908
+
1909
+ // Called by anything that represents new activity (a capture, a typed
1910
+ // message) before it writes a single byte - if the user was scrolled up
1911
+ // reading history, this always returns them to the live view first, so
1912
+ // nothing else in this file ever has to reason about "what if we're
1913
+ // mid-scroll right now." A no-op when already at the live bottom.
1914
+ function resetScrollIfNeeded() {
1915
+ if (scrollOffset === 0) return;
1916
+ scrollOffset = 0;
1917
+ redrawViewport();
1918
+ }
1919
+
1920
+ // The mouse-filter's onWheel callback (wired up where readlineInput is
1921
+ // built, near the top of this file) - ignored entirely while a turn is
1922
+ // active, same as Page Up already is below: mid-turn, bottomRowsShown
1923
+ // might mean "a 1-row spinner" or a still-streaming piece rather than a
1924
+ // settled 2-row box, and redrawViewport's full-screen clear would corrupt
1925
+ // whatever that turn's own writes are tracking. Idle, scrolling is always
1926
+ // safe: resetScrollIfNeeded (above) guarantees scrollOffset is already 0
1927
+ // by the time any turn begins, so there's no state to reconcile once one
1928
+ // does.
1929
+ function handleWheel(direction) {
1930
+ if (!process.stdout.isTTY || turnActive) return;
1931
+ scrollOffset += direction === 'up' ? WHEEL_SCROLL_LINES : -WHEEL_SCROLL_LINES;
1932
+ scrollOffset = Math.max(0, scrollOffset);
1933
+ redrawViewport();
1934
+ }
1935
+
1936
+ // Page Up opens the full conversation history in `less` rather than a
1937
+ // hand-rolled scroll view - a real pager already gets wrapping, search,
1938
+ // and its own Page Up/Down exactly right, which a from-scratch
1939
+ // implementation would have to earn the hard way. `-X` specifically
1940
+ // disables less's own terminal init/deinit (its own alternate-screen
1941
+ // enter/exit) - without it, less entering *its own* alternate screen while
1942
+ // this program is already in one, then leaving it, would drop the
1943
+ // terminal straight back to the normal buffer under it instead of back to
1944
+ // this program's own alternate screen, since 1049 isn't a stack (see the
1945
+ // comment at the top of this file on why 1049 is used at all). With -X,
1946
+ // less paints directly into the screen this program already owns, and
1947
+ // handing it real terminal-attached stdio (not a pipe) is what makes its
1948
+ // own scrolling/search interactive rather than just dumping the content.
1949
+ // Still worth keeping now that the wheel scrolls the box's own viewport
1950
+ // natively: `less` adds real search (`/`), unbounded scroll speed, and
1951
+ // works even on a terminal that doesn't support SGR mouse reporting at
1952
+ // all, none of which the lightweight windowed viewport above attempts to
1953
+ // replace - both draw from the same historyBuffer regardless, so Page Up
1954
+ // is now best thought of as "open the exact same history in a real pager"
1955
+ // rather than the wheel's only fallback.
1956
+ // Ignored while a turn is active (a spinner or a streamed piece is still
1957
+ // mid-write to the same terminal - spawning a pager on top of that would
1958
+ // corrupt both) and while there's nothing to show yet.
1959
+ function showHistory() {
1960
+ if (!process.stdout.isTTY || turnActive || historyBuffer.length === 0) return;
1961
+ const content = historyBuffer.join('');
1962
+ rl.pause();
1963
+ // Mouse tracking is a terminal-wide mode, not scoped to any one file
1964
+ // descriptor - `less` reads its own keyboard input directly from
1965
+ // /dev/tty (the standard pager trick for staying interactive even when
1966
+ // its content comes in over a pipe, as it does here), completely
1967
+ // bypassing this process's stdin. Left enabled, a wheel scroll while
1968
+ // `less` is open would still generate SGR reports, just delivered to
1969
+ // `less` instead - which doesn't understand them any better than
1970
+ // readline does - so they're turned off for the duration and restored
1971
+ // once `less` returns.
1972
+ process.stdout.write(DISABLE_MOUSE_TRACKING);
1973
+ // spawnSync doesn't throw for a missing binary (ENOENT) or any other
1974
+ // spawn-time failure - it returns normally with .error set instead, so
1975
+ // that's what needs checking here, not a try/catch.
1976
+ const result = spawnSync('less', ['-RX'], { input: content, stdio: ['pipe', 'inherit', 'inherit'] });
1977
+ process.stdout.write(ENABLE_MOUSE_TRACKING);
1978
+ rl.resume();
1979
+ if (result.error) {
1980
+ printAboveInput(dim(`companion: warning: could not open history in "less" (${result.error.message})`));
1981
+ return;
1982
+ }
1983
+ scrollOffset = 0;
1984
+ redrawViewport();
1985
+ }
1986
+
1987
+ if (process.stdout.isTTY) {
1988
+ // Attached to filteredStdin, not process.stdin directly - see the "raw
1989
+ // input / mouse-wheel scrollback" section near the top of this file for
1990
+ // why: readline's own keypress decoder (and this listener, sharing the
1991
+ // same decoder) only ever sees the filtered stream in a real terminal.
1992
+ filteredStdin.on('keypress', (str, key) => {
1993
+ if (key && key.name === 'pageup') showHistory();
1994
+ });
1995
+ }
1996
+
1997
+ // --- settings from the extension's options page ----------------------------
1998
+ //
1999
+ // The options page calls settings-rpc.js through the relay (POST
2000
+ // /settings/rpc, forwarded down the inbox stream; see panel-bridge.js). A
2001
+ // save is validated and a backend is built from it before anything is
2002
+ // written, then applySettings swaps it in.
2003
+
2004
+ // The non-secret summary of what the tutor runs on, for the panel's header.
2005
+ function publishConfig() {
2006
+ if (panelBridge) panelBridge.setConfig(statusSummary(effective));
2007
+ }
2008
+
2009
+ // What a backend is built from; when none of it changed (only an option such
2010
+ // as the vault auto-summary did), the running backend and its conversation
2011
+ // are kept.
2012
+ function backendKey(config) {
2013
+ return JSON.stringify([
2014
+ config.provider,
2015
+ config.model,
2016
+ config.baseUrl,
2017
+ config.apiKey,
2018
+ config.contextTokens,
2019
+ config.options.localMaxHistoryTurns,
2020
+ ]);
2021
+ }
2022
+
2023
+ // Puts `nextBackend` in place of the running one between turns, moving the
2024
+ // conversation over (extension/shared/conversation.js) so the new model sees
2025
+ // the same chat and statement (the code comes from the ledger every turn).
2026
+ // Only a backend that cannot take it over (ClaudeBackend's session) starts
2027
+ // fresh, and then the ledger forgets what the tutor saw, so the next message
2028
+ // carries the full code. Returns whether the conversation moved. This is
2029
+ // also what a model switcher calls.
2030
+ function swapBackend(nextBackend) {
2031
+ const moved = transferConversation(backend, nextBackend);
2032
+ backend = nextBackend;
2033
+ if (!moved) ledger.forgetHeld();
2034
+ return moved;
2035
+ }
2036
+
2037
+ // Swaps in a saved configuration through the turn queue, so it lands between
2038
+ // turns and never in the middle of one (a message queued after the save goes
2039
+ // to the new backend). Returns the queued swap: a save does not wait for it
2040
+ // (the options page hears "saved" at once even while a long reply is still
2041
+ // running), the model switcher does (`why` is 'switch'), so the panel shows
2042
+ // "Switching" until the new model is really in place.
2043
+ function applySettings(nextEffective, nextBackend, why = 'save') {
2044
+ return queueTurn(async () => {
2045
+ const swap = backendKey(nextEffective) !== backendKey(effective);
2046
+ effective = nextEffective;
2047
+ if (swap) swapBackend(nextBackend);
2048
+ const now = statusSummary(effective);
2049
+ const model = now.model ? ` (${oneLine(now.model)})` : '';
2050
+ printAboveInput(
2051
+ dim(
2052
+ why === 'switch'
2053
+ ? `companion: switched models - now using ${now.label}${model}, same conversation`
2054
+ : `companion: settings saved - ${swap ? 'now using' : 'still using'} ${now.label}${model}`
2055
+ )
2056
+ );
2057
+ publishConfig();
2058
+ }).catch((err) => {
2059
+ printAboveInput(dim(`companion: error applying settings: ${err.message}`));
2060
+ throw err;
2061
+ });
2062
+ }
2063
+
2064
+ const settingsRpc = createSettingsRpc({
2065
+ file: SETTINGS_FILE,
2066
+ env: process.env,
2067
+ getStored: () => storedSettings,
2068
+ setStored: (next) => {
2069
+ storedSettings = next;
2070
+ },
2071
+ buildBackend: (config) => makeBackend(config),
2072
+ apply: applySettings,
2073
+ validateVault: () => validateVaultPath(VAULT_PATH),
2074
+ });
2075
+
2076
+ // For the idle exit: whether the panel bridge's inbox stream to the relay is
2077
+ // up, and when it last was.
2078
+ let relayConnected = false;
2079
+ let relayLastSeen = Date.now();
2080
+ function noteRelayState(state) {
2081
+ if (relayConnected) relayLastSeen = Date.now();
2082
+ relayConnected = state === 'connected';
2083
+ if (relayConnected) relayLastSeen = Date.now();
2084
+ }
2085
+
2086
+ await ensureRelayServer();
2087
+
2088
+ if (PANEL_ENABLED) {
2089
+ let announced = false;
2090
+ panelBridge = createPanelBridge({
2091
+ baseUrl: `http://${RELAY_HOST}:${RELAY_PORT}`,
2092
+ onMessage: handlePanelMessage,
2093
+ onRpc: (method, params) => settingsRpc.handle(method, params),
2094
+ onState: (state) => {
2095
+ noteRelayState(state);
2096
+ if (state === 'connected' && !announced) {
2097
+ announced = true;
2098
+ printAboveInput(dim('companion: in-page panel connected (the Tutor tab on leetcode.com)'));
2099
+ } else if (state === 'unsupported') {
2100
+ printAboveInput(
2101
+ dim('companion: warning: the running relay server predates the in-page panel; restart it to enable the Tutor tab')
2102
+ );
2103
+ }
2104
+ },
2105
+ });
2106
+ panelBridge.start();
2107
+ publishConfig();
2108
+ if (IDLE_EXIT_MS > 0) {
2109
+ setInterval(() => {
2110
+ if (relayConnected || Date.now() - relayLastSeen < IDLE_EXIT_MS) return;
2111
+ printAboveInput(dim(`companion: nothing heard from the relay for ${Math.round(IDLE_EXIT_MS / 1000)} s, exiting`));
2112
+ rl.close();
2113
+ }, Math.min(IDLE_EXIT_MS, 10000)).unref();
2114
+ }
2115
+ process.on('exit', () => panelBridge.stop());
2116
+ }
2117
+
2118
+ {
2119
+ const active = statusSummary(effective);
2120
+ printBanner(
2121
+ dim(`companion: backend=${active.provider} (${active.label})${active.model ? ` model=${oneLine(active.model)}` : ''}`)
2122
+ );
2123
+ if (loadedSettings.warning) printBanner(dim(`companion: warning: settings: ${oneLine(loadedSettings.warning)}`));
2124
+ }
2125
+ printBanner(dim(`companion: watching ${CAPTURES_PATH} (starting offset=${offset})`));
2126
+ printBanner(
2127
+ dim(
2128
+ effective.options.vaultAutoSummary
2129
+ ? `companion: vault auto-summary ON - writing to ${VAULT_PATH}`
2130
+ : 'companion: vault auto-summary off (turn it on in the Tutor settings, or set VAULT_AUTO_SUMMARY=1)'
2131
+ )
2132
+ );
2133
+ printBanner(
2134
+ dim(
2135
+ effective.options.autoClearContext
2136
+ ? 'companion: auto-clear-context ON - a message, Run or Submit on a different problem resets the tutor session (set COMPANION_AUTO_CLEAR_CONTEXT=0 to disable)'
2137
+ : 'companion: auto-clear-context off (COMPANION_AUTO_CLEAR_CONTEXT=0)'
2138
+ )
2139
+ );
2140
+ printBanner(
2141
+ dim('companion: type to chat directly; a changed Submit goes to the tutor, a Run waits for your next message. Page Up for history, /exit to quit.')
2142
+ );
2143
+ if (stylingEnabled()) drawBox();
2144
+ else rl.prompt();
2145
+
2146
+ // Node updates process.stdout.columns/rows (from the terminal's SIGWINCH)
2147
+ // and emits 'resize' before this fires, so refreshWidth() below - which
2148
+ // just re-reads process.stdout.columns - already picks up the new size.
2149
+ // Always safe to do regardless of what's on screen right now. Redrawing
2150
+ // the box itself to *show* the new width immediately is only safe when
2151
+ // idle (turnActive false): mid-turn, bottomRowsShown might mean "a 1-row
2152
+ // spinner" rather than "a 2-row box" at any given moment, and forcing a
2153
+ // 2-row box there would corrupt whatever the turn's own writes are
2154
+ // tracking. Idle, the box's state is simple and known, so it can just be
2155
+ // redrawn on the spot; mid-turn, the new width still takes effect - just
2156
+ // only visibly once the turn's own next write happens, rather than
2157
+ // instantly. Registered only after the box above is first drawn, so there's
2158
+ // no window where a resize could fire before there's a box to redraw.
2159
+ if (process.stdout.isTTY) {
2160
+ process.stdout.on('resize', () => {
2161
+ refreshWidth();
2162
+ // Rows may have changed too, not just columns. redrawViewport (below)
2163
+ // already recomputes its own padding fresh on every call regardless of
2164
+ // this latch's prior value, so this reset only actually matters for the
2165
+ // turnActive branch: mid-turn, redrawViewport doesn't run yet (see
2166
+ // below), so drop the latch now so the turn's own next incremental
2167
+ // drawBox call - once it does run - re-evaluates against the new size
2168
+ // instead of trusting a conclusion reached at the old one.
2169
+ screenFilledToBox = false;
2170
+ if (!turnActive) {
2171
+ // Always a full repaint (not just the box's own row), whether idle at
2172
+ // the live tail or scrolled - redrawViewport already handles both
2173
+ // (scrollOffset 0 is simply "show the live tail"), and it's what
2174
+ // makes the box actually move to the new last row rather than only
2175
+ // updating in place where it already was. It replays already-
2176
+ // rendered content from historyBuffer rather than re-wrapping it to
2177
+ // the new width (only markdown rendered *after* this point picks up
2178
+ // the new width - see refreshWidth's own comment), so this is safe
2179
+ // for a plain column-only resize too; the cost is a full-screen
2180
+ // clear on every resize rather than only when scrolled, which is an
2181
+ // acceptable trade for a resize actually pinning correctly.
2182
+ redrawViewport();
2183
+ }
2184
+ // Mid-turn, the new size still takes effect - just only visibly once
2185
+ // the turn's own next write calls drawBox again (screenFilledToBox was
2186
+ // just cleared above, so that draw will pad correctly too).
2187
+ });
2188
+ }
2189
+
2190
+ pollLoop().catch((err) => {
2191
+ console.error(dim(`companion: fatal: ${err.stack || err.message}`));
2192
+ process.exit(1);
2193
+ });