diff --git a/LICENSE-ASSETS b/LICENSE-ASSETS index 5eec3aa1e..f1ac9cfbb 100644 --- a/LICENSE-ASSETS +++ b/LICENSE-ASSETS @@ -11,4 +11,11 @@ The Office cast sprites are not LimeZu art -- they are drawn procedurally in src/renderer/src/scene/office/portraitArt.ts and are covered by the MIT License like the rest of the source. +The Star Hike floor art under themes/starhike/ (art/ and map/) is not LimeZu +art and not MIT either. It is "Artwork by Prozzta" (https://github.com/Prozzta/guppyos), +licensed under themes/starhike/LICENSE-ARTWORK.md, which permits editing and use +in commercial and non-commercial projects, does not permit reselling or +distributing the art itself, and REQUIRES the credit "Artwork by Prozzta" with a +link to https://github.com/Prozzta/guppyos. + See src/renderer/src/assets/ATTRIBUTION.md for details. diff --git a/README.md b/README.md index b8cb80668..32b9d35a3 100644 --- a/README.md +++ b/README.md @@ -556,9 +556,15 @@ source, forks compile with no key and send nothing) are documented in > LimeZu is required by that licence** and must stay in place. The Office cast is not LimeZu art. It > is drawn procedurally in `portraitArt.ts`. See > [`src/renderer/src/assets/ATTRIBUTION.md`](./src/renderer/src/assets/ATTRIBUTION.md). +> +> The **Star Hike** floor's art and map (`themes/starhike`) are **Artwork by Prozzta** +> (), under the pack's own licence in +> [`themes/starhike/LICENSE-ARTWORK.md`](./themes/starhike/LICENSE-ARTWORK.md). It permits editing and +> use in any project, not reselling or passing on the art by itself, and **the credit is required**. The **source code** is licensed under the **MIT License**. See [`LICENSE`](./LICENSE). The MIT grant -covers the code only; the bundled pixel art is licensed separately from LimeZu and is carved out in +covers the code only; the bundled pixel art is licensed separately (from LimeZu, and from Prozzta for +Star Hike) and is carved out in [`LICENSE-ASSETS`](./LICENSE-ASSETS). *Munder Difflin* is an affectionate parody and is not affiliated with NBC's *The Office* or Dunder Mifflin. diff --git a/src/main/askMeAnswers.ts b/src/main/askMeAnswers.ts new file mode 100644 index 000000000..6ae88d0a3 --- /dev/null +++ b/src/main/askMeAnswers.ts @@ -0,0 +1,104 @@ +/** + * The main side of `hive:answerQuestion` and `hive:answerQuestions`: an answer + * or a dismissal changes ONE humanQA entry, the one the card showed, on the + * latest tasks.json. The window never writes a card's whole humanQA list: its + * copy is up to 5 s old, and writing it back would undo an entry the god added + * since. Several requests (Dismiss all) are applied in order and written once. + * + * Pure and Electron free: Hive.answerQuestions reads and writes the ledger. + */ +import { + answerMail, + composeAnswer, + normalizeHumanQA, + normalizeOptionsIndexed, + openQuestionByRef, + type AnswerRequest, + type QuestionRef +} from '../shared/humanQuestion'; + +const MAX_ID = 200; + +function isObject(v: unknown): v is Record { + return !!v && typeof v === 'object' && !Array.isArray(v); +} + +function parseRef(raw: unknown): QuestionRef | null { + if (!isObject(raw) || typeof raw.q !== 'string') return null; + if (!Number.isSafeInteger(raw.nth) || (raw.nth as number) < 0) return null; + if (raw.askedAt !== undefined && typeof raw.askedAt !== 'string') return null; + if (raw.labels !== undefined && (!Array.isArray(raw.labels) || !raw.labels.every((l) => typeof l === 'string'))) return null; + return { + q: raw.q, + nth: raw.nth as number, + ...(typeof raw.askedAt === 'string' ? { askedAt: raw.askedAt } : {}), + ...(Array.isArray(raw.labels) ? { labels: raw.labels as string[] } : {}) + }; +} + +/** A well formed request, or null (reported as invalid). */ +export function parseAnswerRequest(raw: unknown): AnswerRequest | null { + if (!isObject(raw)) return null; + const taskId = raw.taskId; + if (typeof taskId !== 'string' || !taskId || taskId.length > MAX_ID) return null; + const ref = parseRef(raw.ref); + if (!ref) return null; + if (raw.dismiss === true) return raw.answer === undefined ? { taskId, ref, dismiss: true } : null; + const a = raw.answer; + if (!isObject(a) || typeof a.text !== 'string') return null; + if (a.chosen !== undefined && (!Array.isArray(a.chosen) || !a.chosen.every((i) => Number.isSafeInteger(i) && (i as number) >= 0))) return null; + return { taskId, ref, answer: { text: a.text, ...(Array.isArray(a.chosen) ? { chosen: a.chosen as number[] } : {}) } }; +} + +export interface AppliedAnswer { + result: 'ok' | 'gone' | 'invalid'; + /** For an answer that was applied: the god's inbox message. */ + notice?: { subject: string; body: string }; +} + +/** + * Apply each request, in order, to the raw ledger list. Returns the next list + * (only changed cards replaced, every other field kept as written), whether + * anything changed, and one result per request. + */ +export function applyAnswers( + rawTasks: unknown[], + reqs: Array, + nowIso: string +): { tasks: unknown[]; changed: boolean; outcomes: AppliedAnswer[] } { + const tasks = rawTasks.slice(); + let changed = false; + const outcomes = reqs.map((req): AppliedAnswer => { + if (!req) return { result: 'invalid' }; + const at = tasks.findIndex((t) => isObject(t) && t.id === req.taskId); + if (at < 0) return { result: 'gone' }; + const card = tasks[at] as Record; + const i = openQuestionByRef(card, req.ref, 'answer' in req); + if (i < 0) return { result: 'gone' }; + const qa = (card.humanQA as unknown[]).slice(); + const entry = qa[i] as Record; + let notice: AppliedAnswer['notice']; + if ('dismiss' in req) { + qa[i] = { ...entry, dismissedAt: nowIso }; + } else { + const shown = normalizeHumanQA(entry)!; + const options = shown.options ?? []; + const chosen = [...new Set(req.answer.chosen ?? [])].sort((a, b) => a - b); + if (chosen.some((c) => c >= options.length)) return { result: 'invalid' }; + if (chosen.length > 1 && !shown.multi) return { result: 'invalid' }; + const text = composeAnswer(options, chosen, req.answer.text); + if (!text) return { result: 'invalid' }; + // The card's picks index the valid list; the ledger keeps raw positions. + const rawIndex = normalizeOptionsIndexed(entry.options)?.rawIndex ?? []; + const rest = { ...entry }; + delete rest.chosen; + qa[i] = { ...rest, a: text, answeredAt: nowIso, ...(chosen.length ? { chosen: chosen.map((c) => rawIndex[c]) } : {}) }; + const title = typeof card.title === 'string' ? card.title : req.taskId; + notice = answerMail({ id: req.taskId, title }, entry, { text, chosen }); + } + tasks[at] = { ...card, humanQA: qa }; + changed = true; + return notice ? { result: 'ok', notice } : { result: 'ok' }; + }); + return { tasks, changed, outcomes }; +} diff --git a/src/main/codexAgentConfig.ts b/src/main/codexAgentConfig.ts new file mode 100644 index 000000000..eff81a461 --- /dev/null +++ b/src/main/codexAgentConfig.ts @@ -0,0 +1,97 @@ +/** + * What a hive Codex agent's OWN config.toml changes relative to the seed copied + * from the person's ~/.codex/config.toml (installCodexHooks). Pure text + * transforms; the person's file is only ever read, never written. + * + * Line based on purpose: a full TOML round trip would reformat the person's + * file and drop their comments. + * + * Ported from Prozzta/guppyos 02d0d50b (MIT). + */ + +/** Each tool output the model sees is capped at this many of Codex's estimated + * tokens (about 4 characters each): an output over it keeps its beginning and + * end, and the middle is cut. The model's own policy still applies on top. */ +export const CODEX_TOOL_OUTPUT_TOKEN_LIMIT = 4000; + +/** A `[plugins.]` table header (not an array of tables, not a sub-table). + * A bare TOML key is only [A-Za-z0-9_-]: a dot starts a dotted key (a sub-table + * such as `[plugins.browser.settings]`), and a name with '@' must be quoted. */ +const PLUGIN_TABLE = /^\s*\[\s*plugins\s*\.\s*("(?:[^"\\]|\\.)*"|'[^']*'|[A-Za-z0-9_-]+)\s*\]\s*(#.*)?$/; +const ANY_TABLE = /^\s*\[/; +const ENABLED = /^(\s*)(["']?)enabled\2(\s*=\s*)(true|false)(\s*(#.*)?)$/; + +/** + * Every plugin the seed enables is turned OFF for a hive agent: each enabled + * plugin adds its tools, skills and instructions to every request (browser, + * computer use, documents, spreadsheets, ...), none of which a hive coding + * agent uses. A plugin table with no `enabled` key gets `enabled = false` too. + * Returns the text and how many were turned off. + */ +export function disableCodexPlugins(config: string): { text: string; disabled: number } { + const lines = config.split(/\r?\n/); + const out: string[] = []; + let inPlugin = false; + let sawEnabled = false; + let disabled = 0; + const closeTable = (): void => { + if (inPlugin && !sawEnabled) { + // After the table's last non-blank line, so a blank separator stays a separator. + let at = out.length; + while (at > 0 && out[at - 1].trim() === '') at--; + out.splice(at, 0, 'enabled = false # munder-hive: plugins are off for hive agents'); + disabled++; + } + inPlugin = false; + sawEnabled = false; + }; + for (const line of lines) { + if (ANY_TABLE.test(line)) { + closeTable(); + inPlugin = PLUGIN_TABLE.test(line); + out.push(line); + continue; + } + const m = inPlugin ? ENABLED.exec(line) : null; + if (m) { + sawEnabled = true; + if (m[4] === 'true') { + out.push(`${m[1]}${m[2]}enabled${m[2]}${m[3]}false # munder-hive: plugins are off for hive agents`); + disabled++; + continue; + } + } + out.push(line); + } + closeTable(); + return { text: out.join('\n'), disabled }; +} + +/** A top-level key line of the seed (before its first table), bare or quoted. */ +function topLevelKey(line: string, key: string): boolean { + const esc = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`^\\s*(["']?)${esc}\\1\\s*=`).test(line); +} + +/** + * Set top-level scalar keys in OUR copy: any seed line for the same key before + * the first table is removed (TOML forbids a duplicate key), and ours go first + * in the file. A `null` value only removes. + */ +export function setCodexTopLevelKeys(config: string, entries: Record): string { + const lines = config.split(/\r?\n/); + const firstTable = lines.findIndex((l) => ANY_TABLE.test(l)); + const topEnd = firstTable < 0 ? lines.length : firstTable; + const keys = Object.keys(entries); + const kept = lines.filter((l, i) => i >= topEnd || !keys.some((k) => topLevelKey(l, k))); + const ours = keys + .filter((k) => (typeof entries[k] === 'number' && Number.isFinite(entries[k] as number)) || typeof entries[k] === 'boolean') + .map((k) => `${k} = ${typeof entries[k] === 'boolean' ? String(entries[k]) : Math.trunc(entries[k] as number)}`); + if (!ours.length) return kept.join('\n'); + return `# --- munder-hive: per-agent limits (auto-generated; do not edit) ---\n${ours.join('\n')}\n\n${kept.join('\n')}`; +} + +/** OUR copy of the seed: plugins off, and each tool output capped. */ +export function codexAgentConfig(seed: string): string { + return setCodexTopLevelKeys(disableCodexPlugins(seed).text, { tool_output_token_limit: CODEX_TOOL_OUTPUT_TOKEN_LIMIT }); +} diff --git a/src/main/codexProjectLayers.ts b/src/main/codexProjectLayers.ts new file mode 100644 index 000000000..6372a056b --- /dev/null +++ b/src/main/codexProjectLayers.ts @@ -0,0 +1,486 @@ +/** + * The R3 hold: what a hive Codex agent's folder would load from project `.codex` + * layers, so main can hold the start until the person allows the folder. + * + * Every hive Codex start passes --dangerously-bypass-hook-trust, so a layer Codex + * loads runs with no review: its MCP servers start with the agent and its hooks on + * the first turn (in auto mode, outside the sandbox). Codex loads a project layer + * only where the person's own config trusts the folder, but the hold does not rely + * on that: every project `.codex` counts, fail safe. + * + * This is the PURE detector. It is deliberately wider than any Codex version's + * loader: every `.codex` from the agent's folder up to the filesystem root (not + * just up to the project root), and for a linked worktree the main checkout's + * hooks as well (Codex takes a worktree's hooks from there). The agent's own + * CODEX_HOME and the person's own ~/.codex (their global config, already loaded) + * are not project layers. Whatever cannot be read counts as code that runs. + * + * A layer carries code through config.toml, hooks.json, rules/, agents/ (a role + * file can carry mcp_servers) and hooks/; every entry of agents/ and hooks/ counts. + * Only sandbox_mode and approval_policy in config.toml are overridden by the + * agent's own --sandbox and --ask-for-approval; every other key can widen access + * or run code (shell_environment_policy sets the environment of every command, + * notify runs a program after every turn), so it counts and is named. + * + * Ported from Prozzta/guppyos 02d0d50b (MIT): the TOML reader and the worktree + * walk as written there; the trust and version model left out. + */ +import * as nodeFs from 'node:fs'; +import { homedir } from 'node:os'; +import { posix, win32 } from 'node:path'; + +// ── a small TOML reader (enough to read what codex reads; throws on what codex would reject) ── + +export class TomlError extends Error {} +type TomlTable = { [k: string]: unknown }; +const isTable = (v: unknown): v is TomlTable => typeof v === 'object' && v !== null && !Array.isArray(v); + +/** + * Parse TOML text into plain objects. Strings (all four forms), arrays, inline tables, tables, + * arrays of tables and dotted keys are real; other scalars (numbers, booleans, dates) are kept + * as their raw text, so a malformed number is NOT caught here (codex would then reject the file; + * the only cost is a missed "does not parse" note, never a missed hook). Redefining a key or a + * table is an error, as in TOML. + */ +export function parseToml(text: string): TomlTable { + const s = text.replace(/\r\n/g, '\n'); + let i = 0; + let line = 1; + const root: TomlTable = {}; + const defined = new WeakSet(); // tables defined by a [header] or a key path + const inlineOrValue = new WeakSet(); // inline tables / static arrays: closed to extension + let current: TomlTable = root; + const fail = (m: string): never => { throw new TomlError(`line ${line}: ${m}`); }; + const peek = (): string => s[i] ?? ''; + const ws = (): void => { while (peek() === ' ' || peek() === '\t') i++; }; + const wsNl = (): void => { + for (;;) { + ws(); + if (peek() === '#') { while (i < s.length && s[i] !== '\n') i++; } + if (peek() === '\n') { i++; line++; continue; } + return; + } + }; + const endOfLine = (): void => { + ws(); + if (peek() === '#') while (i < s.length && s[i] !== '\n') i++; + if (i < s.length && s[i] !== '\n') fail(`unexpected ${JSON.stringify(s.slice(i, i + 12))}`); + }; + const basicEscape = (): string => { + const c = s[i++]; + const simple: Record = { b: '\b', t: '\t', n: '\n', f: '\f', r: '\r', '"': '"', '\\': '\\', e: '\u001b' }; + if (c in simple) return simple[c]; + if (c === 'u' || c === 'U') { + const n = c === 'u' ? 4 : 8; + const h = s.slice(i, i + n); + if (!new RegExp(`^[0-9A-Fa-f]{${n}}$`).test(h)) fail('bad unicode escape'); + i += n; + const cp = parseInt(h, 16); + if (cp > 0x10ffff || (cp >= 0xd800 && cp <= 0xdfff)) fail('bad unicode scalar'); + return String.fromCodePoint(cp); + } + return fail(`bad escape \\${c ?? ''}`); + }; + const str = (): string => { + if (s.startsWith('"""', i)) { + i += 3; if (peek() === '\n') { i++; line++; } + let out = ''; + for (;;) { + if (i >= s.length) fail('unterminated """ string'); + if (s.startsWith('"""', i)) { + let q = 3; while (s[i + q] === '"' && q < 5) q++; + out += '"'.repeat(q - 3); i += q; return out; + } + const c = s[i++]; + if (c === '\n') { line++; out += c; continue; } + if (c === '\\') { + if (/^[ \t]*\n/.test(s.slice(i))) { while (/[ \t\n]/.test(peek())) { if (peek() === '\n') line++; i++; } continue; } + out += basicEscape(); continue; + } + out += c; + } + } + if (s.startsWith("'''", i)) { + i += 3; if (peek() === '\n') { i++; line++; } + const end = s.indexOf("'''", i); + if (end < 0) fail("unterminated ''' string"); + let e = end; while (s[e + 3] === "'" && e - end < 2) e++; + const out = s.slice(i, e); + line += (out.match(/\n/g) || []).length; + i = e + 3; return out; + } + if (peek() === '"') { + i++; let out = ''; + for (;;) { + const c = s[i++]; + if (c === undefined || c === '\n') fail('unterminated string'); + if (c === '"') return out; + if (c === '\\') { out += basicEscape(); continue; } + out += c; + } + } + if (peek() === "'") { + i++; const end = s.indexOf("'", i); + const nl = s.indexOf('\n', i); + if (end < 0 || (nl >= 0 && nl < end)) fail('unterminated literal string'); + const out = s.slice(i, end); i = end + 1; return out; + } + return fail('expected a string'); + }; + const keyPart = (): string => { + ws(); + if (peek() === '"' || peek() === "'") { + if (s.startsWith('"""', i) || s.startsWith("'''", i)) fail('a key cannot be multi-line'); + return str(); + } + const m = /^[A-Za-z0-9_-]+/.exec(s.slice(i)); + if (!m) fail('expected a key'); + i += m![0].length; return m![0]; + }; + const keyPath = (): string[] => { + const parts = [keyPart()]; + for (;;) { ws(); if (peek() !== '.') return parts; i++; parts.push(keyPart()); } + }; + const value = (): unknown => { + ws(); + const c = peek(); + if (c === '"' || c === "'") return str(); + if (c === '[') { + i++; const arr: unknown[] = []; + for (;;) { + wsNl(); + if (peek() === ']') { i++; break; } + arr.push(value()); + wsNl(); + if (peek() === ',') { i++; continue; } + if (peek() === ']') { i++; break; } + fail('expected , or ] in an array'); + } + inlineOrValue.add(arr); + return arr; + } + if (c === '{') { + i++; const t: TomlTable = {}; + ws(); + if (peek() === '}') { i++; inlineOrValue.add(t); return t; } + for (;;) { + const kp = keyPath(); ws(); + if (peek() !== '=') fail('expected = in an inline table'); + i++; + assign(t, kp, value(), true); + ws(); + if (peek() === ',') { i++; continue; } + if (peek() === '}') { i++; break; } + fail('expected , or } in an inline table'); + } + inlineOrValue.add(t); + return t; + } + const m = /^[A-Za-z0-9_:.+\-]+(?:[ T][0-9:.+\-Z]+)?/.exec(s.slice(i)); + if (!m) return fail('expected a value'); + i += m[0].length; + return m[0] === 'true' ? true : m[0] === 'false' ? false : m[0]; + }; + const assign = (table: TomlTable, kp: string[], v: unknown, inInline = false): void => { + let t = table; + for (const part of kp.slice(0, -1)) { + const next = t[part]; + if (next === undefined) { const n: TomlTable = {}; t[part] = n; defined.add(n); t = n; continue; } + if (!isTable(next) || (inlineOrValue.has(next) && !inInline)) fail(`key ${part} is already a value`); + t = next as TomlTable; + } + const last = kp[kp.length - 1]; + if (last in t) fail(`key ${kp.join('.')} is defined twice`); + t[last] = v; + }; + const tableAt = (kp: string[], arrayOfTables: boolean): TomlTable => { + let t = root; + kp.forEach((part, idx) => { + const isLast = idx === kp.length - 1; + let next = t[part]; + if (isLast && arrayOfTables) { + if (next === undefined) { next = []; t[part] = next; } + if (!Array.isArray(next) || inlineOrValue.has(next)) fail(`${kp.join('.')} is not an array of tables`); + const n: TomlTable = {}; (next as unknown[]).push(n); t = n; return; + } + if (next === undefined) { const n: TomlTable = {}; t[part] = n; t = n; if (isLast) defined.add(n); return; } + if (Array.isArray(next) && !inlineOrValue.has(next) && !isLast) { t = next[next.length - 1] as TomlTable; return; } + if (!isTable(next) || inlineOrValue.has(next)) fail(`${kp.join('.')} redefines a value`); + if (isLast) { if (defined.has(next as TomlTable)) fail(`table ${kp.join('.')} is defined twice`); defined.add(next as TomlTable); } + t = next as TomlTable; + }); + return t; + }; + for (;;) { + wsNl(); + if (i >= s.length) break; + if (peek() === '[') { + const aot = s.startsWith('[[', i); + i += aot ? 2 : 1; + const kp = keyPath(); ws(); + if (!s.startsWith(aot ? ']]' : ']', i)) fail('expected ] to close a table header'); + i += aot ? 2 : 1; + current = tableAt(kp, aot); + endOfLine(); + continue; + } + const kp = keyPath(); ws(); + if (peek() !== '=') fail('expected = after a key'); + i++; + assign(current, kp, value()); + endOfLine(); + } + return root; +} + +// ── the detector ───────────────────────────────────────────────────────────────────────────── + +export interface LayerFs { + statSync: (p: string) => { isDirectory(): boolean; isFile(): boolean }; + readFileSync: (p: string, e: 'utf8') => string; + readdirSync: (p: string) => string[]; +} +const realFs: LayerFs = { + statSync: (p) => nodeFs.statSync(p), + readFileSync: (p, e) => nodeFs.readFileSync(p, e), + readdirSync: (p) => nodeFs.readdirSync(p) +}; + +/** One thing a layer carries. `what` is an id the window words; `detail` is data + * (server or hook names, a config key, or the error as given). An entry of agents/ + * or hooks/ is one item per file; past CODEX_FOLDER_LIST_MAX, one more item names + * the folder and counts the rest (`more`). */ +export interface CodexLayerItem { + file: string; + what: 'mcp-servers' | 'hooks' | 'rules' | 'agents' | 'setting' | 'unreadable' | 'does-not-parse' | 'confinement'; + detail?: string; + more?: number; +} + +export interface CodexLayer { + /** The `.codex` folder of this layer. */ + dotCodex: string; + /** Linked worktree: the main checkout's `.codex` its hooks come from. */ + hooksFrom?: string; + items: CodexLayerItem[]; +} + +export interface CodexLayerReport { + layers: CodexLayer[]; + /** What could not be checked (an unreadable folder, an unresolvable worktree). */ + unknown: string[]; + /** The folder a person means by "this project": the cwd, or for a linked worktree the same + * path in its main checkout (so one OK covers every worktree of that project). */ + projectFolder: string; +} + +export interface CodexLayerInput { + cwd: string; + /** The agent's CODEX_HOME (a `.codex` equal to it is not a project layer). */ + codexHome: string; + /** The person's own codex home (default ~/.codex). */ + userCodexHome?: string; + platform?: NodeJS.Platform; + fs?: LayerFs; +} + +/** The only config.toml keys the agent's own --sandbox and --ask-for-approval override. */ +const CONFINE_KEYS = ['sandbox_mode', 'approval_policy']; +/** The most entries of agents/ or hooks/ listed one by one. */ +export const CODEX_FOLDER_LIST_MAX = 20; +const stripVerbatim = (p: string): string => p.replace(/^\\\\\?\\(?!UNC\\)/, ''); +const asciiLower = (s: string): string => s.replace(/[A-Z]/g, (c) => c.toLowerCase()); + +/** A folder's key: resolved, and on Windows ASCII-lowercased (as Codex keys a project). */ +export function codexFolderKey(p: string, platform: NodeJS.Platform = process.platform): string { + const P = platform === 'win32' ? win32 : posix; + const r = P.resolve(stripVerbatim(p)); + return platform === 'win32' ? asciiLower(r) : r; +} + +const sameKey = (a: string, b: string, platform: NodeJS.Platform): boolean => codexFolderKey(a, platform) === codexFolderKey(b, platform); +const names = (v: unknown): string => (isTable(v) ? Object.keys(v).join(', ') : ''); + +/** The project `.codex` layers this cwd could load, and what each carries. Never throws: + * whatever cannot be read or resolved is reported (as `unreadable` or in `unknown`). */ +export function codexProjectLayers(input: CodexLayerInput): CodexLayerReport { + const platform = input.platform ?? process.platform; + const fs = input.fs ?? realFs; + const P = platform === 'win32' ? win32 : posix; + const unknown: string[] = []; + /** null: not there; 'error': there but not readable. */ + const stat = (p: string): { dir: boolean; file: boolean } | null | 'error' => { + try { const st = fs.statSync(p); return { dir: st.isDirectory(), file: st.isFile() }; } + catch (e) { + const code = (e as NodeJS.ErrnoException)?.code; + return code === 'ENOENT' || code === 'ENOTDIR' ? null : 'error'; + } + }; + const read = (p: string): { text: string | null; error: string | null } => { + try { return { text: fs.readFileSync(p, 'utf8'), error: null }; } + catch (e) { + return (e as NodeJS.ErrnoException)?.code === 'ENOENT' ? { text: null, error: null } : { text: null, error: (e as Error).message }; + } + }; + + const cwd = P.resolve(stripVerbatim(input.cwd)); + const ancestors: string[] = []; + for (let d = cwd; ; d = P.dirname(d)) { ancestors.push(d); if (P.dirname(d) === d) break; } + + // The git checkout root, and the main checkout of a linked worktree. + let checkoutRoot: string | null = null; + for (const a of ancestors) { + const st = stat(P.join(a, '.git')); + if (st === 'error') { unknown.push(`${P.join(a, '.git')} could not be read`); continue; } + if (!st) continue; + if (st.dir) { const h = stat(P.join(a, '.git', 'HEAD')); if (h === 'error' || !h) continue; } + checkoutRoot = a; break; + } + let repoRoot: string | null = checkoutRoot; + if (checkoutRoot) { + const dotGit = P.join(checkoutRoot, '.git'); + const st = stat(dotGit); + if (st && st !== 'error' && st.file) { + // A linked worktree: `.git` holds `gitdir:
/.git/worktrees/`, whose + // `commondir` points at the main `.git`. Unresolvable -> fail closed. + repoRoot = null; + try { + const m = /^gitdir:\s*(.+?)\s*$/m.exec(fs.readFileSync(dotGit, 'utf8')); + if (!m) throw new Error('no gitdir line'); + const gitDir = P.resolve(checkoutRoot, m[1]); + const common = P.resolve(gitDir, fs.readFileSync(P.join(gitDir, 'commondir'), 'utf8').trim()); + if (P.basename(P.dirname(gitDir)) !== 'worktrees' || P.basename(common) !== '.git') throw new Error('not a worktrees/ gitdir'); + repoRoot = P.dirname(common); + } catch (e) { + unknown.push(`the linked worktree at ${checkoutRoot} could not be resolved to its main checkout (${(e as Error).message}); its hooks come from there`); + } + } + } + const linked = !!(checkoutRoot && repoRoot && !sameKey(checkoutRoot, repoRoot, platform)); + + const own = [input.codexHome, input.userCodexHome ?? P.join(homedir(), '.codex')]; + const hooksOf = (dir: string, cfg: TomlTable | null, items: CodexLayerItem[]): void => { + if (cfg && 'hooks' in cfg) items.push({ file: P.join(dir, 'config.toml'), what: 'hooks', detail: names(cfg.hooks) }); + const hj = P.join(dir, 'hooks.json'); + const r = read(hj); + if (r.error) { items.push({ file: hj, what: 'unreadable', detail: r.error }); return; } + if (r.text === null) return; + let parsed: unknown; + try { parsed = JSON.parse(r.text); } catch (e) { items.push({ file: hj, what: 'does-not-parse', detail: (e as Error).message }); return; } + const hooks = isTable(parsed) ? parsed.hooks : undefined; + const empty = isTable(parsed) && Object.keys(parsed).length === 0; + const noHooks = isTable(hooks) && Object.values(hooks).every((v) => Array.isArray(v) && v.length === 0); + if (!empty && !noHooks) items.push({ file: hj, what: 'hooks', detail: names(hooks) }); + }; + const configOf = (dir: string, items: CodexLayerItem[]): TomlTable | null => { + const file = P.join(dir, 'config.toml'); + const r = read(file); + if (r.error) { items.push({ file, what: 'unreadable', detail: r.error }); return null; } + if (r.text === null) return null; + try { return parseToml(r.text); } catch (e) { items.push({ file, what: 'does-not-parse', detail: (e as Error).message }); return null; } + }; + /** Every entry of a folder Codex loads code from (agents/, hooks/); an unreadable one counts. */ + const entriesOf = (folder: string, what: 'agents' | 'hooks', items: CodexLayerItem[]): void => { + const st = stat(folder); + if (st === 'error') { items.push({ file: folder, what: 'unreadable' }); return; } + if (!st || !st.dir) return; + let list: string[]; + try { list = fs.readdirSync(folder).slice().sort(); } + catch (e) { items.push({ file: folder, what: 'unreadable', detail: (e as Error).message }); return; } + for (const n of list.slice(0, CODEX_FOLDER_LIST_MAX)) items.push({ file: P.join(folder, n), what }); + if (list.length > CODEX_FOLDER_LIST_MAX) items.push({ file: folder, what, more: list.length - CODEX_FOLDER_LIST_MAX }); + }; + + const layers: CodexLayer[] = []; + for (const dir of ancestors.slice().reverse()) { + const dotCodex = P.join(dir, '.codex'); + const st = stat(dotCodex); + const items: CodexLayerItem[] = []; + // Linked worktree: the main checkout's matching `.codex` gives this folder's hooks, + // whether or not the worktree has its own copy. + let hooksFrom: string | undefined; + if (linked && checkoutRoot && repoRoot) { + const rel = P.relative(checkoutRoot, dir); + if (!rel.startsWith('..') && !P.isAbsolute(rel)) hooksFrom = P.join(repoRoot, rel, '.codex'); + } + const ownLayer = st === 'error' || (st !== null && st.dir); + if (ownLayer && !own.some((h) => sameKey(dotCodex, h, platform))) { + if (st === 'error') items.push({ file: dotCodex, what: 'unreadable' }); + else { + const cfg = configOf(dotCodex, items); + const cfgFile = P.join(dotCodex, 'config.toml'); + // hooks is listed by hooksOf below; every other key is named here. + for (const k of Object.keys(cfg ?? {})) { + if (k === 'mcp_servers') items.push({ file: cfgFile, what: 'mcp-servers', detail: names(cfg!.mcp_servers) }); + else if (CONFINE_KEYS.includes(k)) items.push({ file: cfgFile, what: 'confinement', detail: k }); + else if (k !== 'hooks') items.push({ file: cfgFile, what: 'setting', detail: k }); + } + const rulesDir = P.join(dotCodex, 'rules'); + const rst = stat(rulesDir); + if (rst === 'error') items.push({ file: rulesDir, what: 'unreadable' }); + else if (rst && rst.dir) { + try { + for (const n of fs.readdirSync(rulesDir)) if (n.endsWith('.rules')) items.push({ file: P.join(rulesDir, n), what: 'rules' }); + } catch (e) { items.push({ file: rulesDir, what: 'unreadable', detail: (e as Error).message }); } + } + entriesOf(P.join(dotCodex, 'agents'), 'agents', items); + // A worktree's own hooks count too: which copy Codex reads differs by version. + hooksOf(dotCodex, cfg, items); + entriesOf(P.join(dotCodex, 'hooks'), 'hooks', items); + } + } + if (hooksFrom && !own.some((h) => sameKey(hooksFrom!, h, platform))) { + const hst = stat(hooksFrom); + if (hst === 'error') items.push({ file: hooksFrom, what: 'unreadable' }); + else if (hst && hst.dir) { + hooksOf(hooksFrom, configOf(hooksFrom, items), items); + entriesOf(P.join(hooksFrom, 'hooks'), 'hooks', items); + } + } + if (items.length) layers.push({ dotCodex, ...(hooksFrom ? { hooksFrom } : {}), items }); + } + let projectFolder = cwd; + if (linked && checkoutRoot && repoRoot) { + const rel = P.relative(checkoutRoot, cwd); + if (!rel.startsWith('..') && !P.isAbsolute(rel)) projectFolder = rel ? P.join(repoRoot, rel) : repoRoot; + } + return { layers, unknown, projectFolder }; +} + +// ── the hold ───────────────────────────────────────────────────────────────────────────────── + +/** True when the person allowed this folder (by its key, spelled as in the main checkout). */ +export function isCodexFolderOk(folder: string, oks: readonly string[] | undefined, platform: NodeJS.Platform = process.platform): boolean { + const key = codexFolderKey(folder, platform); + return (oks ?? []).some((k) => typeof k === 'string' && codexFolderKey(k, platform) === key); +} + +/** Why a Codex start is held: the layers that carry code or command policy, or what + * could not be checked. */ +export interface CodexHold { + /** The key one OK records: the project folder, spelled as in the main checkout. */ + folderKey: string; + folder: string; + layers: CodexLayer[]; + unknown: string[]; +} + +/** Hold when a layer carries anything but sandbox_mode and approval_policy (code, any + * other config key, an entry of agents/ or hooks/, a file that cannot be read or does + * not parse), or something could not be checked, unless the folder is allowed. A + * config.toml with those two keys alone starts (the agent's own --sandbox and + * --ask-for-approval win). */ +export function codexHold(report: CodexLayerReport, oks: readonly string[] | undefined, platform: NodeJS.Platform = process.platform): CodexHold | null { + const layers = report.layers + .map((l) => ({ ...l, items: l.items.filter((it) => it.what !== 'confinement') })) + .filter((l) => l.items.length > 0); + if (!layers.length && !report.unknown.length) return null; + if (isCodexFolderOk(report.projectFolder, oks, platform)) return null; + return { folderKey: codexFolderKey(report.projectFolder, platform), folder: report.projectFolder, layers, unknown: report.unknown }; +} + +/** What the window shows for a held Codex start. */ +export interface CodexHoldNotice extends CodexHold { + agentId: string; + at: number; +} diff --git a/src/main/config.ts b/src/main/config.ts index f66c854c1..7806e234c 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -12,6 +12,10 @@ import { import { defaultMcpDefaults } from '../shared/mcpCatalog'; import { MAX_AGENT_TOKEN_CAP } from '../shared/tokenCaps'; import { expandTilde, normalizeHiveHome } from './fs'; +import { codexFolderKey } from './codexProjectLayers'; +import { isProjectThemeId, isStoredProjectThemeId, projectThemeForHome, projectThemeHomeKey, resolveProjectTheme, type ProjectThemeId, type ProjectThemeSave } from '../shared/projectTheme'; +import { readJsonFile, writeRefusal } from './damagedFile'; +import { parseGraphicsMode, type GraphicsMode } from '../shared/graphicsMode'; import type { IntegrationRecord } from '../shared/integrations'; import { DEFAULT_CONTEXT_TRIGGER, @@ -314,10 +318,22 @@ export interface HarnessConfig { * destructive switch flow). Default false = the picker is hidden and the * office renders as today (zero behavior change). */ tvShowOffices?: boolean; - /** Which office map/cast theme the pixel office renders. Only honored when - * `tvShowOffices` is on; otherwise the office theme is used. Unbuilt show - * themes fall back to 'office' in the loader. */ - officeTheme?: 'office' | 'friends' | 'brooklyn99' | 'siliconvalley' | 'got' | 'hogwarts'; + /** The floor theme of a project with no entry in `officeThemeByHive`: the one + * global theme from before themes were per project. The built-in TV-show maps + * are honored only while `tvShowOffices` is on; unbuilt show themes fall back + * to 'office' in the loader. */ + officeTheme?: ProjectThemeId; + /** Each project's own floor theme (shared/projectTheme), keyed by its home + * folder (projectThemeHomeKey). Set through setProjectThemeForHome only. */ + officeThemeByHive?: Record; + /** The folders the person allowed to start hive Codex agents although their + * project `.codex` carries code (codexProjectLayers), by codexFolderKey, spelled + * as in the main checkout so one OK covers its worktrees. Set through + * setCodexFolderOk only. */ + codexLayerOptIns?: string[]; + /** Settings, Graphics (shared/graphicsMode): Auto, GPU or CPU only. Read once + * before app ready; a change applies after a restart. Absent is Auto. */ + graphicsMode?: GraphicsMode; /** Per-CLI-provider local/self-hosted base URL (Ollama/LM Studio/vLLM, …) for the * OpenCode/Crush/pi/qwen engines; applied at spawn (config-injection or proxy * upstream). API KEYS are NOT stored here — they live write-only in the secret @@ -585,16 +601,24 @@ function migrateTriggersV1(cfg: HarnessConfig): HarnessConfig { } } +/** This run's last config read whole (a copy), for a config.json that is + * damaged or cannot be read just now (damagedFile). */ +let lastGoodConfig: HarnessConfig | null = null; + export function readConfig(): HarnessConfig { const p = configPath(); + const read = readJsonFile(p, lastGoodConfig === null); // No file yet = a first run with nothing to migrate; the defaults ARE the // post-migration shape. Deliberately does not persist — a bare read must not // conjure a config.json before onboarding has written one. - if (!existsSync(p)) return withTriggerDefaults({ ...DEFAULTS }); + if (read.kind === 'missing') return withTriggerDefaults({ ...DEFAULTS }); + // Damaged (kept as it is, writes paused) or not readable just now: this run's + // last good config, never the defaults over the person's settings. + if (read.kind !== 'ok') return lastGoodConfig ? structuredClone(lastGoodConfig) : withTriggerDefaults({ ...DEFAULTS }); try { - const raw = readFileSync(p, 'utf8'); - const parsed = JSON.parse(raw); - return normalizeStoredHomes(migrateTriggersV1(withTriggerDefaults({ ...DEFAULTS, ...parsed }))); + const cfg = normalizeStoredHomes(migrateTriggersV1(withTriggerDefaults({ ...DEFAULTS, ...read.value }))); + lastGoodConfig = structuredClone(cfg); + return cfg; } catch { return withTriggerDefaults({ ...DEFAULTS }); } @@ -620,6 +644,16 @@ function normalizeStoredHomes(cfg: HarnessConfig): HarnessConfig { .map((h) => expandTilde(h)) .filter((h) => (seen.has(h) ? false : (seen.add(h), true))); } + if (cfg.officeThemeByHive && typeof cfg.officeThemeByHive === 'object' && !Array.isArray(cfg.officeThemeByHive)) { + const normalized: Record = {}; + // An unregistered pack id is kept (the pack may be renamed or dropped in this build); only garbage is dropped. + for (const [home, theme] of Object.entries(cfg.officeThemeByHive)) { + if (home.trim() && isStoredProjectThemeId(theme)) normalized[projectThemeHomeKey(expandTilde(home))] = theme; + } + cfg.officeThemeByHive = normalized; + } else { + delete cfg.officeThemeByHive; + } return cfg; } @@ -638,6 +672,14 @@ export function onConfigWritten(listener: ConfigWriteListener): () => void { function persistConfig(next: HarnessConfig): HarnessConfig { const p = configPath(); + // A damaged config.json, or one that cannot be read just now, is never + // written over: the change is skipped and logged (once per spell), so a timer + // or an agent start carries on. + const refused = writeRefusal(p); + if (refused) { + if (refused.logged) console.error(`[config] not saved: config.json ${refused.why === 'damaged' ? `is damaged${refused.copy ? ` (copy: ${refused.copy})` : ''}` : `could not be read just now (${refused.error})`}; nothing was written`); + return readConfig(); + } mkdirSync(dirname(p), { recursive: true }); // Temp + rename: `rename` is atomic within a filesystem, so a crash mid-write // leaves either the old config.json or the new one, never half of either. A @@ -728,6 +770,46 @@ export function setAgentTokenCap(agentId: unknown, tokenCap: unknown): HarnessCo }); } +/** Save the Graphics mode; it applies after a restart. An unknown value throws. */ +export function setGraphicsMode(value: unknown): HarnessConfig { + const mode = parseGraphicsMode(value); + if (mode === null) throw new Error('invalid graphics mode'); + return persistConfig({ ...readConfig(), graphicsMode: mode }); +} + +/** Allow (or withdraw) one folder for hive Codex starts, then read it back. Withdraw + * removes that folder's key. `saved` is false when config.json is damaged or not + * readable just now (the change was skipped). */ +export function setCodexFolderOk(folder: string, allow: boolean): { saved: boolean; oks: string[] } { + const current = readConfig(); + const key = codexFolderKey(expandTilde(folder)); + const list = (Array.isArray(current.codexLayerOptIns) ? current.codexLayerOptIns : []) + .filter((k): k is string => typeof k === 'string' && !!k.trim() && codexFolderKey(k) !== key); + if (allow) list.push(key); + persistConfig({ ...current, codexLayerOptIns: list }); + const oks = (readConfig().codexLayerOptIns ?? []).filter((k): k is string => typeof k === 'string'); + return { saved: oks.some((k) => codexFolderKey(k) === key) === allow, oks }; +} + +/** Set one project's floor theme against the latest config on disk (the + * renderer's config is a snapshot), then read it back. A damaged config.json, or + * one not readable just now, skips the save: the answer is 'not-saved', with the + * theme still in force. */ +export function setProjectThemeForHome(harnessHome: string, theme: unknown): ProjectThemeSave { + const home = expandTilde(harnessHome); + const current = readConfig(); + if (!isProjectThemeId(theme)) return { ok: false, reason: 'invalid', theme: resolveProjectTheme(current, home) }; + const key = projectThemeHomeKey(home); + const map = { ...(current.officeThemeByHive ?? {}) }; + for (const k of Object.keys(map)) if (projectThemeHomeKey(k) === key) delete map[k]; + map[key] = theme; + persistConfig({ ...current, officeThemeByHive: map }); + const after = readConfig(); + return projectThemeForHome(after.officeThemeByHive, home) === theme + ? { ok: true, theme } + : { ok: false, reason: 'not-saved', theme: resolveProjectTheme(after, home) }; +} + /** Wipe the persisted config back to first-run defaults so the app boots into * onboarding again. Used by the "reset & start over" flow. */ export function resetConfig(): HarnessConfig { diff --git a/src/main/damagedFile.ts b/src/main/damagedFile.ts new file mode 100644 index 000000000..1f16a076b --- /dev/null +++ b/src/main/damagedFile.ts @@ -0,0 +1,145 @@ +/** + * A damaged source of truth is kept, never replaced. config.json and the + * hive's registry.json and tasks.json are read through readJsonFile: + * + * - DAMAGED: the file does not parse (or is not a JSON object). It is kept as + * it is, the text that was judged is copied aside once per spell of damage + * as `.damaged-YYYY-MM-DD-HHMM` in local time (then seconds, then + * milliseconds, if that name is taken; a copy is never overwritten), writes + * to it pause, and integrityIssues() names it for the window's banner. + * - UNREADABLE: it cannot be read just now (busy, locked). That is no damage: + * no copy, and the caller keeps this run's last good value (a copy). With + * none yet (start up) it is read 3 more times, 50 ms apart, once per spell + * of the error. + * + * A spell ends when the file reads again, or is gone. + * + * Ported from Prozzta/guppyos 02d0d50b (MIT): the fork keeps a copy of a + * config.json that does not parse and pauses writes to it. + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { basename, dirname, join } from 'node:path'; + +export type JsonRead = + | { kind: 'ok'; value: Record } + | { kind: 'missing' } + | { kind: 'damaged'; error: string; copy: string | null } + | { kind: 'unreadable'; error: string }; + +/** One damaged file, as the window may see it: names only, never a path. */ +export interface IntegrityIssue { + file: string; + /** The copy's name beside it, or null when no copy could be made. */ + copy: string | null; + error: string; +} + +interface Held extends IntegrityIssue { path: string; copyPath: string | null } + +const held = new Map(); +/** Files read patiently in the current spell of a read error. */ +const waited = new Set(); +/** Files whose skipped write was logged in the current spell. */ +const skipLogged = new Set(); + +const errText = (e: unknown): string => (e instanceof Error ? e.message : String(e)); +const pad = (n: number, w = 2) => String(n).padStart(w, '0'); + +function waitSync(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +/** The copy's time stamp: minutes, then seconds, then milliseconds. */ +function stamp(d: Date, detail: number): string { + const base = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`; + if (detail === 0) return base; + return detail === 1 ? `${base}${pad(d.getSeconds())}` : `${base}${pad(d.getSeconds())}${pad(d.getMilliseconds(), 3)}`; +} + +/** Copy the judged text beside the file under a name that is not taken. */ +function copyAside(p: string, raw: string): string | null { + const d = new Date(); + for (let detail = 0; detail < 3; detail++) { + const copyPath = join(dirname(p), `${basename(p)}.damaged-${stamp(d, detail)}`); + try { + writeFileSync(copyPath, raw, { encoding: 'utf8', flag: 'wx' }); + return copyPath; + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== 'EEXIST') { + console.error(`[integrity] could not copy ${basename(p)} aside:`, errText(e)); + return null; + } + } + } + return null; +} + +function healthy(p: string): void { + if (held.delete(p)) console.log(`[integrity] ${basename(p)} reads again; writes to it resume`); + waited.delete(p); + skipLogged.delete(p); +} + +function readOnce(p: string): JsonRead { + let raw: string; + try { raw = readFileSync(p, 'utf8'); } catch (e) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') { healthy(p); return { kind: 'missing' }; } + return { kind: 'unreadable', error: errText(e) }; + } + let error: string; + try { + const value: unknown = JSON.parse(raw); + if (value && typeof value === 'object' && !Array.isArray(value)) { + healthy(p); + return { kind: 'ok', value: value as Record }; + } + error = 'not a JSON object'; + } catch (e) { error = errText(e); } + const known = held.get(p); + // Once per spell: the first damaged read keeps the text it judged. + const copyPath = known ? known.copyPath : copyAside(p, raw); + const copy = copyPath ? basename(copyPath) : null; + if (!known) console.error(`[integrity] ${basename(p)} is damaged (${error}); kept as it is${copy ? `, copied to ${copy}` : ''}; writes to it pause until it reads again`); + held.set(p, { path: p, file: basename(p), copyPath, copy, error }); + waited.delete(p); + return { kind: 'damaged', error, copy }; +} + +/** Read one JSON source of truth. `patient`: the caller has no last good value + * yet, so an unreadable file is read 3 more times, 50 ms apart (once per spell). */ +export function readJsonFile(p: string, patient = false): JsonRead { + let r = readOnce(p); + if (r.kind === 'unreadable' && patient && !waited.has(p)) { + waited.add(p); + for (let i = 0; i < 3 && r.kind === 'unreadable'; i++) { waitSync(50); r = readOnce(p); } + } + return r; +} + +/** Whether a write to `p` must be skipped now: it is damaged, or cannot be read + * just now (what is on disk is unknown). `logged` is true the first time in a + * spell, so a caller logs a skipped write once. */ +export function writeRefusal(p: string): { why: 'damaged' | 'read error'; error: string; copy: string | null; logged: boolean } | null { + const r = readJsonFile(p); + if (r.kind !== 'damaged' && r.kind !== 'unreadable') return null; + const logged = !skipLogged.has(p); + skipLogged.add(p); + return r.kind === 'damaged' + ? { why: 'damaged', error: r.error, copy: r.copy, logged } + : { why: 'read error', error: r.error, copy: null, logged }; +} + +/** Damaged files, for the window's banner. */ +export function integrityIssues(): IntegrityIssue[] { + return [...held.values()].map(({ file, copy, error }) => ({ file, copy, error })); +} + +/** The path behind a listed name (a damaged file or its copy), or null: the + * window names a file, it never passes a path. */ +export function integrityPath(name: string): string | null { + for (const h of held.values()) { + if (name === h.file) return h.path; + if (h.copyPath && name === h.copy) return h.copyPath; + } + return null; +} diff --git a/src/main/fs.ts b/src/main/fs.ts index e79198e20..0b763a9ac 100644 --- a/src/main/fs.ts +++ b/src/main/fs.ts @@ -364,3 +364,14 @@ export async function statAbs(p: string): Promise<{ exists: boolean; isFile: boo return { exists: false, isFile: false, path: abs }; } } + +/** P2: whether a new project may start in `dir`. Missing, or holding nothing but + * .DS_Store, is 'empty'; anything else in it is 'not-empty'; a path that cannot be + * read as a folder (a file, no permission) is 'unreadable'. */ +export async function newProjectFolderState(dir: string): Promise<'empty' | 'not-empty' | 'unreadable'> { + try { + return (await readdir(dir)).some((n) => n !== '.DS_Store') ? 'not-empty' : 'empty'; + } catch (e) { + return (e as NodeJS.ErrnoException)?.code === 'ENOENT' ? 'empty' : 'unreadable'; + } +} diff --git a/src/main/git.ts b/src/main/git.ts index 2257567f6..e4b9be07d 100644 --- a/src/main/git.ts +++ b/src/main/git.ts @@ -1,4 +1,6 @@ import { spawn } from 'node:child_process'; +import { promises as fsp } from 'node:fs'; +import { join } from 'node:path'; import { openForRead, safeResolve } from './fs'; /** Run git in `cwd` with `args`. Returns stdout text or an error. */ @@ -272,14 +274,116 @@ export async function addWorktree( return { ok: false, error: fallback.error }; } -/** Best-effort removal of an agent's worktree. Forced so a dirty tree doesn't - * block teardown; failures are surfaced but callers may ignore them. */ +/** After a held start's worktree is taken back: delete the branch the app made for + * it, so the next start gets its own worktree again. `branch` is the worktree's + * branch, read before the take back. Deleted only when it is the app's name for + * that path, is not the base, and has nothing ahead of the base, counted against + * the base and not HEAD (a detached main checkout's HEAD is not the base). A count + * git cannot give keeps the branch. */ +export async function dropHeldAgentBranch( + cwd: string, wtPath: string, branch: string | null, baseBranch: string +): Promise<{ deleted: boolean; error?: string }> { + if (!branch || branch !== agentBranchFor(wtPath) || branch === baseBranch) return { deleted: false }; + const ahead = await runGit(cwd, ['rev-list', '--count', `${baseBranch}..${branch}`, '--']); + if (!ahead.ok) return { deleted: false, error: ahead.error }; + if (ahead.stdout.trim() !== '0') return { deleted: false }; + const del = await runGit(cwd, ['branch', '-D', branch]); + return del.ok ? { deleted: true } : { deleted: false, error: del.error }; +} + +/** The two removals unlinkLinksUnder uses, and how it reads (injectable for tests). */ +export interface LinkOps { + unlink: (p: string) => Promise; + rmdir: (p: string) => Promise; + readdir?: (p: string) => Promise; + lstat?: (p: string) => Promise<{ isSymbolicLink(): boolean; isDirectory(): boolean }>; +} +const fsLinkOps: LinkOps = { unlink: (p) => fsp.unlink(p), rmdir: (p) => fsp.rmdir(p) }; +const errText = (e: unknown): string => e instanceof Error ? e.message : String(e); +const gone = (e: unknown): boolean => (e as NodeJS.ErrnoException)?.code === 'ENOENT'; + +/** Remove every symlink or junction under `root` as a LINK, never through it. + * A junction is a folder link on Windows: unlink can refuse it, and a + * NON-recursive rmdir then removes only the link. A folder or entry that cannot + * be read may hold links, so it is a failure (the removal is refused), unless + * it is already gone. Ported from Prozzta/guppyos 02d0d50b (MIT). */ +export async function unlinkLinksUnder( + root: string, ops: LinkOps = fsLinkOps +): Promise<{ removed: number; failed: { path: string; error: string }[] }> { + let removed = 0; + const failed: { path: string; error: string }[] = []; + const readdir = ops.readdir ?? ((p: string) => fsp.readdir(p)); + const lstat = ops.lstat ?? ((p: string) => fsp.lstat(p)); + const stack = [root]; + while (stack.length) { + const dir = stack.pop()!; + let names: string[] = []; + try { names = await readdir(dir); } catch (e) { if (!gone(e)) failed.push({ path: dir, error: errText(e) }); continue; } + for (const name of names) { + const p = join(dir, name); + let st; + try { st = await lstat(p); } catch (e) { if (!gone(e)) failed.push({ path: p, error: errText(e) }); continue; } + if (st.isSymbolicLink()) { + try { await ops.unlink(p); removed++; } catch { + try { await ops.rmdir(p); removed++; } catch (e) { failed.push({ path: p, error: errText(e) }); } + } + } else if (st.isDirectory()) stack.push(p); + } + } + return { removed, failed }; +} + +/** A folder as the file system resolves it (case folded on Windows), or null. */ +async function realKey(p: string): Promise { + try { + const r = await fsp.realpath(p); + return process.platform === 'win32' ? r.toLowerCase() : r; + } catch { return null; } +} + +/** Why `wtPath` must not be force removed as a worktree of the repository at + * `cwd`, or null when it may be. It has to be a real folder (not itself a link, + * and readable) that git lists as a LINKED worktree, never the main checkout. + * A path already gone is left to git, as before. */ +async function removalRefusal(cwd: string, wtPath: string): Promise { + let st; + try { st = await fsp.lstat(wtPath); } catch (e) { return gone(e) ? null : `it cannot be read (${errText(e)})`; } + if (st.isSymbolicLink()) return 'it is itself a link'; + if (!st.isDirectory()) return 'it is not a folder'; + const listed = await listWorktrees(cwd); + if ('error' in listed) return `git could not list the worktrees (${listed.error})`; + const want = await realKey(wtPath); + if (want === null) return 'it cannot be resolved'; + // The first entry git lists is the main checkout. + for (const w of listed.slice(1)) if ((await realKey(w.path)) === want) return null; + return 'git does not list it as a linked worktree of this repository'; +} + +/** Removal of an agent's worktree. Forced so a dirty tree doesn't block + * teardown; failures are surfaced but callers may ignore them. + * Git for Windows' `worktree remove --force` deletes THROUGH a junction (a + * linked node_modules was emptied that way), so every link inside goes first, + * as a link. A path that is not a linked worktree of this repository, or a link + * that cannot be removed, REFUSES the removal (`refused`): the error counts the + * links already removed and no file or folder is deleted. */ export async function removeWorktree( - cwd: string, wtPath: string -): Promise<{ ok: boolean; error?: string }> { + cwd: string, wtPath: string, ops?: LinkOps +): Promise<{ ok: boolean; error?: string; refused?: boolean; linksRemoved?: number }> { + const why = await removalRefusal(cwd, wtPath); + if (why) { + return { ok: false, refused: true, linksRemoved: 0, error: `refused: ${wtPath} was not removed because ${why}; nothing was touched` }; + } + const links = await unlinkLinksUnder(wtPath, ops); + if (links.failed.length) { + const first = links.failed[0]; + return { + ok: false, refused: true, linksRemoved: links.removed, + error: `refused: ${links.removed} link(s) removed as links, but ${links.failed.length} link(s) or folder(s) inside could not be removed or read, and --force would delete through them (${first.path}: ${first.error}); no file or folder was deleted` + }; + } const res = await runGit(cwd, ['worktree', 'remove', '--force', wtPath]); - if (res.ok) return { ok: true }; - return { ok: false, error: res.error }; + if (res.ok) return { ok: true, linksRemoved: links.removed }; + return { ok: false, error: res.error, linksRemoved: links.removed }; } /** Does this worktree hold work that must NOT be auto-discarded? `keep` is true if diff --git a/src/main/graphics.ts b/src/main/graphics.ts new file mode 100644 index 000000000..00e8d39df --- /dev/null +++ b/src/main/graphics.ts @@ -0,0 +1,131 @@ +/** + * Main's half of the Graphics setting (src/shared/graphicsMode.ts has the rules). + * + * The mode is read from config.json ONCE, at startup, before app ready: + * `disableHardwareAcceleration` only works before ready, and the floors' WebGL + * power preference is chosen when they first draw. The read is deliberately NOT + * readConfig: it is a plain read of one key, and anything unreadable is 'auto', + * so a damaged config.json never blocks the app and never forces software + * rendering. + * + * Ported from Prozzta/guppyos 02d0d50b (MIT). + */ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + graphicsModeFromConfigText, graphicsModeOf, hardwareCompositing, + type GraphicsMode, type GraphicsStatus +} from '../shared/graphicsMode'; + +/** The parts of Electron's `app` this needs (a fake in tests). */ +export interface GraphicsApp { + isReady(): boolean; + getPath(name: 'userData'): string; + disableHardwareAcceleration(): void; +} + +let applied: GraphicsMode = 'auto'; +let hardwareDisabled = false; +let appliedOnce = false; + +/** The mode saved in `/config.json`; 'auto' when the file is missing, unreadable or damaged. */ +export function readStartupGraphicsMode(userData: string, read: (p: string) => string = (p) => readFileSync(p, 'utf8')): GraphicsMode { + let text: string | null = null; + try { text = read(join(userData, 'config.json')); } catch { /* missing or unreadable: the default */ } + return graphicsModeFromConfigText(text); +} + +/** + * Apply the saved mode. MUST run at module load of main, before app ready. + * 'cpu' disables hardware acceleration; 'auto' and 'gpu' leave Chromium's GPU + * choice and blocklist alone ('gpu' only changes the floors' WebGL power + * preference). Too late (after ready) it applies nothing and says so: the run + * keeps Chromium's default, never a half applied mode. + */ +export function applyGraphicsModeAtStartup(app: GraphicsApp, read?: (p: string) => string): { mode: GraphicsMode; applied: boolean } { + if (appliedOnce) return { mode: applied, applied: true }; + const mode = readStartupGraphicsMode(app.getPath('userData'), read); + if (app.isReady()) { + console.error(`[graphics] the graphics mode "${mode}" was read after app ready: not applied this run`); + return { mode, applied: false }; + } + if (mode === 'cpu') { app.disableHardwareAcceleration(); hardwareDisabled = true; } + applied = mode; + appliedOnce = true; + return { mode, applied: true }; +} + +/** The mode this run started with. */ +export function appliedGraphicsMode(): GraphicsMode { return applied; } + +/** What the Settings indicator needs from main: the mode in force, the saved one, Chromium's view. */ +export function graphicsStatus(storedConfig: { graphicsMode?: unknown } | null | undefined, featureStatus: Record | null | undefined): GraphicsStatus { + return { + applied, + stored: graphicsModeOf(storedConfig), + hardwareDisabled, + hardwareCompositing: hardwareDisabled ? false : hardwareCompositing(featureStatus) + }; +} + +/** + * The startup 'graphics' row in the hive log, written once Chromium KNOWS. + * Until the GPU process has sent its info (the first `gpu-info-update`, a few + * hundred ms after ready), getGPUFeatureStatus() reads gpu_compositing + * 'disabled_software' in every mode, so a row written at ready would say + * software on every launch. CPU only is known at once; a GPU process that never + * reports is null (unknown), never false. A real fallback still reports false. + */ +export interface GraphicsRowApp { + getGPUFeatureStatus(): unknown; + on(event: 'gpu-info-update', fn: () => void): unknown; + once(event: 'gpu-info-update', fn: () => void): unknown; + removeListener(event: 'gpu-info-update', fn: () => void): unknown; +} +/** A type, not an interface: hive.appendLog takes a Record. */ +export type GraphicsRow = { + kind: 'graphics'; + mode: GraphicsMode; + applied: boolean; + hardwareCompositing: boolean | null; + /** Chromium's raw gpu_compositing string, so a fallback is told apart from a missing status. */ + gpuCompositing: string | null; +}; +export const GRAPHICS_ROW_TIMEOUT_MS = 10_000; + +let gpuReported = false; + +/** Note the GPU process's first report. MUST run at module load of main, before app ready. */ +export function watchGpuReports(app: Pick): void { + app.on('gpu-info-update', () => { gpuReported = true; }); +} + +function featureStatusOf(app: GraphicsRowApp): Record | null { + try { return app.getGPUFeatureStatus() as Record; } catch { return null; } +} + +/** Write the row once: now if Chromium already knows (CPU only, or the GPU + * process reported), else at its first report, else null at the timeout. */ +export function logGraphicsWhenKnown( + app: GraphicsRowApp, + startup: { mode: GraphicsMode; applied: boolean }, + append: (row: GraphicsRow) => void, + timers: { setTimer?: (fn: () => void, ms: number) => unknown; clearTimer?: (t: unknown) => void } = {} +): void { + const setTimer = timers.setTimer ?? ((fn, ms) => { const t = setTimeout(fn, ms); t.unref?.(); return t; }); + const clearTimer = timers.clearTimer ?? ((t) => clearTimeout(t as ReturnType)); + const write = (known: boolean): void => { + const status = featureStatusOf(app); + const raw = typeof status?.gpu_compositing === 'string' ? status.gpu_compositing : null; + const hw = hardwareDisabled ? false : known ? hardwareCompositing(status) : null; + try { append({ kind: 'graphics', mode: startup.mode, applied: startup.applied, hardwareCompositing: hw, gpuCompositing: raw }); } catch { /* the log is best-effort */ } + }; + if (hardwareDisabled || gpuReported) { write(true); return; } + let timer: unknown = null; + const onReport = (): void => { clearTimer(timer); write(true); }; + app.once('gpu-info-update', onReport); + timer = setTimer(() => { app.removeListener('gpu-info-update', onReport); write(false); }, GRAPHICS_ROW_TIMEOUT_MS); +} + +/** Tests only: forget what this module applied. */ +export function __resetGraphicsForTests(): void { applied = 'auto'; hardwareDisabled = false; appliedOnce = false; gpuReported = false; } diff --git a/src/main/historyNormalize.ts b/src/main/historyNormalize.ts new file mode 100644 index 000000000..1cd330bfd --- /dev/null +++ b/src/main/historyNormalize.ts @@ -0,0 +1,257 @@ +/** + * Pure normalisers from each provider's transcript record to the History tab's + * items. No fs, no Electron: one parsed JSON record in, 0..n items out. + * Ported from Prozzta/guppyos 02d0d50b (MIT). + * + * WHAT IS KEPT. User and assistant text, and each tool CALL as a single line (the tool's + * name and its key argument). WHAT IS NOT: tool output, thinking/reasoning, token counts, + * snapshots, injected context. The History tab is for reading what happened, and a raw + * output dump is exactly the noise the terminal already has too much of. + * + * Every text is capped here (HISTORY_TEXT_MAX / HISTORY_TOOL_MAX), so nothing downstream + * can be handed an unbounded string. + */ +import { + HISTORY_TEXT_MAX, + HISTORY_TOOL_MAX, + type HistoryItem, + type HistoryItemKind, + type HistoryProvider +} from '../shared/history'; + +type Rec = Record; + +const isRec = (v: unknown): v is Rec => typeof v === 'object' && v !== null && !Array.isArray(v); +const str = (v: unknown): string => (typeof v === 'string' ? v : ''); + +function toMs(v: unknown): number | null { + if (typeof v === 'number' && Number.isFinite(v)) return v > 1e12 ? v : v * 1000; + if (typeof v !== 'string' || !v) return null; + const t = Date.parse(v); + return Number.isFinite(t) ? t : null; +} + +/** Cap a message's text. Trims surrounding blank space; keeps inner newlines. */ +export function capText(s: string, max = HISTORY_TEXT_MAX): { text: string; truncated: boolean } { + const t = s.trim(); + if (t.length <= max) return { text: t, truncated: false }; + return { text: t.slice(0, max).trimEnd() + ' …', truncated: true }; +} + +/** One line: whitespace collapsed, capped. */ +export function oneLine(s: string, max = HISTORY_TOOL_MAX): { text: string; truncated: boolean } { + const t = s.replace(/\s+/g, ' ').trim(); + if (t.length <= max) return { text: t, truncated: false }; + return { text: t.slice(0, max - 1).trimEnd() + '…', truncated: true }; +} + +class Out { + readonly items: HistoryItem[] = []; + constructor(private readonly offset: number, private readonly at: number | null) {} + push(kind: HistoryItemKind, raw: string, tool = false): void { + const { text, truncated } = tool ? oneLine(raw) : capText(raw); + if (!text) return; + const item: HistoryItem = { id: `${this.offset}.${this.items.length}`, kind, at: this.at, text, offset: this.offset }; + if (truncated) item.truncated = true; + this.items.push(item); + } +} + +/** Argument keys that name what a tool call is ABOUT, most telling first. */ +const KEY_ARGS = [ + 'command', 'cmd', 'CommandLine', 'file_path', 'filePath', 'AbsolutePath', 'TargetFile', 'path', + 'notebook_path', 'pattern', 'Query', 'query', 'SearchPath', 'url', 'Url', 'skill', 'description', + 'prompt', 'subject', 'to', 'toolSummary' +]; + +/** A tool call's one-line summary: `name: key argument`. Arguments may arrive as an object, + * as a JSON string (Codex function_call), or with JSON-encoded string values (AGY). */ +export function toolSummary(name: string, args: unknown): string { + let a: unknown = args; + if (typeof a === 'string') { + try { a = JSON.parse(a); } catch { return a ? `${name}: ${a}` : name; } + } + if (!isRec(a)) return name; + const val = (v: unknown): string => { + if (typeof v === 'string') { + // AGY encodes each argument value as JSON text ("\"C:\\\\x\""): unwrap one level. + if (v.length > 1 && v.startsWith('"') && v.endsWith('"')) { + try { const p = JSON.parse(v); if (typeof p === 'string') return p; } catch { /* keep as is */ } + } + return v; + } + if (Array.isArray(v) && v.every((x) => typeof x === 'string')) return v.join(' '); + return ''; + }; + for (const k of KEY_ARGS) { + const v = val(a[k]); + if (v) return `${name}: ${v}`; + } + let json = ''; + try { json = JSON.stringify(a); } catch { /* circular: impossible from JSON.parse */ } + return json && json !== '{}' ? `${name}: ${json}` : name; +} + +// ─── Claude Code ───────────────────────────────────────────────────────────── + +/** Injected context, never something the user typed. */ +function stripClaudeInjected(s: string): string { + return s.replace(/[\s\S]*?<\/system-reminder>/g, '').trim(); +} + +export function normalizeClaude(rec: unknown, offset: number): HistoryItem[] { + if (!isRec(rec)) return []; + const out = new Out(offset, toMs(rec.timestamp)); + const type = rec.type; + if (type === 'system') { + if (rec.subtype === 'compact_boundary') out.push('system', 'Conversation compacted'); + return out.items; + } + if (type !== 'user' && type !== 'assistant') return []; + // Subagent (sidechain) turns and the compaction summary are not the visible conversation. + if (rec.isSidechain === true || rec.isMeta === true || rec.isCompactSummary === true) return []; + const msg = isRec(rec.message) ? rec.message : null; + if (!msg) return []; + const content = msg.content; + if (type === 'user') { + const texts: string[] = []; + if (typeof content === 'string') texts.push(content); + else if (Array.isArray(content)) { + for (const b of content) if (isRec(b) && b.type === 'text') texts.push(str(b.text)); + // tool_result blocks are tool OUTPUT: never shown. + } + for (const raw of texts) { + const t = stripClaudeInjected(raw); + if (!t) continue; + const cmd = /^([^<]*)<\/command-name>/.exec(t); + if (cmd) { out.push('system', cmd[1].trim()); continue; } + if (t.startsWith(' (isRec(c) ? str(c.text) : '')).filter(Boolean).join('\n'); +} + +export function normalizeCodex(rec: unknown, offset: number): HistoryItem[] { + if (!isRec(rec)) return []; + const p = isRec(rec.payload) ? rec.payload : null; + if (!p) return []; + const out = new Out(offset, toMs(rec.timestamp)); + if (rec.type === 'event_msg') { + if (p.type === 'item_completed' && isRec(p.item)) { + const it = p.item; + switch (it.type) { + case 'UserMessage': out.push('user', joinContent(it.content)); break; + case 'AgentMessage': out.push('assistant', joinContent(it.content)); break; + case 'CommandExecution': { + const parsed = Array.isArray(it.parsed_cmd) + ? it.parsed_cmd.map((c) => (isRec(c) ? str(c.cmd) : '')).filter(Boolean).join(' ; ') + : ''; + const argv = Array.isArray(it.command) ? it.command.filter((x): x is string => typeof x === 'string') : []; + const cmd = parsed || argv[argv.length - 1] || str(it.command); + const code = typeof it.exit_code === 'number' && it.exit_code !== 0 ? ` (exit ${it.exit_code})` : ''; + out.push('tool', `shell: ${cmd}${code}`, true); + break; + } + case 'FileChange': { + const files = isRec(it.changes) ? Object.keys(it.changes) : []; + out.push('tool', `edit: ${files.join(', ') || '(files)'}`, true); + break; + } + case 'McpToolCall': { + const name = [str(it.server), str(it.tool)].filter(Boolean).join('.') || 'mcp'; + out.push('tool', toolSummary(name, it.arguments), true); + break; + } + case 'Extension': { + if (it.kind === 'web.search') out.push('tool', `web search: ${str(it.query)}`, true); + else out.push('tool', str(it.kind) || 'extension', true); + break; + } + case 'ContextCompaction': out.push('system', 'Context compacted'); break; + // Reasoning, and anything unknown: not shown. + } + return out.items; + } + // Legacy rollouts (older Codex) wrote the conversation as these. + if (p.type === 'user_message') out.push('user', str(p.message)); + else if (p.type === 'agent_message') out.push('assistant', str(p.message)); + return out.items; + } + if (rec.type === 'response_item') { + // `message` items duplicate item_completed and carry injected developer/environment + // context, so they are skipped. Legacy tool calls are kept as one line. + if (p.type === 'function_call') out.push('tool', toolSummary(str(p.name) || 'tool', p.arguments), true); + else if (p.type === 'local_shell_call') { + const action = isRec(p.action) ? p.action : {}; + const argv = Array.isArray(action.command) ? action.command.filter((x): x is string => typeof x === 'string') : []; + out.push('tool', `shell: ${argv.join(' ')}`, true); + } + return out.items; + } + return []; +} + +// ─── Antigravity ───────────────────────────────────────────────────────────── + +function agyUserText(s: string): string { + const req = /([\s\S]*?)<\/USER_REQUEST>/.exec(s); + if (req) return req[1]; + return s + .replace(/[\s\S]*?<\/ADDITIONAL_METADATA>/g, '') + .replace(/[\s\S]*?<\/USER_SETTINGS_CHANGE>/g, ''); +} + +export function normalizeAgy(rec: unknown, offset: number): HistoryItem[] { + if (!isRec(rec)) return []; + const out = new Out(offset, toMs(rec.created_at)); + switch (rec.type) { + case 'USER_INPUT': out.push('user', agyUserText(str(rec.content))); break; + case 'PLANNER_RESPONSE': { + if (typeof rec.content === 'string') out.push('assistant', stripClaudeInjected(rec.content)); + if (Array.isArray(rec.tool_calls)) { + for (const tc of rec.tool_calls) { + if (!isRec(tc)) continue; + out.push('tool', toolSummary(str(tc.name) || 'tool', tc.args), true); + } + } + break; + } + case 'CHECKPOINT': out.push('system', 'Context checkpoint'); break; + // GENERIC (tool output) and SYSTEM_MESSAGE: not shown. + } + return out.items; +} + +const BY_PROVIDER: Record HistoryItem[]> = { + claude: normalizeClaude, + codex: normalizeCodex, + antigravity: normalizeAgy +}; + +/** Parse and normalise one transcript line. A malformed line yields nothing. */ +export function normalizeLine(provider: HistoryProvider, line: string, offset: number): HistoryItem[] { + if (!line) return []; + let rec: unknown; + try { rec = JSON.parse(line); } catch { return []; } + return BY_PROVIDER[provider](rec, offset); +} diff --git a/src/main/historyService.ts b/src/main/historyService.ts new file mode 100644 index 000000000..e66255a4b --- /dev/null +++ b/src/main/historyService.ts @@ -0,0 +1,256 @@ +/** + * The main side of the `hive:history` IPC: one bounded page of an agent's + * conversation, read from its CLI's own transcript. + * + * Resolves an agent's CURRENT transcript from its id alone (the window never + * names a path), reads one bounded page of it (historyTail) and normalises the + * lines (historyNormalize). Electron free: the facts it needs are injected. + * + * Sources, each used only where it resolves (links followed) inside that + * engine's own store: + * - Claude Code: the hook's transcript_path, else `projectDir(cwd)/.jsonl`, + * inside `/projects` (~/.claude, or CLAUDE_CONFIG_DIR). + * - Codex: the hook's transcript_path, else the rollout for the registry session + * id, inside the agent's own CODEX_HOME and the session folders it links to. + * Only an agent with NO session id falls back to the newest rollout: with one, + * another session's file is never shown. + * - Antigravity: the hook's transcript_path, else + * `~/.gemini/antigravity-cli/brain//.system_generated/logs/transcript.jsonl`, + * inside that brain folder. + * + * The resolved path is cached per agent and re-resolved when the session id or + * the hook path changes, or the file disappears; a miss is looked up again after + * 3 s, so a follow poll never walks the Codex folders on every tick. + * + * Ported from Prozzta/guppyos 02d0d50b (MIT). + */ +import { createHash } from 'node:crypto'; +import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + HISTORY_PAGE_DEFAULT, + HISTORY_PAGE_MAX, + type HistoryItem, + type HistoryPage, + type HistoryProvider +} from '../shared/history'; +import { normalizeLine } from './historyNormalize'; +import { readLinesBackward, readLinesForward, type LineRef, type TailLimits } from './historyTail'; +import { projectDir } from './transcript'; + +export interface HistoryAgentFacts { + provider?: string; + cwd?: string; + sessionId?: string; +} + +export interface HistoryDeps { + agent(agentId: string): HistoryAgentFacts | null; + transcriptPath(agentId: string): string | undefined; + codexHomeFor(agentId: string): string | null; + geminiHome(): string; +} + +const AGENT_ID = /^[A-Za-z0-9_.-]{1,128}$/; +const SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/; +/** How long a miss is kept before the transcript is looked up again. */ +export const HISTORY_MISS_MS = 3000; + +function isFile(p: string): boolean { + try { return statSync(p).isFile(); } catch { return false; } +} + +function real(p: string): string | null { + try { return realpathSync(p); } catch { return null; } +} + +/** Is `child` inside `dir`? Both as given. */ +function inside(dir: string, child: string): boolean { + const rel = path.relative(path.resolve(dir), path.resolve(child)); + return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel); +} + +/** `file` when it is a file that resolves (links followed) inside one of + * `roots` (each resolved too), else null. */ +function realInside(roots: string[], file: string | null | undefined): string | null { + if (!file || !isFile(file)) return null; + const f = real(file); + if (!f) return null; + return roots.some((r) => { const rr = real(r); return !!rr && inside(rr, f); }) ? file : null; +} + +/** Where a Claude Code transcript may live: `/projects`. */ +export function claudeProjectRoots(env: NodeJS.ProcessEnv = process.env): string[] { + const roots = [path.join(os.homedir(), '.claude', 'projects')]; + const cfg = env.CLAUDE_CONFIG_DIR?.trim(); + if (cfg) roots.push(path.join(cfg, 'projects')); + return roots; +} + +/** Where a hive Codex agent's rollouts may live: its own CODEX_HOME and the + * session folders it links to (installCodexHooks keeps them under the person's + * ~/.codex scan roots). */ +export function codexRoots(home: string): string[] { + return [home, path.join(home, 'sessions'), path.join(home, 'archived_sessions')]; +} + +/** The rollout for `sessionId` under `/sessions`, or null. */ +export function findCodexRollout(codexHome: string, sessionId: string): string | null { + if (!codexHome || !/^[0-9a-fA-F][0-9a-fA-F-]{15,}$/.test(sessionId)) return null; + const stack: Array<{ dir: string; depth: number }> = [{ dir: path.join(codexHome, 'sessions'), depth: 0 }]; + while (stack.length) { + const { dir, depth } = stack.pop()!; + let ents: import('node:fs').Dirent[]; + try { ents = readdirSync(dir, { withFileTypes: true }); } catch { continue; } + for (const e of ents) { + const p = path.join(dir, e.name); + if (e.isDirectory()) { if (depth < 4) stack.push({ dir: p, depth: depth + 1 }); continue; } + if (e.isFile() && e.name.endsWith('.jsonl') && e.name.includes(sessionId)) return p; + } + } + return null; +} + +/** The newest `sessions/YYYY/MM/DD/rollout-*.jsonl` under `codexHome`, or null. + * A fixed three level walk, so a stray folder cannot turn it into a scan of a home. */ +export function findNewestRollout(codexHome: string): string | null { + const root = path.join(codexHome, 'sessions'); + if (!existsSync(root)) return null; + let newest: { file: string; mtimeMs: number } | null = null; + const dirs = (p: string): string[] => { + try { return readdirSync(p, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name); } + catch { return []; } + }; + for (const y of dirs(root)) { + for (const m of dirs(path.join(root, y))) { + for (const d of dirs(path.join(root, y, m))) { + const dayDir = path.join(root, y, m, d); + let names: string[] = []; + try { names = readdirSync(dayDir); } catch { continue; } + for (const name of names) { + if (!name.startsWith('rollout-') || !name.endsWith('.jsonl')) continue; + const file = path.join(dayDir, name); + try { + const st = statSync(file); + if (!newest || st.mtimeMs > newest.mtimeMs) newest = { file, mtimeMs: st.mtimeMs }; + } catch { /* vanished mid walk */ } + } + } + } + } + return newest?.file ?? null; +} + +function asProvider(p: string | undefined): HistoryProvider | null { + const v = p ?? 'claude'; + return v === 'claude' || v === 'codex' || v === 'antigravity' ? v : null; +} + +function offsetArg(v: unknown): number | undefined { + return typeof v === 'number' && Number.isSafeInteger(v) && v >= 0 ? v : undefined; +} + +export class HistoryService { + /** Per agent: the file found for `key` (null: a miss, kept until `until`). */ + private cache = new Map(); + + constructor(private readonly deps: HistoryDeps, private readonly limits: TailLimits = {}, private readonly now: () => number = Date.now) {} + + /** The transcript file for an agent, or why there is none. */ + resolve(agentId: string): { provider: HistoryProvider; file: string } | { reason: 'no-agent' | 'unsupported-provider' | 'no-transcript' } { + const a = this.deps.agent(agentId); + if (!a) return { reason: 'no-agent' }; + const provider = asProvider(a.provider); + if (!provider) return { reason: 'unsupported-provider' }; + const sid = a.sessionId && SESSION_ID.test(a.sessionId) ? a.sessionId : ''; + const hookPath = this.deps.transcriptPath(agentId) ?? ''; + const key = `${provider}|${sid}|${hookPath}|${a.cwd ?? ''}`; + const hit = this.cache.get(agentId); + if (hit && hit.key === key) { + if (hit.file && existsSync(hit.file)) return { provider, file: hit.file }; + if (!hit.file && this.now() < hit.until) return { reason: 'no-transcript' }; + } + const file = this.locate(agentId, provider, sid, hookPath, a.cwd ?? ''); + this.cache.set(agentId, { key, file, until: this.now() + HISTORY_MISS_MS }); + return file ? { provider, file } : { reason: 'no-transcript' }; + } + + private locate(agentId: string, provider: HistoryProvider, sid: string, hookPath: string, cwd: string): string | null { + const hook = hookPath.endsWith('.jsonl') ? hookPath : ''; + if (provider === 'claude') { + const roots = claudeProjectRoots(); + return realInside(roots, hook) + ?? (sid && cwd && path.isAbsolute(cwd) ? realInside(roots, path.join(projectDir(cwd), `${sid}.jsonl`)) : null); + } + if (provider === 'codex') { + const home = this.deps.codexHomeFor(agentId); + if (!home) return null; + const roots = codexRoots(home); + const fromHook = realInside(roots, hook); + if (fromHook) return fromHook; + // With a session id, only that session's rollout: never a newer one's. + if (sid) return realInside(roots, findCodexRollout(home, sid)); + return realInside(roots, findNewestRollout(home)); + } + // antigravity + const brain = path.join(this.deps.geminiHome(), 'antigravity-cli', 'brain'); + if (path.basename(hook) === 'transcript.jsonl') { + const fromHook = realInside([brain], hook); + if (fromHook) return fromHook; + } + return sid ? realInside([brain], path.join(brain, sid, '.system_generated', 'logs', 'transcript.jsonl')) : null; + } + + forget(agentId: string): void { + this.cache.delete(agentId); + } + + /** One page. Never throws: a bad request or an unreadable file is a reason, not an error. */ + page(req: unknown): HistoryPage { + const r = (typeof req === 'object' && req !== null ? req : {}) as Record; + const agentId = typeof r.agentId === 'string' && AGENT_ID.test(r.agentId) ? r.agentId : ''; + if (!agentId) return { ok: false, reason: 'no-agent' }; + const resolved = this.resolve(agentId); + if ('reason' in resolved) return { ok: false, reason: resolved.reason }; + const { provider, file } = resolved; + const limitRaw = typeof r.limit === 'number' && Number.isFinite(r.limit) ? Math.floor(r.limit) : HISTORY_PAGE_DEFAULT; + const limit = Math.max(1, Math.min(HISTORY_PAGE_MAX, limitRaw)); + const after = offsetArg(r.after); + const before = offsetArg(r.before); + const head = { + ok: true as const, + provider, + fileId: createHash('sha1').update(path.resolve(file)).digest('hex').slice(0, 12), + fileName: path.basename(file) + }; + try { + const toItems = (lines: LineRef[]): HistoryItem[] => lines.flatMap((l) => normalizeLine(provider, l.text, l.offset)); + if (after !== undefined) { + const size = statSync(file).size; + // Smaller than where we were: truncated or replaced. The view reloads the tail. + if (size < after) return { ...head, items: [], start: 0, end: 0, atStart: false, size, reset: true }; + const res = readLinesForward(file, after, this.limits); + return { ...head, items: toItems(res.lines), start: res.start, end: res.end, atStart: res.start === 0, size: res.size }; + } + // Each line is normalised once, as it is met (newest first), so the scan + // can stop as soon as the page is full. + const groups: HistoryItem[][] = []; + let seen = 0; + let count = 0; + const res = readLinesBackward(file, before ?? null, (newestFirst) => { + while (seen < newestFirst.length) { + const l = newestFirst[seen++]; + const items = normalizeLine(provider, l.text, l.offset); + groups.push(items); + count += items.length; + } + return count >= limit; + }, this.limits); + const items = groups.reverse().flat(); + return { ...head, items, start: res.start, end: res.end, atStart: res.start === 0, size: res.size }; + } catch { + return { ok: false, reason: 'unreadable' }; + } + } +} diff --git a/src/main/historyTail.ts b/src/main/historyTail.ts new file mode 100644 index 000000000..143504ce6 --- /dev/null +++ b/src/main/historyTail.ts @@ -0,0 +1,249 @@ +/** + * A bounded line reader for append-only JSONL transcripts, for the History tab. + * Ported from Prozzta/guppyos 02d0d50b (MIT). + * + * A Codex rollout reaches 100+ MB (one reached 132 MB), so nothing here ever reads a + * file whole: + * - BACKWARD from a byte offset (the tail, or an older page), in CHUNK-sized reads, and + * only until the caller has enough or the byte budget is spent; + * - FORWARD from a byte offset (following growth), with the same budget. + * + * The limits, per request: + * - `budget`: bytes read before the reader stops at the next line boundary. + * - `maxLine`: a line longer than this is SKIPPED, never assembled or parsed (Codex + * `compacted` records run to many MB). Skipping still has to find the line's end, so + * an oversized line may read up to `skipMax` more before the reader gives up at a + * boundary. + * + * Offsets are BYTE offsets of line starts. A line is split at 0x0A, which never occurs + * inside a multi-byte UTF-8 sequence, so each line decodes on its own; a trailing CR + * (CRLF files) is dropped. An unterminated last line is one still being written: it is + * never returned, and the window's `end` stops before it, so the next forward read picks + * it up whole. + * + * Growth is detected by SIZE, not mtime: on Windows a Codex rollout's mtime can stay at + * its creation time while the file grows. + * + * RESYNC: a line longer than even `budget + skipMax` cannot be + * skipped in one request. Rather than stopping at the same boundary forever (follow and + * older paging would stall, re-reading the whole budget every time), the reader then hands + * back a cursor INSIDE that line: the furthest byte it scanned. A cursor that is not a line + * start (the byte before it is not 0x0A) can only be one of these, so the next read starts + * in skip mode and carries on to the line's end (forward) or start (backward). Such a + * cursor is only ever produced here, and only inside a line already judged oversized. + */ +import { closeSync, fstatSync, openSync, readSync } from 'node:fs'; + +export const HISTORY_CHUNK_BYTES = 64 * 1024; +export const HISTORY_BUDGET_BYTES = 4 * 1024 * 1024; +export const HISTORY_MAX_LINE_BYTES = 1024 * 1024; +export const HISTORY_SKIP_MAX_BYTES = 64 * 1024 * 1024; + +export interface TailLimits { + budget?: number; + maxLine?: number; + skipMax?: number; + chunk?: number; +} + +export interface LineRef { + /** Byte offset of the line's first byte. */ + offset: number; + text: string; +} + +export interface ScanResult { + /** Oldest first. */ + lines: LineRef[]; + /** The window covered, [start, end): both are line boundaries. */ + start: number; + end: number; + /** The file's size at the read. */ + size: number; + /** Lines skipped for exceeding maxLine. */ + skipped: number; + bytesRead: number; +} + +function limitsOf(l: TailLimits = {}): Required { + return { + budget: l.budget ?? HISTORY_BUDGET_BYTES, + maxLine: l.maxLine ?? HISTORY_MAX_LINE_BYTES, + skipMax: l.skipMax ?? HISTORY_SKIP_MAX_BYTES, + chunk: l.chunk ?? HISTORY_CHUNK_BYTES + }; +} + +/** Is `at` inside a line (not a line start)? Only resync cursors are. */ +function midLine(fd: number, at: number, size: number): boolean { + if (at <= 0 || at > size) return false; + const b = Buffer.alloc(1); + readSync(fd, b, 0, 1, at - 1); + return b[0] !== 0x0a; +} + +function decode(parts: Buffer[]): string { + const s = (parts.length === 1 ? parts[0] : Buffer.concat(parts)).toString('utf8'); + return s.endsWith('\r') ? s.slice(0, -1) : s; +} + +/** + * Read lines BACKWARD from `from` (default: the end of the file). `from` may sit mid-line + * (the file's unterminated last line): that fragment is not returned, and `end` becomes + * the boundary before it. `enough(lines)` is asked after each line (newest first) and + * stops the scan once it says so. + */ +export function readLinesBackward( + file: string, + from: number | null, + enough: (newestFirst: LineRef[]) => boolean, + limits?: TailLimits +): ScanResult { + const { budget, maxLine, skipMax, chunk } = limitsOf(limits); + const fd = openSync(file, 'r'); + try { + const size = fstatSync(fd).size; + const top = from === null ? size : Math.max(0, Math.min(from, size)); + // `from` inside a line: a resync cursor (see RESYNC). The window ends exactly there, and + // the rest of that oversized line (back to its start) is skipped, not treated as a fragment. + const resume = from !== null && midLine(fd, top, size); + const out: LineRef[] = []; + let skipped = 0; + let bytesRead = 0; + let pos = top; + // The bytes of the line being assembled, AFTER `pos` (latest part last). + let carry: Buffer[] = []; + let carryLen = 0; + let oversized = resume; + // null until the first newline is met: until then we are inside the trailing fragment. + let end: number | null = resume ? top : null; + // The start of the oldest line fully handled: the window's start if we stop now. + let boundary = top; + let stopped = false; + const buf = Buffer.alloc(Math.max(1, chunk)); + + // A line [lineStart, …) is complete: emit it (or skip it), then decide whether to stop. + const finishLine = (lineStart: number): boolean => { + if (end === null) { + // The fragment after the last newline: never returned; the window ends before it. + end = lineStart; + } else if (oversized) { + skipped += 1; + } else if (carryLen > 0) { + const text = decode(carry); + if (text) out.push({ offset: lineStart, text }); + } + carry = []; carryLen = 0; oversized = false; + boundary = lineStart; + return enough(out) || bytesRead >= budget; + }; + + while (pos > 0 && !stopped) { + const n = Math.min(buf.length, pos); + pos -= n; + readSync(fd, buf, 0, n, pos); + bytesRead += n; + let segEnd = n; + for (let i = n - 1; i >= 0; i -= 1) { + if (buf[i] !== 0x0a) continue; + if (!oversized) { + const piece = buf.subarray(i + 1, segEnd); + if (piece.length) { carry.unshift(Buffer.from(piece)); carryLen += piece.length; } + if (carryLen > maxLine) { oversized = true; carry = []; } + } + segEnd = i; + if (finishLine(pos + i + 1)) { stopped = true; break; } + } + if (stopped) break; + if (!oversized) { + const piece = buf.subarray(0, segEnd); + if (piece.length) { carry.unshift(Buffer.from(piece)); carryLen += piece.length; } + if (carryLen > maxLine) { oversized = true; carry = []; } + } else { + carryLen += segEnd; + } + // Out of budget in the middle of a line: stop at the last boundary, unless this is + // an oversized line being skipped (it may read up to skipMax more to find its start). + if (bytesRead >= budget && !(oversized && bytesRead < budget + skipMax)) { + stopped = true; + } + } + if (!stopped && pos === 0) { + // Reached the start of the file: what is carried is the first line, complete. + finishLine(0); + } + const windowEnd = end ?? boundary; + out.reverse(); + // Stopped INSIDE an oversized line: hand back the furthest byte scanned (a resync cursor), + // so the next older page carries on from there instead of rescanning the same bytes. + const start = stopped && oversized && end !== null ? pos : Math.min(boundary, windowEnd); + return { lines: out, start, end: windowEnd, size, skipped, bytesRead }; + } finally { + closeSync(fd); + } +} + +/** + * Read complete lines FORWARD from `after` (a line start). Costs one fstat and no read + * when the file has not grown past `after`. + */ +export function readLinesForward(file: string, after: number, limits?: TailLimits): ScanResult { + const { budget, maxLine, skipMax, chunk } = limitsOf(limits); + const fd = openSync(file, 'r'); + try { + const size = fstatSync(fd).size; + const out: LineRef[] = []; + const startAt = Math.max(0, after); + if (size <= startAt) return { lines: out, start: startAt, end: startAt, size, skipped: 0, bytesRead: 0 }; + let skipped = 0; + let bytesRead = 0; + let pos = startAt; + let lineStart = startAt; + let boundary = startAt; + let carry: Buffer[] = []; + let carryLen = 0; + // `after` inside a line: a resync cursor (see RESYNC). Skip on to that line's end. + let oversized = midLine(fd, startAt, size); + const buf = Buffer.alloc(Math.max(1, chunk)); + let stopped = false; + while (pos < size && !stopped) { + const n = Math.min(buf.length, size - pos); + readSync(fd, buf, 0, n, pos); + bytesRead += n; + let segStart = 0; + for (let i = 0; i < n; i += 1) { + if (buf[i] !== 0x0a) continue; + if (!oversized) { + const piece = buf.subarray(segStart, i); + if (piece.length) { carry.push(Buffer.from(piece)); carryLen += piece.length; } + if (carryLen > maxLine) oversized = true; + } + if (oversized) skipped += 1; + else if (carryLen > 0) { + const text = decode(carry); + if (text) out.push({ offset: lineStart, text }); + } + carry = []; carryLen = 0; oversized = false; + lineStart = pos + i + 1; + boundary = lineStart; + segStart = i + 1; + if (bytesRead >= budget) { stopped = true; break; } + } + if (!stopped) { + if (!oversized) { + const piece = buf.subarray(segStart, n); + if (piece.length) { carry.push(Buffer.from(piece)); carryLen += piece.length; } + if (carryLen > maxLine) { oversized = true; carry = []; } + } + if (bytesRead >= budget && !(oversized && bytesRead < budget + skipMax)) stopped = true; + } + pos += n; + } + // Whatever is carried has no newline yet: still being written. Not returned. Inside an + // oversized line, hand back the furthest byte scanned (a resync cursor) so the next read + // does not rescan it. + return { lines: out, start: startAt, end: oversized ? pos : boundary, size, skipped, bytesRead }; + } finally { + closeSync(fd); + } +} diff --git a/src/main/hive.ts b/src/main/hive.ts index 5f9c38bff..45fe2e29f 100644 --- a/src/main/hive.ts +++ b/src/main/hive.ts @@ -41,7 +41,11 @@ import { MCP_CATALOG } from '../shared/mcpCatalog'; import { selectBroadcastTargets } from '../shared/broadcast'; import { preferredAgentRole } from '../shared/agentRole'; import { mergeTaskLedger } from '../shared/taskLedger'; +import type { AnswerRequest } from '../shared/humanQuestion'; +import { applyAnswers, type AppliedAnswer } from './askMeAnswers'; import { expandTilde } from './fs'; +import { readJsonFile, writeRefusal } from './damagedFile'; +import { codexAgentConfig } from './codexAgentConfig'; import { resolveGodName } from '../shared/godIdentity'; /** The subset of HarnessConfig the hive consumes for the default-MCP merge. @@ -453,6 +457,13 @@ export class HiveManager { private agentDir(id: string): string { return join(this.root()!, 'agents', id); } + /** A hive Codex agent's own CODEX_HOME (installCodexHooks), or null when it + * has none. For the History tab, which reads the agent's rollouts. */ + codexHomeOf(id: string): string | null { + if (!this.root() || !id || basename(id) !== id || id === '.' || id === '..') return null; + const home = join(this.agentDir(id), '.codex'); + return existsSync(home) ? home : null; + } /** IPC endpoint the cth-hook shim talks to (Phase 1 autonomy). * On POSIX this is a Unix-domain socket file under the hive root. On Windows, * Node's `net` IPC uses named pipes (a flat `\\.\pipe\` namespace, not the @@ -791,7 +802,7 @@ export class HiveManager { lastSeen: Date.now() }; if (meta.isGod) reg.godId = meta.id; - this.atomicWriteJson(join(root, 'registry.json'), reg); + this.saveRegistry(root, reg); this.appendLog({ kind: 'spawn', agentId: meta.id, name: meta.name, isGod: !!meta.isGod }); // Only logs on an invalid cwd (rare) — not a per-spawn line, so no log spam. @@ -1020,7 +1031,7 @@ export class HiveManager { if (agent.role === next) return { ok: true }; agent.role = next; agent.lastSeen = Date.now(); - this.writeJson(join(root, 'registry.json'), reg); + this.saveRegistry(root, reg); writeFileSync(join(this.agentDir(id), 'identity.md'), this.identityText(agent), 'utf8'); this.appendLog({ kind: 'role', agentId: id, role: next }); this.commit(`hive: role ${id}`); @@ -1045,7 +1056,7 @@ export class HiveManager { if (!agent || agent.archived === archived) return; agent.archived = archived; agent.lastSeen = Date.now(); - this.atomicWriteJson(join(root, 'registry.json'), reg); + this.saveRegistry(root, reg); this.appendLog({ kind: 'archive', agentId: id, archived }); this.commit(`hive: ${archived ? 'archive' : 'unarchive'} ${id}`); } catch { /* best-effort — never crash a lifecycle handler */ } @@ -1138,7 +1149,7 @@ export class HiveManager { if (!!agent.onHold === hold) return { ok: true, onHold: hold }; agent.onHold = hold; - this.writeJson(join(root, 'registry.json'), reg); + this.saveRegistry(root, reg); const fleetPath = join(root, 'fleet.json'); if (existsSync(fleetPath)) { @@ -1172,7 +1183,7 @@ export class HiveManager { const previousName = agent.name; agent.name = nextName; - this.writeJson(join(root, 'registry.json'), reg); + this.saveRegistry(root, reg); // fleet.json is ephemeral and may not exist yet. When it does, keep its // display name in lockstep with the registry so rosterContext() is fresh. @@ -1214,7 +1225,7 @@ export class HiveManager { if (!agent || agent.sessionId === sessionId) return; // unknown agent or unchanged → no write agent.sessionId = sessionId; agent.lastSeen = Date.now(); - this.atomicWriteJson(join(root, 'registry.json'), reg); + this.saveRegistry(root, reg); this.appendLog({ kind: 'session', agentId, sessionId }); this.commit(`hive: session ${agentId}`); } catch { /* best-effort — never crash a hook handler */ } @@ -1584,7 +1595,7 @@ export class HiveManager { : ''; const godLine = meta.isGod ? 'You are the GOD / ORCHESTRATOR of this hive — your job is to ORCHESTRATE, not to implement: maintain live situational awareness and delegate the work. (1) AWARENESS — always know what is going on: keep an accurate picture of every agent (active vs archived/idle), the task board, and all in-flight work; drain your inbox continually and triage every other agent\'s requests, answering clarifications so the team runs autonomously. (2) DELEGATE — decompose work and fan it out to the hive agents via their inboxes (route messages and assign owners; do not do their jobs); do NOT take on grunt implementation yourself. Stay aware of who is already on the floor and delegate OPPORTUNISTICALLY: BEFORE you spawn anything, CHECK THE LIVE ROSTER (active agents in registry.json + their state in fleet.json) and prefer routing to an EXISTING agent that fits — above all when the request names one ("ask Pam to…", "have Jim…"), route to that agent instead of reflexively creating a new one. Reuse an idle or already-running agent whose role matches; only spawn a fresh agent when no existing one is a sensible fit, and say that you checked. One capable owner beats a duplicate. (3) OWN ONLY THE IMPORTANT, high-leverage things — task decomposition, dispatch decisions, sign-offs, conflict resolution, branch integration, and final QA — and remain the sole scribe of board.md. You are otherwise fully autonomous — there is NO separate approval queue. For the genuinely critical (destructive actions, spending real money, scope changes, unresolvable conflicts), ask the human directly in your own session and let the tool-permission prompt gate the action; the human approves natively, including remotely from their phone via /remote-control. Keep the team unblocked. When you DISPATCH a task, write it as a 4-part contract so the agent can run autonomously: (1) OBJECTIVE — the concrete goal; (2) OUTPUT — the expected deliverable/format; (3) TOOLS — what to use or avoid, and any references to read instead of re-deriving; (4) BOUNDARIES — scope limits + the definition of done. Pass references (file paths, message ids, board sections), not pasted content — keep dispatches short.' - + ` MONITOR the floor by reading ${inRoot('fleet.json')} (live per-agent tokens, cost, status, last tool, breaker level, inbox backlog) and ${inRoot('registry.json')} — note that running 'claude agents' will NOT list your hive's sibling agents. A full Claude Code command reference is at ${inRoot('COMMANDS.md')} (slash commands act ONLY on your own session; CLI commands run in your shell and can target the fleet). You periodically receive scheduler / "Heartbeat" standup requests — on each, review every agent via fleet.json, re-engage anyone stalled, over-budget, or breaker-armed, and keep board.md and tasks.json accurate. In tasks.json, ALWAYS set each task's "assignee" to the worker's agent id the moment you dispatch it, and NEVER clear it on status changes — a done card must still say who did the work (the human reads the board by who-did-what). HUMAN FEEDBACK is first-class in the ledger: when a task can only proceed with the human's input — a QUESTION to answer OR an ACTION only the human can perform (create an account, approve a purchase, provide credentials/screenshots, test on their device) — set its status to "blocked" and append the concrete ask to the card's "humanQA" array (push {"q":"...","askedAt":""}; phrase actions as clear to-dos; keep every past entry — the history documents the card's decisions). WRITE THE ASK SHORT AND IN MARKDOWN. The human reads it on a CARD, not in a terminal, so an ask longer than a short paragraph plus its options (roughly 700 characters) is a report, not a question — cut the narrative, keep the decision. Open with ONE **bold** sentence saying exactly what you need from them; put paths, commands, values and identifiers in \`backticks\`; give each option or step its own "-" bullet or "1." number; leave a blank line between paragraphs (a single newline is a line break, so each option stays on its own line). When the ask originates in another agent's report, REWRITE it into that shape — never paste the report body in as the question, and never make the human read the investigation to find the decision. The harness surfaces open questions on the office floor's ASK ME board; the human's answer lands in the same entry ("a") AND arrives as an inbox message to you — read it, act on it, and unblock the card so work continues. Do NOT park human questions in separate files (no HumanQuestion.md) and never sit waiting on the human in your own session. Steward the token budget.` + + ` MONITOR the floor by reading ${inRoot('fleet.json')} (live per-agent tokens, cost, status, last tool, breaker level, inbox backlog) and ${inRoot('registry.json')} — note that running 'claude agents' will NOT list your hive's sibling agents. A full Claude Code command reference is at ${inRoot('COMMANDS.md')} (slash commands act ONLY on your own session; CLI commands run in your shell and can target the fleet). You periodically receive scheduler / "Heartbeat" standup requests — on each, review every agent via fleet.json, re-engage anyone stalled, over-budget, or breaker-armed, and keep board.md and tasks.json accurate. In tasks.json, ALWAYS set each task's "assignee" to the worker's agent id the moment you dispatch it, and NEVER clear it on status changes — a done card must still say who did the work (the human reads the board by who-did-what). HUMAN FEEDBACK is first-class in the ledger: when a task can only proceed with the human's input — a QUESTION to answer OR an ACTION only the human can perform (create an account, approve a purchase, provide credentials/screenshots, test on their device) — set its status to "blocked" and append the concrete ask to the card's "humanQA" array (push {"q":"...","askedAt":""}; phrase actions as clear to-dos; keep every past entry — the history documents the card's decisions). For a CHOICE add "options":[{"label":"...","detail":"..."}] (at most 8), "recommended": and "multi":true only when several may be picked; the card shows them as buttons plus a note, and "a" comes back with the picked labels. Never edit an entry's q or options once asked: append a new entry, because an answer to a question that changed is refused. WRITE THE ASK SHORT AND IN MARKDOWN. The human reads it on a CARD, not in a terminal, so an ask longer than a short paragraph plus its options (roughly 700 characters) is a report, not a question — cut the narrative, keep the decision. Open with ONE **bold** sentence saying exactly what you need from them; put paths, commands, values and identifiers in \`backticks\`; give each option or step its own "-" bullet or "1." number; leave a blank line between paragraphs (a single newline is a line break, so each option stays on its own line). When the ask originates in another agent's report, REWRITE it into that shape — never paste the report body in as the question, and never make the human read the investigation to find the decision. The harness surfaces open questions on the office floor's ASK ME board; the human's answer lands in the same entry ("a") AND arrives as an inbox message to you — read it, act on it, and unblock the card so work continues. Do NOT park human questions in separate files (no HumanQuestion.md) and never sit waiting on the human in your own session. Steward the token budget.` : meta.isAssistant ? `You are ${godNameForPrompt}'s PREP ASSISTANT. You will be handed short, possibly vague instructions (each begins with "ENRICH TASK:"). For each one: (1) figure out which project it concerns and cd into the most relevant repo — you start in ${godNameForPrompt}'s home directory; (2) gather concrete context READ-ONLY (exact file paths, current state, relevant code, conventions, active branch, gotchas) — NEVER modify, create, or delete files; (3) rewrite the instruction into ONE clear, self-contained prompt that ${godNameForPrompt} can execute autonomously, preserving the user's original intent without inventing scope. Then deliver it: write ONE message JSON into your outbox with "to":"god", "act":"request", a short subject, and the finished prompt as the body. Do NOT perform the task yourself — your only output is the improved prompt sent to ${godNameForPrompt}.` : 'For anything ambiguous, cross-cutting, or needing sign-off, address a message to "god".'; @@ -1897,7 +1908,7 @@ export class HiveManager { registry(): Registry { const root = this.root(); if (!root) return { godId: null, agents: {} }; - return this.readJson(join(root, 'registry.json'), { godId: null, agents: {} }); + return this.readLedger(join(root, 'registry.json'), { godId: null, agents: {} } as Registry); } board(): string { const root = this.root(); @@ -1905,7 +1916,34 @@ export class HiveManager { } tasks(): unknown { const root = this.root(); - return root ? this.readJson(join(root, 'tasks.json'), { tasks: [] }) : { tasks: [] }; + return root ? this.readLedger(join(root, 'tasks.json'), { tasks: [] }) : { tasks: [] }; + } + + /** The last registry.json or tasks.json read whole this run, by path (a copy). */ + private lastGood = new Map(); + + /** Read registry.json or tasks.json (damagedFile). Damaged (kept as it is, + * writes paused) or not readable just now: this run's last good value, a + * copy so a caller's unsaved change never becomes it; with none, `empty`. */ + private readLedger(p: string, empty: T): T { + const read = readJsonFile(p, !this.lastGood.has(p)); + if (read.kind === 'ok') { this.lastGood.set(p, structuredClone(read.value)); return read.value as T; } + if (read.kind === 'missing') return empty; + return this.lastGood.has(p) ? structuredClone(this.lastGood.get(p)) as T : empty; + } + + /** Write registry.json, unless it is damaged or cannot be read just now: + * then the write is skipped and logged once per spell, so a timer or an + * agent start carries on. */ + private saveRegistry(root: string, reg: Registry): boolean { + const p = join(root, 'registry.json'); + const refused = writeRefusal(p); + if (refused) { + if (refused.logged) this.appendLog({ kind: 'write-skipped', file: 'registry.json', why: refused.why, error: refused.error, copy: refused.copy }); + return false; + } + this.atomicWriteJson(p, reg); + return true; } /** Persist the task ledger to hive/tasks.json and commit it. Mirrors the @@ -1926,9 +1964,20 @@ export class HiveManager { if (!root) return; this.ensureHive(); const path = join(root, 'tasks.json'); - const current = this.readJson<{ tasks?: unknown }>(path, { tasks: [] }); + // A damaged board, or one that cannot be read just now, is never written + // over: the caller is told, and nothing is written. + const read = readJsonFile(path); + if (read.kind === 'damaged') { + this.appendLog({ kind: 'write-skipped', file: 'tasks.json', why: 'damaged', error: read.error, copy: read.copy }); + throw new Error(`hive: tasks.json is damaged, so it is kept as it is${read.copy ? ` (copy: ${read.copy})` : ''} and nothing was written. Repair it, then try again`); + } + if (read.kind === 'unreadable') { + this.appendLog({ kind: 'write-skipped', file: 'tasks.json', why: 'read error', error: read.error }); + throw new Error(`hive: tasks.json could not be read just now (${read.error}); nothing was written. Try again in a moment`); + } + const current = read.kind === 'ok' ? read.value as { tasks?: unknown } : { tasks: [] }; const merged = mergeTaskLedger(current?.tasks, tasks); - this.writeJson(path, { tasks: merged }); + this.atomicWriteJson(path, { tasks: merged }); this.appendLog({ kind: 'tasks', count: merged.length }); this.commit(`hive: tasks (${merged.length})`); } @@ -1966,6 +2015,17 @@ export class HiveManager { this.writeTasks(next); return true; } + + /** Answer or dismiss Ask Me questions (askMeAnswers) against the latest + * on-disk ledger, in ONE write. Throws like writeTasks when the board is + * damaged or cannot be read just now. */ + answerQuestions(reqs: Array): AppliedAnswer[] { + const ledger = this.tasks() as { tasks?: unknown }; + const tasks = Array.isArray(ledger?.tasks) ? ledger.tasks : []; + const applied = applyAnswers(tasks, reqs, new Date().toISOString()); + if (applied.changed) this.writeTasks(applied.tasks as HiveTask[]); + return applied.outcomes; + } memory(id: string): string { const p = join(this.agentDir(id), 'memory.md'); return existsSync(p) ? readFileSync(p, 'utf8') : ''; @@ -2244,8 +2304,10 @@ export class HiveManager { // `codex app-server` → initialize → `hooks/list` reports the normalized // timeoutSec per event. const shim = this.shimPath(); - let config = existsSync(join(userHome, 'config.toml')) - ? readFileSync(join(userHome, 'config.toml'), 'utf8') : ''; + // Our copy turns the seed's plugins off and caps each tool output the + // model sees (codexAgentConfig); the person's own file is untouched. + let config = codexAgentConfig(existsSync(join(userHome, 'config.toml')) + ? readFileSync(join(userHome, 'config.toml'), 'utf8') : ''); if (shim) { const events = ['PreToolUse', 'PostToolUse', 'Stop', 'SubagentStop', 'SessionStart', 'UserPromptSubmit', 'PreCompact', 'PostCompact']; @@ -3019,6 +3081,13 @@ The harness shows the open ask on the ASK ME board and in the ASK ME tab, and th in the same entry as \`"a"\` plus an inbox message to god. Every past entry stays on the card — that trail is the decision history. +For a choice, put the options on the entry and the card shows them as buttons with a note field: +\`"options"\` (up to 8, each \`{ "label": "...", "detail": "..." }\`, detail optional), \`"recommended"\` +(the position of the one you suggest, 0 for the first) and \`"multi": true\` when several may be picked +(otherwise one). The answer's \`"a"\` holds the picked labels and any note, and \`"chosen"\` their +positions. Never change an entry's \`q\` or options once asked: append a new entry instead, because an +answer to a question that changed is refused. A card marked done asks nothing. + **Write the ask short, and in markdown.** The card renders it, so plain-text asterisks and backticks show up literally, and a card is not a terminal — an ask longer than a short paragraph plus its options (roughly 700 characters) is a report, not a question. Cut the narrative and keep the decision: diff --git a/src/main/index.ts b/src/main/index.ts index 0cc017c8d..0bf69479a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,3 +1,4 @@ +import './packThemes'; // theme packs join the project-theme catalogue before any config is read import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, powerMonitor, powerSaveBlocker, screen, shell, Notification } from 'electron'; import { spawn } from 'node:child_process'; import { @@ -14,14 +15,23 @@ import { resolveCommand as resolveCliCommand, isSafeCommandName } from './shellE import { initAutoUpdater, abortPendingRestart } from './updater'; import { RealtimeFloorWatcher } from './realtimeFloorWatcher'; import { - readConfig, writeConfig, setAgentTokenCap, resetConfig, onConfigWritten, ensureHarnessHome, ensureClaudePermissionsAccepted, + readConfig, writeConfig, setAgentTokenCap, setGraphicsMode, setProjectThemeForHome, setCodexFolderOk, resetConfig, onConfigWritten, ensureHarnessHome, ensureClaudePermissionsAccepted, modelForRole, OPS_STANDUP_MISSION, HEARTBEAT_MISSION, COMPACT_MAINTENANCE_MISSION, type HarnessConfig, type ScheduledMission } from './config'; -import { listDir, readFileText, readFileBinary, writeFileText, statAbs, expandTilde } from './fs'; +import { listDir, readFileText, readFileBinary, writeFileText, statAbs, expandTilde, newProjectFolderState } from './fs'; +import { integrityIssues, integrityPath } from './damagedFile'; +import { applyGraphicsModeAtStartup, appliedGraphicsMode, graphicsStatus, logGraphicsWhenKnown, watchGpuReports } from './graphics'; +import type { GraphicsStatusView } from '../shared/graphicsMode'; +import { HistoryService } from './historyService'; +import { parseAnswerRequest } from './askMeAnswers'; +import { isProjectThemeId, moveProjectTheme, resolveProjectTheme, type ProjectThemeId, type ProjectThemeSave } from '../shared/projectTheme'; +import { ReloadPolicy, type RecoveryNotice } from '../shared/viewRecovery'; +import { codexFolderKey, codexHold, codexProjectLayers, type CodexHold, type CodexHoldNotice } from './codexProjectLayers'; +import type { AnswerOutcome } from '../shared/humanQuestion'; import { normalizeWeekly, weeklyDelayMs } from '../shared/weeklySchedule'; import { getBranch, getStatus, getLog, getBranches, getAheadBehind, isRepo, getDiff, mainRepoRoot, - addWorktree, removeWorktree, worktreeHasUnintegratedWork, worktreeIsGcSafe, + addWorktree, removeWorktree, dropHeldAgentBranch, worktreeHasUnintegratedWork, worktreeIsGcSafe, getLogGraph, getCommitFiles, getFileAtRev, compareRefs, listWorktrees, checkoutRef } from './git'; import { linkWorktreeDeps, unlinkWorktreeDeps } from './worktreeDeps'; @@ -95,6 +105,14 @@ import { const isDev = !!process.env.ELECTRON_RENDERER_URL; +// Settings, Graphics: applied HERE, at module load, before app ready +// (disableHardwareAcceleration does nothing later). A missing or damaged +// config.json is Auto. CPU only turns hardware acceleration off for this run. +const graphicsAtStartup = applyGraphicsModeAtStartup(app); +// The GPU process's first report, noted before ready so none is missed; the +// startup 'graphics' row waits for it, since the status reads software until then. +watchGpuReports(app); + // Keep the main process alive on an unexpected throw/rejection. The harness is a // multi-agent supervisor — a single stray throw (e.g. node-pty's ConPTY console // helper choking when a fast-exiting agent CLI's console is already gone) must @@ -421,6 +439,16 @@ interface PreservedWorktree { * sweep drains this: an entry is removed (worktree + scratch GC'd) only when the * work is provably integrated, or when the worktree is already gone from disk. */ const preservedWorktrees = new Map(); +/** Worktrees whose removal was refused this run (git.removeWorktree: not a + * linked worktree, or a link inside could not be removed as a link). Each is + * logged once to the hive log, and the GC sweep stops retrying it until the + * app starts again. */ +const refusedWorktrees = new Set(); +function noteRemovalRefused(wtPath: string, error: string | undefined): void { + if (refusedWorktrees.has(wtPath)) return; + refusedWorktrees.add(wtPath); + try { hive.appendLog({ kind: 'worktree-removal-refused', path: wtPath, error }); } catch { /* best-effort */ } +} /** * Tear down everything tied to a PTY id: archive its hive agent, remove its @@ -486,7 +514,7 @@ function teardownPty(id: string): void { void finalizeWorkerWorktree(wtPath, origCwd, worker); } else { void removeWorktree(origCwd, wtPath) - .then(r => { if (!r.ok) console.error('[worktree] removeWorktree failed:', r.error); }) + .then(r => { if (!r.ok) { console.error('[worktree] removeWorktree failed:', r.error); if (r.refused) noteRemovalRefused(wtPath, r.error); } }) .catch(e => console.error('[worktree] removeWorktree threw:', e)); } } @@ -548,7 +576,7 @@ async function finalizeWorkerWorktree(wtPath: string, origCwd: string, worker: W return; } const r = await removeWorktree(origCwd, wtPath); - if (!r.ok) { console.error('[worker] removeWorktree failed:', r.error); return; } + if (!r.ok) { console.error('[worker] removeWorktree failed:', r.error); if (r.refused) noteRemovalRefused(wtPath, r.error); return; } // Worktree is gone (clean/integrated at teardown), but DEFER its scratch-dir // cleanup to the throttled GC sweep rather than deleting it synchronously here: // HIVE_ROOT/agents/ holds the worker's memory.md and the MemPalace miner @@ -2303,6 +2331,67 @@ ipcMain.handle('hire:openFile', async () => { * window — cascades its position, and on close stops only its OWN terminals * while the app keeps running. */ +/** One shot "the view was restored" notices (shared/viewRecovery), by the + * webContents that should show it; the reloaded page takes its own on mount. */ +const recoveryNotices = new Map(); +/** Asked once, synchronously, by the reloaded page's preload: true while this + * window has a notice waiting, so the page opens on the live project rather + * than the launch-time hive picker. */ +ipcMain.on('window:recoveringSync', (evt) => { evt.returnValue = recoveryNotices.has(evt.sender.id); }); +ipcMain.handle('window:takeRecoveryNotice', (evt) => { + const n = recoveryNotices.get(evt.sender.id) ?? null; + recoveryNotices.delete(evt.sender.id); + return n; +}); +/** The root error boundary's "Reload the window", through main so the reloaded + * page says it was restored. */ +ipcMain.on('window:reloadAfterError', (evt) => { + const wc = evt.sender; + if (wc.isDestroyed()) return; + recoveryNotices.set(wc.id, { at: Date.now(), reason: 'render-error', streak: 1 }); + try { hive.appendLog({ kind: 'renderer-error-reload' }); } catch { /* best-effort */ } + wc.reload(); +}); + +/** A window's view died. Its terminals live in main and keep running, so reload + * it and leave the notice; a view that keeps dying (ReloadPolicy) stays down. A + * view that exits cleanly, or dies while the app quits, is left alone. */ +function onViewGone(win: BrowserWindow, wc: Electron.WebContents, details: Electron.RenderProcessGoneDetails, isFloor: boolean, policy: ReloadPolicy): void { + if (details.reason === 'clean-exit' || allowQuit || win.isDestroyed() || wc.isDestroyed()) return; + const at = Date.now(); + const { reload, streak } = policy.next(at); + try { hive.appendLog({ kind: 'render-process-gone', reason: details.reason, exitCode: details.exitCode, floor: isFloor, reload, streak }); } catch { /* best-effort */ } + if (!reload) return; + recoveryNotices.set(wc.id, { at, reason: details.reason, streak }); + wc.reload(); +} + +/** This window's view cannot answer: it died (and stayed down), or is gone. */ +function viewGone(wc: Electron.WebContents): boolean { + try { return wc.isDestroyed() || wc.isCrashed(); } catch { return true; } +} + +/** The quit warning, asked natively when the window's view cannot show its own + * (it died and stayed down): the same choice the page's modal gives, so the + * app can still be quit. */ +function askQuitNatively(win: BrowserWindow | null, ptyCount: number): void { + const opts: Electron.MessageBoxSyncOptions = { + type: 'warning', + buttons: ['Quit and stop agents', 'Cancel'], + defaultId: 1, + cancelId: 1, + message: `Quit Munder Difflin? ${ptyCount} running terminal${ptyCount === 1 ? '' : 's'} will be stopped.`, + detail: 'The window is not showing (its view stopped), so this is asked here instead.' + }; + const choice = win && !win.isDestroyed() ? dialog.showMessageBoxSync(win, opts) : dialog.showMessageBoxSync(opts); + if (choice === 0) { + closingTime.cancel(); // a hard quit overrides a closing time in progress + teardownAndQuit(); + } else { + abortPendingRestart(); + } +} + function createWindow(opts: { floor?: boolean } = {}): BrowserWindow { const isFloor = opts.floor === true; @@ -2436,6 +2525,7 @@ function createWindow(opts: { floor?: boolean } = {}): BrowserWindow { const count = ptyManager.list().length; if (count === 0) return; e.preventDefault(); + if (viewGone(wc)) { askQuitNatively(win, count); return; } win.focus(); wc.send('app:closeRequested', { ptyCount: count }); }); @@ -2451,6 +2541,9 @@ function createWindow(opts: { floor?: boolean } = {}): BrowserWindow { if (details.isMainFrame) rendererReadyForHires = false; }); + const reloads = new ReloadPolicy(); + wc.on('render-process-gone', (_e, details) => onViewGone(win, wc, details, isFloor, reloads)); + if (isDev && process.env.ELECTRON_RENDERER_URL) { win.loadURL(process.env.ELECTRON_RENDERER_URL); } else { @@ -2624,7 +2717,69 @@ ipcMain.handle('pty:spawn', async (evt, opts: AgentSpawnOptions) => { * it can ALSO be invoked by the god-triggered ephemeral-worker watcher (which has * no renderer `evt`). `owner` is the window that should receive this PTY's output * (null → the primary window). Behavior-identical to the prior inline handler. */ -async function spawnAgentCore(opts: AgentSpawnOptions, owner: Electron.WebContents | null): Promise<{ ok: boolean; error?: string; cwd?: string; worktreePath?: string; resumeNotFound?: boolean; resumed?: boolean; seedPrompt?: string }> { +// ── R3: hold a hive Codex start (codexProjectLayers) ───────────────────────── +/** Held Codex starts for the window's card, one per agent and folder, newest last. */ +const codexHolds: CodexHoldNotice[] = []; +function pushCodexHolds(): void { + for (const w of allWindows) { + if (w.isDestroyed() || w.webContents.isDestroyed()) continue; + try { w.webContents.send('codexHold:push', codexHolds.slice()); } catch { /* window tearing down */ } + } +} +/** Null to start a hive Codex agent in `cwd`; else why it is held. `keyCwd` is the + * folder the person started in when `cwd` is the worktree made from it: the OK and + * the card stay keyed by that folder (spelled as in its main checkout). A check + * that throws holds too (fail safe). */ +function codexStartCheck(agentId: string, cwd: string, keyCwd: string = cwd): CodexHold | null { + try { + const codexHome = hive.codexHomeOf(agentId) ?? join(hive.root() ?? '', 'agents', agentId, '.codex'); + const report = codexProjectLayers({ cwd, codexHome }); + if (keyCwd !== cwd) report.projectFolder = codexProjectLayers({ cwd: keyCwd, codexHome }).projectFolder; + return codexHold(report, readConfig().codexLayerOptIns); + } catch (e) { + return { folderKey: codexFolderKey(keyCwd), folder: keyCwd, layers: [], unknown: [`the check failed (${e instanceof Error ? e.message : String(e)})`] }; + } +} +/** A held start: kept for the card (one per agent and folder, newest last), pushed, + * logged, and returned as spawnAgentCore's result. */ +function codexStartHeld(agentId: string, hold: CodexHold): { ok: false; error: string; codexHold: CodexHoldNotice } { + const notice: CodexHoldNotice = { ...hold, agentId, at: Date.now() }; + const same = codexHolds.findIndex((n) => n.agentId === agentId && n.folderKey === hold!.folderKey); + if (same >= 0) codexHolds.splice(same, 1); + codexHolds.push(notice); + while (codexHolds.length > 20) codexHolds.shift(); + pushCodexHolds(); + try { hive.appendLog({ kind: 'codex-hold', agent: agentId, folder: hold.folder, files: hold.layers.flatMap((l) => l.items.map((it) => it.file)), unknown: hold.unknown }); } catch { /* best-effort */ } + return { ok: false, error: `Codex start held: ${hold.folder} has project .codex settings that would run unreviewed. Allow the folder to start it.`, codexHold: notice }; +} +ipcMain.handle('codexHold:list', () => codexHolds.slice()); +/** The card's Allow: records the held folder's key (one OK covers its worktrees); + * the window then starts the held agents again. Only a folder held now. */ +ipcMain.handle('codexHold:allow', (_evt, folderKey: unknown) => { + if (typeof folderKey !== 'string' || !codexHolds.some((n) => n.folderKey === folderKey)) return { ok: false, error: 'that folder is not held' }; + const { saved } = setCodexFolderOk(folderKey, true); + if (!saved) return { ok: false, error: 'not-saved' }; + for (let i = codexHolds.length - 1; i >= 0; i--) if (codexHolds[i].folderKey === folderKey) codexHolds.splice(i, 1); + pushCodexHolds(); + try { hive.appendLog({ kind: 'codex-folder-allowed', folder: folderKey }); } catch { /* best-effort */ } + return { ok: true }; +}); +ipcMain.handle('codexHold:dismiss', (_evt, at: unknown) => { + const i = codexHolds.findIndex((n) => n.at === at); + if (i >= 0) { codexHolds.splice(i, 1); pushCodexHolds(); } + return { ok: true }; +}); +/** Settings: the allowed folders, and withdrawing one (by its key). */ +ipcMain.handle('codexHold:allowed', () => (readConfig().codexLayerOptIns ?? []).filter((k): k is string => typeof k === 'string')); +ipcMain.handle('codexHold:withdraw', (_evt, folderKey: unknown) => { + if (typeof folderKey !== 'string' || !folderKey.trim()) return { ok: false, error: 'invalid folder' }; + const { saved } = setCodexFolderOk(folderKey, false); + if (!saved) return { ok: false, error: 'not-saved' }; + try { hive.appendLog({ kind: 'codex-folder-withdrawn', folder: folderKey }); } catch { /* best-effort */ } + return { ok: true }; +}); + +async function spawnAgentCore(opts: AgentSpawnOptions, owner: Electron.WebContents | null): Promise<{ ok: boolean; error?: string; codexHold?: CodexHoldNotice; cwd?: string; worktreePath?: string; resumeNotFound?: boolean; resumed?: boolean; seedPrompt?: string }> { // ── cwd INGESTION — expand `~` exactly once, here ─────────────────────────── // This is the single door every agent spawn comes through (`pty:spawn` IPC and // the god-triggered ephemeral-worker watcher), so it is where a user-typed @@ -2718,6 +2873,16 @@ async function spawnAgentCore(opts: AgentSpawnOptions, owner: Electron.WebConten return res; } } + // R3: every hive Codex start (first, resume, respawn) passes + // --dangerously-bypass-hook-trust, so a project `.codex` that carries code would + // run unreviewed. Held until the person allows the folder. Checked on the cwd as + // given, before any worktree is made, so the usual held start leaves nothing + // behind; a made worktree is checked once more below. + const codexHive = !!(opts.hive && hive.enabled() && provider === 'codex'); + if (codexHive) { + const held = codexStartCheck(opts.hive!.id, opts.cwd); + if (held) return codexStartHeld(opts.hive!.id, held); + } // Git isolation: when requested and the cwd is a real repo, give this agent // its own worktree on an `agent/` branch so it can't clobber other agents' // (or the user's) working tree. Best-effort — a failure falls back to the @@ -2743,6 +2908,21 @@ async function spawnAgentCore(opts: AgentSpawnOptions, owner: Electron.WebConten const br = await getBranch(origCwd); const baseBranch = 'current' in br && br.current ? br.current : 'main'; const wt = await addWorktree(origCwd, wtPath, baseBranch); + // R3, second check: the worktree holds the base branch's committed `.codex`, not + // the working tree checked above. Held: the worktree is taken back (as teardown + // does) and the branch made for it deleted when nothing is on it, before the + // card goes up, so an Allow starts again in its own worktree. + const held = wt.ok && codexHive ? codexStartCheck(opts.hive!.id, wtPath, origCwd) : null; + if (held) { + const made = await getBranch(wtPath); + const r = await removeWorktree(origCwd, wtPath).catch((e: unknown) => ({ ok: false, error: String(e), refused: false })); + if (!r.ok) { console.error('[worktree] removeWorktree failed:', r.error); if (r.refused) noteRemovalRefused(wtPath, r.error); } + else { + const drop = await dropHeldAgentBranch(origCwd, wtPath, 'current' in made ? made.current : null, baseBranch); + if (drop.error) console.error('[worktree] held start: branch kept:', drop.error); + } + return codexStartHeld(opts.hive!.id, held); + } if (wt.ok) { opts.cwd = wtPath; worktreePaths.set(opts.id, wtPath); @@ -3250,7 +3430,7 @@ ipcMain.handle('config:ensureHome', (_evt, path: unknown) => { // re-binds against the new root. mode: 'move' copies the data (old kept as a // safety net), 'fresh' just re-points and bootstraps an empty home. ipcMain.handle('config:changeHome', async (_evt, payload: unknown) => { - const p = (payload ?? {}) as { newHome?: unknown; mode?: unknown }; + const p = (payload ?? {}) as { newHome?: unknown; mode?: unknown; projectTheme?: unknown }; if (typeof p.newHome !== 'string' || !p.newHome) return { ok: false, error: 'invalid newHome' }; const mode: 'move' | 'fresh' = p.mode === 'fresh' ? 'fresh' : 'move'; // expandTilde BEFORE resolve: both UI callers feed a folder-dialog result @@ -3261,6 +3441,19 @@ ipcMain.handle('config:changeHome', async (_evt, payload: unknown) => { const newHome = resolve(expandTilde(p.newHome)); const oldRaw = readConfig().harnessHome; const oldHome = oldRaw ? resolve(oldRaw) : null; + // Read before anything is copied in: a fresh home with no hive is a new project. + const blankProject = !existsSync(join(newHome, 'hive')); + // P2: a fresh project may come with the floor the person chose (a move keeps its + // own). It only starts a new project in an empty folder (nothing but .DS_Store), so + // it never lands on an existing project. Errors are ids the window words. + const chosen = mode === 'fresh' && p.projectTheme != null ? p.projectTheme : undefined; + let theme: ProjectThemeId | undefined; + if (chosen !== undefined) { + if (!isProjectThemeId(chosen)) return { ok: false, error: 'invalid-theme' }; + theme = chosen; + const folder = await newProjectFolderState(newHome); + if (folder !== 'empty') return { ok: false, error: folder }; + } // Guard against same-folder / nested-folder (a move would self-copy forever). if (oldHome) { @@ -3317,7 +3510,10 @@ ipcMain.handle('config:changeHome', async (_evt, payload: unknown) => { // Repoint config and relaunch so every service re-bootstraps against newHome. // (Identical recovery path to resetAll — relaunch is the clean re-bind.) allowQuit = true; - writeConfig({ harnessHome: newHome }); + // The project's floor theme goes with it on a move; a new project gets the one + // chosen for it (shared/projectTheme). + const officeThemeByHive = moveProjectTheme(readConfig().officeThemeByHive, { mode, oldHome, newHome, blankProject, theme }); + writeConfig({ harnessHome: newHome, officeThemeByHive }); try { ptyManager.killAll(); } catch (e) { console.error('[changeHome] killAll:', e); } app.relaunch(); app.exit(0); @@ -3517,20 +3713,109 @@ ipcMain.handle('hive:addTask', (_evt, task: unknown) => { return { ok: false, error: 'invalid task' }; } if (!hive.enabled()) return { ok: false, error: 'hive disabled (no harnessHome)' }; - return { ok: hive.addTask(task as HiveTask) }; + return taskWrite(() => hive.addTask(task as HiveTask)); }); +/** A board write, with a refusal (tasks.json damaged, or not readable just now) + * returned as its error rather than thrown, so the window can say so. */ +function taskWrite(write: () => boolean): { ok: boolean; error?: string } { + try { return { ok: write() }; } catch (e) { return { ok: false, error: e instanceof Error ? e.message : String(e) }; } +} ipcMain.handle('hive:patchTask', (_evt, id: unknown, patch: unknown) => { if (typeof id !== 'string' || !id || !patch || typeof patch !== 'object' || Array.isArray(patch)) { return { ok: false, error: 'invalid task patch' }; } if (!hive.enabled()) return { ok: false, error: 'hive disabled (no harnessHome)' }; - return { ok: hive.patchTask(id, patch as Partial>) }; + return taskWrite(() => hive.patchTask(id, patch as Partial>)); }); ipcMain.handle('hive:deleteTask', (_evt, id: unknown) => { if (typeof id !== 'string' || !id) return { ok: false, error: 'invalid task id' }; if (!hive.enabled()) return { ok: false, error: 'hive disabled (no harnessHome)' }; - return { ok: hive.deleteTask(id) }; + return taskWrite(() => hive.deleteTask(id)); }); +/** Ask Me (askMeAnswers): answer or dismiss the question each card showed, one + * humanQA entry each, in one tasks.json write. An answer counts as sent once it + * is saved: the god's notice follows, and a notice that fails is logged, never + * reported as not sent. */ +function answerQuestions(raw: unknown[]): AnswerOutcome[] { + if (!hive.enabled()) return raw.map((): AnswerOutcome => ({ result: 'failed', error: 'hive disabled (no harnessHome)' })); + const reqs = raw.map(parseAnswerRequest); + let applied: ReturnType; + try { + applied = hive.answerQuestions(reqs); + } catch (e) { + const error = e instanceof Error ? e.message : String(e); + return reqs.map((r): AnswerOutcome => (r ? { result: 'failed', error } : { result: 'invalid' })); + } + return applied.map(({ result, notice }, i): AnswerOutcome => { + if (notice) { + try { + hive.send({ to: 'god', act: 'inform', subject: notice.subject, body: notice.body }, 'human'); + analytics.trackMessageSent('hive'); + } catch (e) { + hive.appendLog({ kind: 'answer-notice-failed', task: reqs[i]?.taskId, error: e instanceof Error ? e.message : String(e) }); + } + } + return { result }; + }); +} +ipcMain.handle('hive:answerQuestion', (_evt, req: unknown) => answerQuestions([req])[0]); +ipcMain.handle('hive:answerQuestions', (_evt, reqs: unknown) => + Array.isArray(reqs) && reqs.length <= 500 ? answerQuestions(reqs) : []); +// Damaged sources of truth (damagedFile): config.json, registry.json and +// tasks.json are each read first, so the list is current. Names only. +ipcMain.handle('integrity:issues', () => { + readConfig(); + if (hive.enabled()) { hive.registry(); hive.tasks(); } + return integrityIssues(); +}); +// Show a listed file (a damaged file or its copy) in the file manager. The +// window names it; a path from the window is never used. +ipcMain.handle('integrity:reveal', (_evt, name: unknown) => { + const p = typeof name === 'string' ? integrityPath(name) : null; + if (!p) return { ok: false, error: 'not a listed file' }; + shell.showItemInFolder(p); + return { ok: true }; +}); +// The History tab: one page of an agent's conversation from its CLI's own +// transcript, found by agent id alone (historyService); never a window path. +const historyService = new HistoryService({ + agent: (id) => { + const a = hive.registry().agents[id]; + return a ? { provider: a.provider, cwd: a.cwd, sessionId: a.sessionId } : null; + }, + transcriptPath: (id) => hookServer.transcriptPath(id), + codexHomeFor: (id) => hive.codexHomeOf(id), + geminiHome: () => join(homedir(), '.gemini') +}); +ipcMain.handle('hive:history', (_evt, req: unknown) => historyService.page(req)); +// Settings, Graphics. The status is what is IN FORCE (the mode read before +// ready, Chromium's GPU compositing) plus the saved mode; a saved change +// applies after a restart, which goes through the normal teardown (the window +// asks the person first when agents are running). +function graphicsStatusView(): GraphicsStatusView { + let feature: Record | null = null; + try { feature = app.getGPUFeatureStatus() as unknown as Record; } catch { /* unknown */ } + return { ...graphicsStatus(readConfig(), feature), runningAgents: ptyManager.list().length }; +} +ipcMain.handle('graphics:status', () => graphicsStatusView()); +ipcMain.handle('graphics:setMode', (_evt, mode: unknown) => { + setGraphicsMode(mode); + try { hive.appendLog({ kind: 'graphics-set', mode, applied: appliedGraphicsMode() }); } catch { /* best-effort */ } + return graphicsStatusView(); +}); +ipcMain.handle('graphics:restart', () => { + app.relaunch(); + teardownAndQuit(); +}); +/** The current project's floor theme: the Settings picker, and voice. Main reads, + * merges and writes the map, so two windows cannot erase each other's choice; a + * switch changes only how the floor looks, never the agents. */ +function chooseProjectTheme(theme: unknown): ProjectThemeSave { + const cfg = readConfig(); + if (!cfg.harnessHome) return { ok: false, reason: 'no-home', theme: resolveProjectTheme(cfg, null) }; + return setProjectThemeForHome(cfg.harnessHome, theme); +} +ipcMain.handle('config:setProjectTheme', (_evt, theme: unknown) => chooseProjectTheme(theme)); ipcMain.handle('hive:setArchived', (_evt, id: unknown, archived: unknown) => { if (typeof id !== 'string') return { ok: false, error: 'invalid id' }; if (!hive.enabled()) return { ok: false, error: 'hive disabled (no harnessHome)' }; @@ -4475,9 +4760,9 @@ registerRealtimeActionIpc({ hiveEnabled: () => hive.enabled(), hiveSend: (partial, from) => hive.send(partial, from), hiveTasks: () => hive.tasks(), - hiveAddTask: (task) => hive.addTask(task as HiveTask), - hivePatchTask: (id, patch) => hive.patchTask(id, patch as Partial>), - hiveDeleteTask: (id) => hive.deleteTask(id), + hiveAddTask: (task) => taskWrite(() => hive.addTask(task as HiveTask)).ok, + hivePatchTask: (id, patch) => taskWrite(() => hive.patchTask(id, patch as Partial>)).ok, + hiveDeleteTask: (id) => taskWrite(() => hive.deleteTask(id)).ok, hiveRegistry: () => hive.registry(), hiveLog: (event) => hive.appendLog(event), controlPause: (id, on) => control.pause(id, on), @@ -4536,7 +4821,9 @@ registerRealtimeActionIpc({ try { liveWebContents()?.send('realtime:enqueue', { agentId: id, text }); } catch { /* window gone */ } }, getConfigValue: (key) => (readConfig() as unknown as Record)[key], - patchConfig: (patch) => { writeConfig(patch as Partial); } + patchConfig: (patch) => { writeConfig(patch as Partial); }, + getProjectTheme: () => { const c = readConfig(); return resolveProjectTheme(c, c.harnessHome ?? null); }, + setProjectTheme: (theme) => chooseProjectTheme(theme) }); // rt-12 seam: push detected completions to the live floor; bridge live-flag, queue @@ -4852,6 +5139,8 @@ async function gcPreservedWorktrees(): Promise { // A worker id that is live again (reqId reuse) → never GC its worktree or // scratch out from under the new run; leave the stale entry for a later sweep. if (liveWorkers.has(e.workerId)) continue; + // A removal refused this run is not retried (logged once already). + if (refusedWorktrees.has(e.wtPath)) continue; // (a) Worktree already gone (removed at clean teardown, or god removed it by // hand per the preserve note) → just reclaim the scratch dir + drop tracking. if (!existsSync(e.wtPath)) { @@ -4868,7 +5157,7 @@ async function gcPreservedWorktrees(): Promise { catch (err) { console.error('[worker gc] gc-safe check threw (keeping):', err); continue; } if (!safe.gc) continue; // keep — fail-safe const r = await removeWorktree(e.origCwd, e.wtPath); - if (!r.ok) { console.error(`[worker gc] removeWorktree failed (keeping ${e.workerId}):`, r.error); continue; } + if (!r.ok) { console.error(`[worker gc] removeWorktree failed (keeping ${e.workerId}):`, r.error); if (r.refused) noteRemovalRefused(e.wtPath, r.error); continue; } removeWorkerScratch(e.workerId); preservedWorktrees.delete(key); console.log(`[worker gc] reclaimed ${e.workerId} (${safe.detail})`); @@ -5373,6 +5662,8 @@ function onSystemResume(reason: string): void { } app.whenReady().then(() => { + // The startup 'graphics' row in the hive log, once Chromium knows (graphics.ts). + logGraphicsWhenKnown(app, graphicsAtStartup, (row) => hive.appendLog(row)); // Realtime Michael mic-gate hygiene (rt-8 / Pam rt-10 nit): the voice session // opens the mic permission gate by persisting realtimeVoiceEnabled=true and // closes it on disconnect — but a hard crash/reload mid-session skips that @@ -5461,6 +5752,7 @@ app.on('before-quit', (e) => { if (count === 0) return; e.preventDefault(); if (mainWindow) { + if (viewGone(mainWindow.webContents)) { askQuitNatively(mainWindow, count); return; } mainWindow.focus(); mainWindow.webContents.send('app:closeRequested', { ptyCount: count }); } diff --git a/src/main/packThemeEntries.ts b/src/main/packThemeEntries.ts new file mode 100644 index 000000000..5481f969d --- /dev/null +++ b/src/main/packThemeEntries.ts @@ -0,0 +1,23 @@ +/** + * The theme packs main registers, by the window's own rule: each manifest must + * pass validateManifest (the renderer's validator, pure and DOM free), folders + * are taken in name order, and the first pack of an id wins. The orchestrator + * seed is the validated manifest's. Pure; main/packThemes.ts feeds it the + * bundled manifests. + * + * Ported from Prozzta/guppyos 02d0d50b (MIT). + */ +import { validateManifest } from '../renderer/src/scene/themes/manifest'; +import type { PackThemeEntry } from '../shared/projectTheme'; + +/** The pack entries among parsed theme.json manifests keyed by path (".../themes//theme.json"). */ +export function validatedPackThemeEntries(manifests: Record): PackThemeEntry[] { + const out: PackThemeEntry[] = []; + const byDir = Object.entries(manifests).map(([file, raw]) => ({ dir: file.replace(/\\/g, '/').split('/').slice(-2, -1)[0] ?? '', raw })); + for (const { dir, raw } of byDir.sort((a, b) => a.dir.localeCompare(b.dir))) { + const m = validateManifest(raw, dir).manifest; + if (!m || out.some((e) => e.id === m.id)) continue; + out.push({ id: m.id, label: m.label, orchestrator: m.projectDefaults?.orchestrator }); + } + return out; +} diff --git a/src/main/packThemes.ts b/src/main/packThemes.ts new file mode 100644 index 000000000..f7ce0de21 --- /dev/null +++ b/src/main/packThemes.ts @@ -0,0 +1,15 @@ +/// +/** + * The bundled theme packs (themes//theme.json) as project themes, so main + * accepts a pack id wherever it accepts a built-in one (config:setProjectTheme, + * voice) exactly when the window would. Imported first by index.ts, so + * registration runs before any config is read. + * + * Ported from Prozzta/guppyos 02d0d50b (MIT). + */ +import { registerPackThemes } from '../shared/projectTheme'; +import { validatedPackThemeEntries } from './packThemeEntries'; + +const MANIFESTS = import.meta.glob('../../themes/*/theme.json', { eager: true, import: 'default' }) as Record; + +registerPackThemes(validatedPackThemeEntries(MANIFESTS)); diff --git a/src/main/realtimeActions.ts b/src/main/realtimeActions.ts index 21ebd3e81..8a7ecc58f 100644 --- a/src/main/realtimeActions.ts +++ b/src/main/realtimeActions.ts @@ -40,6 +40,7 @@ import type { ScheduledMission } from './config'; import { inferAgentProvider } from '../shared/agentProvider'; import { clearCommandForProvider } from '../shared/providerAutomation'; import { resolveGodName } from '../shared/godIdentity'; +import { offeredProjectThemes, projectThemes, type ProjectThemeSave } from '../shared/projectTheme'; export const VOICE_ACTOR = 'michael-voice'; @@ -88,6 +89,11 @@ export interface RealtimeActionDeps { * raw patch. */ getConfigValue(key: string): unknown; patchConfig(patch: Record): void; + /** update_setting officeTheme: the current project's theme, and the per-project + * switch the Settings picker uses (config:setProjectTheme), read back. Never + * the global officeTheme key. */ + getProjectTheme(): string; + setProjectTheme(theme: string): ProjectThemeSave; } /** The result every action / confirm / cancel returns to the renderer tool, which @@ -139,7 +145,7 @@ const SETTING_POLICY: Record t.label).join(', '); + if (!want) return { ok: false, spoken: `Which theme? The choices are: ${names}.` }; + const hit = themes.find((t) => normMatch(t.id) === want || normMatch(t.label) === want); + if (!hit) { + const tvShow = projectThemes().find((t) => normMatch(t.id) === want || normMatch(t.label) === want); + if (tvShow) return { ok: false, spoken: `${tvShow.label} is a TV-show office, and TV-show offices are off in Settings. The choices are: ${names}.` }; + return { ok: false, spoken: `There's no "${str(raw).trim()}" theme here. The choices are: ${names}.` }; + } + const labelOf = (id: string): string => projectThemes().find((t) => t.id === id)?.label ?? id; + const old = deps.getProjectTheme(); + if (old === hit.id) return { ok: true, spoken: `This project's theme is already ${hit.label}, nothing to change.` }; + const saved = deps.setProjectTheme(hit.id); + if (!saved.ok) { + return { ok: false, spoken: saved.reason === 'not-saved' + ? `I couldn't save that: the settings file is damaged or busy, so this project stays on ${labelOf(saved.theme)}.` + : saved.reason === 'no-home' ? `I couldn't switch the theme: no project is open.` : `${hit.label} isn't available here.` }; + } + attribute(deps, 'update_setting', 'project theme', { action: `${labelOf(old)} → ${hit.label}` }); + return { ok: true, spoken: `Done, this project's theme is now ${hit.label} (was ${labelOf(old)}). Every agent stays.` }; +} + // ─── propose: classify, allowlist-gate, run-or-stage ──────────────────────── function proposeDestructive(deps: RealtimeActionDeps, verb: string, a: Record): ActionResult { @@ -728,6 +764,7 @@ function proposeDestructive(deps: RealtimeActionDeps, verb: string, a: Record; + }>; + /** What could not be checked (each counts as code that runs). */ + unknown: string[]; +} + export interface AgentDirectory { godId: string | null; agents: AgentDirectoryEntry[]; @@ -323,8 +346,12 @@ export interface HarnessConfig { terminalTheme?: 'light' | 'dark'; /** TV-show office themes feature flag (Settings picker + switch flow). Default OFF. */ tvShowOffices?: boolean; - /** Active office map/cast theme (honored only when tvShowOffices is on). */ - officeTheme?: 'office' | 'friends' | 'brooklyn99' | 'siliconvalley' | 'got' | 'hogwarts'; + /** The floor theme of a project with no entry in officeThemeByHive (the one global + * theme from before themes were per project). Read with resolveProjectTheme. */ + officeTheme?: 'office' | 'friends' | 'brooklyn99' | 'siliconvalley' | 'got' | 'hogwarts' | 'starhike'; + /** Each project's own floor theme, keyed by its home (projectThemeHomeKey). Saved + * only through setProjectTheme. */ + officeThemeByHive?: Record; /** Per-CLI-provider local/self-hosted base URL (Ollama/LM Studio/vLLM, …) for the * OpenCode/Crush/pi/qwen engines; applied at spawn. API KEYS are NOT stored here — * they live write-only in the secret broker. */ @@ -579,8 +606,23 @@ const api = { // ─── PTY ───────────────────────────────────────────────────────────────── /** `cwd` in the result is the TILDE-EXPANDED absolute path main actually spawned * into — the renderer stores that, not the raw `~/…` the user typed. */ - spawnPty: (opts: SpawnPtyOptions): Promise<{ ok: boolean; error?: string; cwd?: string; worktreePath?: string; resumeNotFound?: boolean; resumed?: boolean; seedPrompt?: string }> => + spawnPty: (opts: SpawnPtyOptions): Promise<{ ok: boolean; error?: string; codexHold?: CodexHoldView; cwd?: string; worktreePath?: string; resumeNotFound?: boolean; resumed?: boolean; seedPrompt?: string }> => ipcRenderer.invoke('pty:spawn', opts), + /** R3: hive Codex starts held because their folder's project `.codex` carries code + * that would run unreviewed (codexHold on the spawn result too). */ + codexHolds: (): Promise => ipcRenderer.invoke('codexHold:list'), + onCodexHolds: (cb: (holds: CodexHoldView[]) => void): (() => void) => { + const listener = (_e: IpcRendererEvent, holds: CodexHoldView[]) => cb(holds); + ipcRenderer.on('codexHold:push', listener); + return () => ipcRenderer.removeListener('codexHold:push', listener); + }, + /** Allow a held folder (one OK covers its worktrees), then start the held agents + * again. error 'not-saved': config.json is damaged or busy. */ + codexHoldAllow: (folderKey: string): Promise<{ ok: boolean; error?: string }> => ipcRenderer.invoke('codexHold:allow', folderKey), + codexHoldDismiss: (at: number): Promise<{ ok: boolean }> => ipcRenderer.invoke('codexHold:dismiss', at), + /** Settings: the allowed folders, and withdrawing one. */ + codexAllowedFolders: (): Promise => ipcRenderer.invoke('codexHold:allowed'), + codexWithdrawFolder: (folderKey: string): Promise<{ ok: boolean; error?: string }> => ipcRenderer.invoke('codexHold:withdraw', folderKey), writePty: (id: string, data: string): Promise<{ ok: boolean; error?: string }> => ipcRenderer.invoke('pty:write', id, data), resizePty: (id: string, cols: number, rows: number): Promise<{ ok: boolean; error?: string }> => @@ -667,9 +709,12 @@ const api = { /** Change the harness home folder. 'move' copies the existing hive + palace * into the new folder (old kept as a safety net); 'fresh' just re-points and * bootstraps an empty home. On success the app relaunches (never resolves); - * on failure (e.g. copy error) returns { ok: false, error }. */ - changeHome: (newHome: string, mode: 'move' | 'fresh'): Promise<{ ok: boolean; error?: string }> => - ipcRenderer.invoke('config:changeHome', { newHome, mode }), + * on failure (e.g. copy error) returns { ok: false, error }. `projectTheme` + * (fresh only, P2): the new project's floor. The folder must then be missing or + * empty (nothing but .DS_Store); else error is an id: 'not-empty', 'unreadable' + * (the folder could not be read) or 'invalid-theme'. */ + changeHome: (newHome: string, mode: 'move' | 'fresh', projectTheme?: ProjectThemeId): Promise<{ ok: boolean; error?: string }> => + ipcRenderer.invoke('config:changeHome', { newHome, mode, ...(projectTheme !== undefined ? { projectTheme } : {}) }), // ─── Filesystem (sandboxed to cwd) ─────────────────────────────────────── listDir: (root: string, rel: string): Promise< @@ -1083,6 +1128,42 @@ const api = { /** Atomically remove one named card from the latest main-process ledger. */ hiveDeleteTask: (id: string): Promise<{ ok: boolean; error?: string }> => ipcRenderer.invoke('hive:deleteTask', id), + /** Ask Me: answer or dismiss the question a card showed (questionRef), one + * humanQA entry on the latest board; main sends the god's notice. 'gone': it + * changed or closed first, and nothing was written. */ + hiveAnswerQuestion: (req: AnswerRequest): Promise => + ipcRenderer.invoke('hive:answerQuestion', req), + /** Several in ONE write (Dismiss all), one result each, in order. */ + hiveAnswerQuestions: (reqs: AnswerRequest[]): Promise => + ipcRenderer.invoke('hive:answerQuestions', reqs), + /** Damaged sources of truth (config.json, registry.json, tasks.json): each kept + * as it is, with the name of its copy; writes to it pause. Names only. */ + integrityIssues: (): Promise> => + ipcRenderer.invoke('integrity:issues'), + /** Show a listed damaged file, or its copy, in the file manager, by name. */ + integrityReveal: (name: string): Promise<{ ok: boolean; error?: string }> => + ipcRenderer.invoke('integrity:reveal', name), + /** Settings, Graphics: the mode in force, the saved one, Chromium's view, and + * how many agent terminals a restart would stop. */ + graphicsStatus: (): Promise => ipcRenderer.invoke('graphics:status'), + /** Save the Graphics mode; it applies after a restart. */ + setGraphicsMode: (mode: GraphicsMode): Promise => ipcRenderer.invoke('graphics:setMode', mode), + /** The current project's floor theme, read back: 'not-saved' when config.json is + * damaged or busy, with the theme still in force. Changes only how the floor looks. */ + setProjectTheme: (theme: ProjectThemeId): Promise => ipcRenderer.invoke('config:setProjectTheme', theme), + /** True when main has just reloaded this window after its view died (or after + * reloadAfterError): open on the live project, not the launch hive picker. + * Read once, as the page loads. */ + recovering: ((): boolean => { try { return ipcRenderer.sendSync('window:recoveringSync') === true; } catch { return false; } })(), + /** The one shot "the view was restored" notice for this window, or null. Taking it clears it. */ + takeRecoveryNotice: (): Promise => ipcRenderer.invoke('window:takeRecoveryNotice'), + /** The root error boundary's reload, through main so the reloaded page says it was restored. */ + reloadAfterError: (): void => ipcRenderer.send('window:reloadAfterError'), + /** Restart the app through the normal teardown, so a saved Graphics mode applies. */ + restartForGraphics: (): Promise => ipcRenderer.invoke('graphics:restart'), + /** The History tab: one page of an agent's conversation from its CLI's own + * transcript, by agent id (byte offset cursors; see shared/history). */ + hiveHistory: (req: HistoryRequest): Promise => ipcRenderer.invoke('hive:history', req), // ─── Scheduled missions (recurring auto-dispatch) ────────────────────────── listMissions: (): Promise => ipcRenderer.invoke('missions:list'), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index d2dbb7e01..f538102f8 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -16,6 +16,7 @@ import { AddAgentModal } from '@/components/AddAgentModal'; import { MichaelBooting } from '@/components/MichaelBooting'; import { OnboardingWizard } from '@/components/OnboardingWizard'; import { HivePicker } from '@/components/HivePicker'; +import { IntegrityBanner, RecoveryNotice } from '@/components/FailureNotices'; import { QuitWarningModal, type ClosingTimeState } from '@/components/QuitWarningModal'; import { CompletionToast } from '@/realtime/CompletionToast'; import { UpdateToast } from '@/components/UpdateToast'; @@ -31,6 +32,8 @@ import { FullscreenTerminal } from '@/components/FullscreenTerminal'; import { TaskDetailOverlay } from '@/components/TaskDetailOverlay'; import { IdePanel } from '@/ide/IdePanel'; import { useHoldOptionToTalk } from '@/freeflow/holdOption'; +import { floorSceneTheme, resolveProjectTheme } from '@shared/projectTheme'; +import { useOrchestratorThemeLook } from '@/hooks/useOrchestratorThemeLook'; import brandLogo from '@brand/logo.png?url'; // Injected at build time from package.json (see electron.vite.config.ts). @@ -62,8 +65,10 @@ export function App() { // Whether the user has passed the launch-time hive picker this session. Starts // true (skip the picker) right after a hive SWITCH — changeHome relaunches and // leaves a one-shot localStorage flag so we don't bounce back onto the picker for - // the hive we just chose. Also set true on onboarding completion (below). + // the hive we just chose. Also set true on onboarding completion (below), and + // when main has just reloaded this window: it lands back on the open project. const [hiveOpened, setHiveOpened] = useState(() => { + if (window.cth.recovering) return true; try { if (window.localStorage.getItem('cth.skipHivePickerOnce')) { window.localStorage.removeItem('cth.skipHivePickerOnce'); @@ -108,9 +113,10 @@ export function App() { // show the voice button disabled-with-tooltip when Free Flow is on but no // Groq key is set (Settings keeps this in sync on save). useStore.getState().setHasGroqKey(!!c.groqApiKey); - // Mirror the active office theme so OfficeFloor renders it (gated on the - // tvShowOffices flag; off = always the office). Settings keeps this synced. - useStore.getState().setOfficeTheme(c.tvShowOffices ? (c.officeTheme ?? 'office') : 'office'); + // Mirror this project's floor theme so OfficeFloor renders it: the project's own + // entry, else the global theme (a TV-show map only while the tvShowOffices flag + // is on; a pack always). Settings keeps this synced. + useStore.getState().setOfficeTheme(floorSceneTheme(resolveProjectTheme(c, c.harnessHome), c.tvShowOffices)); // Mirror the triggers so Settings → Connections and the Command Center's // Triggers tab read one list, not two copies that drift — whichever surface // saves calls these same setters and the other repaints. No extra IPC: main @@ -192,6 +198,7 @@ export function App() { // hook) so Michael doesn't boot against the current home while the user may be // about to switch to a different one. useHive(hiveOpened ? config : null); + useOrchestratorThemeLook(); // Pre-warm a persistent terminal for every live agent so its output is // buffered from spawn. Switching agents then re-attaches an already-rendered @@ -258,14 +265,14 @@ export function App() { if (!config.onboardingComplete) { // Just-onboarded users go straight into the hive they set up — skip the picker. - return { setConfig(next); setHiveOpened(true); }} />; + return <> { setConfig(next); setHiveOpened(true); }} />; } // Launch-time hive picker: on reopen, let the user open their current hive, // switch to a recent one, or open/create another. Skipped right after onboarding // and right after a switch-relaunch (see hiveOpened init). if (!hiveOpened) { - return setHiveOpened(true)} />; + return <> setHiveOpened(true)} />; } return ( @@ -280,6 +287,10 @@ export function App() { {/* v0.3.4: background-update toast ("restart to update"); renders null until main's updater pushes a status. */} + {/* Pam's P7: the window was reloaded by itself (once, until OK), and a + settings, board or agent list file that cannot be read (until it reads). */} + + {/* Title bar */}
), used under the pack's own licence, +[`themes/starhike/LICENSE-ARTWORK.md`](../../../../themes/starhike/LICENSE-ARTWORK.md), kept verbatim: + +- ✅ May be **edited and used in any commercial or non-commercial project**, and shipped inside one. +- ❌ May not be resold or distributed as art by itself, edited or not. +- ⚠️ **Credits are required**: "Artwork by Prozzta", with a link to . + The pack's `theme.json` carries that credit, and the theme picker shows it with the floor. diff --git a/src/renderer/src/components/AddAgentModal.tsx b/src/renderer/src/components/AddAgentModal.tsx index 7545c66ea..5daf01053 100644 --- a/src/renderer/src/components/AddAgentModal.tsx +++ b/src/renderer/src/components/AddAgentModal.tsx @@ -3,10 +3,16 @@ import { useTranslation } from 'react-i18next'; import { PixelPanel } from './PixelPanel'; import { PixelButton } from './PixelButton'; import { SpritePortrait } from './SpritePortrait'; +import { ThemeLookBuilder } from './ThemeLookBuilder'; +import { CodexHoldCard } from './CodexHoldCard'; +import type { CodexHoldView } from '../../../preload'; import { Icon } from './Icon'; import { ProviderLogo } from './ProviderLogo'; import { useStore, type Agent } from '@/store/store'; import { OFFICE_CAST, DEFAULT_CHARACTER, type OfficeCharacterName } from '@/scene/office/cast'; +import { useThemeLooks } from '@/scene/themes/useThemeLooks'; +import { isStationPack } from '@/scene/themes/themeCatalog'; +import { draftToLook, startDraft, type LookDraft } from '@/scene/themes/looks'; import { type AccentColorName } from '@/design/tokens'; import type { HireManifest } from '@shared/hire'; import { hireQueueProgress } from '@shared/hireQueue'; @@ -188,9 +194,22 @@ export function AddAgentModal({ onClose, config, onConfigChange }: AddAgentModal const initialProvider = inferAgentProvider(config.defaultCommand); const initialModel = isClaudeProvider(initialProvider) ? config.defaultModel : undefined; - const [name, setName] = useState(pendingHire?.name ?? 'Jim'); + const officeTheme = useStore(s => s.officeTheme); + // On a pack's floor the name starts empty, so a preset may fill it (Pam's P3). + const [name, setName] = useState(pendingHire?.name ?? (isStationPack(officeTheme) ? '' : 'Jim')); const [character, setCharacter] = useState(knownCharacter(pendingHire?.character)); const [accent, setAccent] = useState(knownAccent(pendingHire?.accent)); + // The active theme pack's look builder (only when the pack declares one) takes + // the Character row's place. The draft is per theme, so a theme change + // mid-dialog never saves a look made for another pack. + const looks = useThemeLooks(officeTheme); + const hasThemeLookBuilder = Boolean(looks?.builder); + // Never flash the Office grid while an active pack is still loading. + const showOfficeCharacterPicker = looks !== undefined && !hasThemeLookBuilder; + const [lookDraftState, setLookDraftState] = useState<{ themeId: string; draft: LookDraft } | null>(null); + const lookDraft = looks?.builder + ? (lookDraftState?.themeId === looks.themeId ? lookDraftState.draft : startDraft(looks.builder, looks.columns)) + : null; const [cwd, setCwd] = useState(config.registeredRepos[0] ?? ''); // Local mirror of the registered projects so one added from here shows as a // quick-pick immediately (the `config` prop is a snapshot taken at open time). @@ -244,6 +263,9 @@ export function AddAgentModal({ onClose, config, onConfigChange }: AddAgentModal const [folderNote, setFolderNote] = useState(); const [error, setError] = useState(); const [busy, setBusy] = useState(false); + // A Codex start main held: its card takes the error line's place until allowed or put off. + const [codexHold, setCodexHold] = useState(null); + const [codexHoldError, setCodexHoldError] = useState(null); // Which config section the left sidebar index is showing. const [section, setSection] = useState('identity'); // "Generate a hire with AI" helper — reveals a copy-paste prompt (item 7). @@ -388,8 +410,28 @@ export function AddAgentModal({ onClose, config, onConfigChange }: AddAgentModal advanceHireReview(); }; + // Allow records the folder (one OK covers its worktrees), then the dialog starts + // the agent itself, so "allow this folder and start" holds. Not held any more + // (allowed in Settings meanwhile): start again; main holds it again if need be. + const allowCodexFolder = async () => { + if (!codexHold) return; + setBusy(true); + const r = await window.cth.codexHoldAllow(codexHold.folderKey).catch(() => ({ ok: false, error: 'not-saved' })); + setBusy(false); + if (!r.ok && r.error === 'not-saved') { setCodexHoldError(tr('settings.agentsModels.saveFailed')); return; } + void submit(); + }; + // Not now: the hold is put off and the dialog stays as it was. + const notNowCodexFolder = () => { + if (codexHold) void window.cth.codexHoldDismiss(codexHold.at).catch(() => undefined); + setCodexHold(null); + setCodexHoldError(null); + }; + const submit = async () => { setError(undefined); + setCodexHold(null); + setCodexHoldError(null); // A required field can live in a section the user hasn't opened, so jump to // the offending section as we surface the error — the field is never hidden. if (!name.trim()) { setError(tr('addAgent.errName')); setSection('identity'); return; } @@ -430,6 +472,8 @@ export function AddAgentModal({ onClose, config, onConfigChange }: AddAgentModal }); if (!spawnRes.ok) { setBusy(false); + // The folder's own Codex settings need the person's OK (Pam's P8): the card, not main's English reason. + if (spawnRes.codexHold) { setCodexHold(spawnRes.codexHold); return; } setError(spawnRes.error ?? 'spawn failed'); return; } @@ -455,6 +499,9 @@ export function AddAgentModal({ onClose, config, onConfigChange }: AddAgentModal name: name.trim(), character, accent, + // Saved even when untouched, on purpose: the hire looks as previewed (the + // pack default), not like the id-hash pick an unstyled spawn gets. + ...(looks && lookDraft ? { appearanceByTheme: { [looks.themeId]: draftToLook(lookDraft) } } : {}), description: description.trim() || 'a fresh harness', project: basename(projectCwd), tmuxTarget: '', @@ -635,6 +682,7 @@ export function AddAgentModal({ onClose, config, onConfigChange }: AddAgentModal user field, it rides the imported hire manifest (banner above). */}
+ {codexHold && ( + void allowCodexFolder()} + onNotNow={notNowCodexFolder} + /> + )} + {error && (
`${Math.round(n / 1000)}k`; * and a slim gauge pinned to the bottom edge. Nothing overlaps anything. */ export function AgentCard({ - name, character, accent, status, ptyId, project, action, progress = 0, + agentId, appearanceByTheme, name, character, accent, status, ptyId, project, action, progress = 0, contextTokens, contextLimit, selected, isGod, onClick, onRename, doingCount = 0, onTaskNoteClick, draggable, note, onEditNote }: AgentCardProps) { @@ -197,7 +202,9 @@ export function AgentCard({ display: 'flex', alignItems: 'flex-start', justifyContent: 'center', overflow: 'hidden', flexShrink: 0 }}> - + {agentId !== undefined + ? + : }
diff --git a/src/renderer/src/components/AgentDetailPanel.tsx b/src/renderer/src/components/AgentDetailPanel.tsx index 6c4c0ded7..4f4447c94 100644 --- a/src/renderer/src/components/AgentDetailPanel.tsx +++ b/src/renderer/src/components/AgentDetailPanel.tsx @@ -10,6 +10,8 @@ import { MessageQueueComposer } from './MessageQueueComposer'; import { CommandCenterPanel } from './CommandCenterPanel'; import { disposeTerminal } from './terminalPool'; import { SidebarTabs } from './SidebarTabs'; +import { HistoryView } from './HistoryView'; +import { ErrorBoundary } from './ErrorBoundary'; import { ThreadsPanel } from './ThreadsPanel'; import { ToolWaterfall } from './ToolWaterfall'; import { AgentControlStrip } from './AgentControlStrip'; @@ -150,7 +152,7 @@ export function AgentDetailPanel({ agent }: AgentDetailPanelProps) { display: 'flex', alignItems: 'flex-end', justifyContent: 'center', overflow: 'hidden', flexShrink: 0 }}> - +
@@ -273,6 +275,13 @@ export function AgentDetailPanel({ agent }: AgentDetailPanelProps) { ) )} + {sidebarTab === 'history' && ( + // A History render error stays inside the tab (Pam's P5, the panel error box). + + + + )} + {sidebarTab === 'git' && ( )} diff --git a/src/renderer/src/components/AgentStrip.tsx b/src/renderer/src/components/AgentStrip.tsx index 2f84a6b10..584190e4c 100644 --- a/src/renderer/src/components/AgentStrip.tsx +++ b/src/renderer/src/components/AgentStrip.tsx @@ -133,6 +133,8 @@ export function AgentStrip({ config }: AgentStripProps) { > s.trim()).join('\n').trim() }; + } + return { headline: null, body: text.trim() }; +} + +/** The picks after clicking option `i`: several toggle; one selects it, and a + * second click on it clears it. Sorted, no repeats. */ +export function togglePick(multi: boolean | undefined, cur: number[], i: number): number[] { + if (multi) return (cur.includes(i) ? cur.filter((x) => x !== i) : [...cur, i]).sort((a, b) => a - b); + return cur.length === 1 && cur[0] === i ? [] : [i]; +} + +/** When it was asked, in the window's language, or null for no stamp. */ +function askedWhen(iso: string | undefined, lang: string): string | null { + if (!iso) return null; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return null; + try { + return d.toLocaleString(lang, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }); + } catch { + return d.toLocaleString(); + } +} + +/** The button's words for the picks: their labels, cut at 40 characters. */ +function choiceText(entry: HumanQAFields, picks: number[]): string { + const s = (entry.options ?? []).filter((_, i) => picks.includes(i)).map((o) => o.label).join(', '); + return s.length > 40 ? s.slice(0, 39) + '…' : s; +} + +function Tag({ children, bg }: { children: ReactNode; bg: string }) { + return ( + {children} + ); +} + +const small: CSSProperties = { fontSize: 12, lineHeight: '16px', color: 'var(--cth-ink-500)' }; + +export interface AskMeQuestionProps { + entry: HumanQAFields; + draft: string; + onDraft: (text: string) => void; + /** Picked options, as indexes of entry.options. */ + picks: number[]; + onPicks: (picks: number[]) => void; + onSend: () => void; + sending: boolean; + /** Not sent: the question changed or closed first ('gone'), or anything else ('failed'). */ + notSent?: 'gone' | 'failed'; + /** What the person wrote for a question that changed or closed, to copy. */ + kept?: string; + /** Closed elsewhere: read only, no box and no answer button, until dismissed. */ + closed?: boolean; +} + +/** One ASK ME question (Pam's P4): a kind tag and when it was asked, a bold + * headline, the options when the god gave some, and the note or answer box. A + * click on an option only picks; the button names the answer and sends it. */ +export function AskMeQuestion({ entry, draft, onDraft, picks, onPicks, onSend, sending, notSent, kept, closed }: AskMeQuestionProps) { + const { t, i18n } = useTranslation(); + // Content follows its own direction only in an Arabic window: an English card never flips. + const rtl = useRtl(); + const options = entry.options ?? []; + const hasOptions = options.length > 0; + const { headline, body } = splitHeadline(entry.q); + const when = askedWhen(entry.askedAt, i18n.language); + const answer = composeAnswer(entry.options, picks, draft); + const interactive = !closed && !sending; + + const notSentRow = (line: string) => ( +
+ {t('askMe.notSent')} + {line} +
+ ); + const keptBox = kept ? ( +
+ {t('askMe.goneKept')} +
{kept}
+
+ ) : null; + + const label = sending + ? t('askMe.sending') + : picks.length + ? t('askMe.answerWith', { choice: choiceText(entry, picks) }) + : hasOptions && !draft.trim() + ? t('askMe.pickFirst') + : t('askMe.respond'); + + return ( +
+ {/* A closed card kept only for what was written has no question to tag. */} + {!(closed && !entry.q) &&
+ + {t(hasOptions ? 'askMe.kind.choose' : 'askMe.kind.question')} + + {hasOptions && entry.multi && {t('askMe.pickAny')}} + {when && {t('askMe.asked', { when })}} +
} + + {headline && ( +
+ +
+ )} + {body && ( +
+ +
+ )} + + {hasOptions && ( +
+ {options.map((o, i) => { + const on = picks.includes(i); + return ( + + ); + })} +
+ )} + + {closed ? ( + <> + {notSentRow(t('askMe.gone'))} + {keptBox} + + ) : ( + <> +